Compare commits

...

5 Commits

14 changed files with 90 additions and 81 deletions
+3 -3
View File
@@ -174,9 +174,9 @@ allow, rendered, shuffled, with no rationale and nothing saying what is implemen
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
The verdict lands in `docs/decisions.md`.
Every new or changed markdown or HTML spelling goes to such a panel, seated by the people who read
and write that format (README `## Audience`); a question about what an app relies on seats the
developer personas.
A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
are the people who read and write that format (README `## Audience`). A question about what an app
relies on goes to the developer personas instead.
### Stated numbers
+12 -9
View File
@@ -4,18 +4,21 @@
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code
fence whose info string `adf:<type>` names the node's type, its body the node's JSON without
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
`!adf:` is claimed, and `adf` is an ordinary code block language. Convert stored markdown per
`MIGRATION.md`.
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would
join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled
`{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock`
of several text nodes is a fence per node. A `codeBlock` holding other than plain text nodes
rides the block carry, where it was refused.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects as
`JSON.parse` builds them: two adjacent text nodes CommonMark would read back as one are parted by
`!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`,
`{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text
nodes is a fence per node. A `codeBlock` holding other than plain text nodes rides the block
carry, where it was refused.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
+4 -4
View File
@@ -5,9 +5,9 @@
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
the tables below. Stored ADF needs one change: a document `0.1.0` read from empty markdown holds
no `content` key, and gets `content: []`, the empty document it meant.
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
`0.1.0` read empty markdown to that shape, meaning the empty document.
### Convert markdown
@@ -23,7 +23,7 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored)
// 0.1.0's reader was editor-normal, so a document with no content key always meant an empty one.
// 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
}
```
+9 -8
View File
@@ -175,7 +175,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or wrap a code fence whose info string opens `adf:` in `!adf:codeBlock {language="adf:…"}` with a bare fence |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
@@ -198,7 +198,7 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names, or remove the reserved directive it names; `spec/flavour.md` lists every type's attributes and body |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark, or a reserved directive (`!adf:textBreak{}`, `!adf:listBreak`, `!adf:doc`) stands where it parts nothing | make the edit the message opens with; `spec/flavour.md` lists every type's attributes and body |
## The guarantees
@@ -213,12 +213,13 @@ Serves Goals 1, 3 and 4.
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
and a code fence whose info string opens `adf:`, are claimed (escapable — `spec/flavour.md`) — and
one gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images
are error results, save an image inside another's description, which flattens into the alt text.
Converting back yields the library's canonical spelling, which round-trips byte-identically —
where it converts back at all: a parse succeeding is no promise of that, so keep the source until
the way back succeeds. ``` ` `` ` ``` reads cleanly and then refuses.
and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
mid-text and titled images are error results, save an image inside another's description, which
flattens into the alt text. Converting back yields the library's canonical spelling, which
round-trips byte-identically — where it converts back at all: a parse succeeding is no promise of
that, so keep the source until the way back succeeds. ``` ` `` ` ``` reads cleanly and then
refuses.
- Four CommonMark spellings parse without an error and build a document the reference
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
+31 -38
View File
@@ -36,28 +36,23 @@ does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` na
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
shape the editor would not.
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain
objects as `JSON.parse` builds them, since ADF is JSON. Every key and value `doc` holds counts, two
adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, with no
normalization on either side.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes
merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents
the editor holds equal write the same plain markdown.
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
`0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents the editor holds
equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes a reader would build back as one — neither carried, neither holding
`attrs` or an empty key, their marks identical, attributes included — are parted by the reserved
inline leaf `!adf:textBreak{}`, mirroring `!adf:listBreak`: it builds no node, and anywhere but
between two such nodes, or given `[content]` or `{attrs}`, it is `unsupported-node-shape`. It sits
inside every mark spelling the pair shares, `**Hello, !adf:textBreak{}world**`; a code span holds
no directive, so it closes and reopens, `` `a`!adf:textBreak{}`b` ``. A carried node never joins
its neighbour, so a carry needs no break. A mark run breaks on any difference in the mark, `attrs:
{}` against no `attrs` included.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
nothing of the text around it. A code span holds no directive, so the spans close and reopen
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## An empty key spells `empty`
@@ -84,14 +79,14 @@ the directive form, since no list marker spells the sign.
## Empty markdown is a document of no blocks
2026-10-03, a panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires
`content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` standing alone as its only block, and a named error anywhere else. The
writer panel split 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a
bare `!adf:doc`.
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
`!adf:doc`.
## Unknown nodes ride the carry
@@ -114,10 +109,10 @@ The block carry is a code fence whose info string `adf:<type>` names the node's
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`. The
reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its own
reader.
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
or a bare `adf:` fence whose body's `type` an info string could carry, is `unsupported-node-shape`.
The reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its
own reader.
## A code block is a fence per text node
@@ -125,11 +120,9 @@ reader.
node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, each fence's info string the language; its other attributes sit on the opener, and a
language no info string carries stays the opener's `language` with bare fences. A `codeBlock`
spelling `content=empty` has no fence to carry the language, so the opener does. Fences with
differing info strings are `unsupported-node-shape` — ADF holds one language — and so is an empty
fence beside another, since a text node holds text.
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
language, and none is empty beside another, since a text node holds text. The grammar:
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
## Foreign HTML sorts three ways
@@ -372,14 +365,14 @@ handles one cause alike whichever node, attribute or direction raised it.
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
missing its closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
code — the spelling is well formed, and telling that apart from a typo is what a consumer
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
that code, and the names the flavour reserves part on form: a form the grammar does not have is
a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
type, `!adf:textBreak{}` anything but two text nodes a reader joins, `!adf:doc` standing beside
another block (2026-09-01, the text break and `doc` 2026-10-03).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
— the spelling is well formed, and telling that apart from a typo is what a consumer switches on
when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
and the names the flavour reserves part on form: a form the grammar does not have is a claim code
— `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
`unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type,
`!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
+3 -3
View File
@@ -4,7 +4,7 @@ The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` p
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
string opens `adf:` is the opaque carry (wrap it as a bare fence in
string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
`!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
@@ -181,8 +181,8 @@ product). Block and inline positions canonicalize differently, each fitting wher
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or an
`adf:` fence whose type an info string carries, is a named error.
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a bare
`adf:` fence whose body's `type` an info string could carry, is a named error.
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute.
-1
View File
@@ -77,7 +77,6 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
return marksKey(left) === marksKey(right)
}
// Callers say when two nodes join.
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
+1 -1
View File
@@ -54,7 +54,7 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
// Merging text renumbers siblings, so the caller's document names the path of the refusal the editor-normal one meets.
// Merging text renumbers siblings, so a refusal's path comes from the caller's document.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
}
+2 -2
View File
@@ -23,10 +23,10 @@ export function readEmptyKeys(type: string, attributes: DirectiveAttributes, key
for (const key of keys) {
const spelled = rest.get(key)
if (spelled === undefined) continue
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) }
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} takes only the value ${emptyValue}: this one spells ${key}=${spelled.spelling}`) }
empty.add(key)
rest.delete(key)
}
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside an attribute it holds`) }
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside another attribute, an argument or content: drop attrs=empty or the rest`) }
return { value: { empty, rest } }
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function readCarriedBlock(type: string, body: string, depth: number): Rea
}
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) }
return { fault: unsupportedNodeShape(`the ${carryFencePrefix} fence holds a type its info string can carry: spell the fence ${carryFencePrefix}${read.value.type} and drop type from the JSON`) }
}
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
+3 -3
View File
@@ -72,7 +72,7 @@ type SharedScan = Pick<Scan, 'definitions' | 'path'>
type SlotContent = { carry: boolean; nodes: Inline[] }
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
const carriedInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
@@ -247,7 +247,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
const content = slot.value?.nodes
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path)
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (!node.ok) return node
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'carry', node: node.value })
@@ -488,7 +488,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner, scan, false)
if (!nodes.ok) return nodes
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path)
return success(adfNodes(nodes.value).map(altText).join(''))
}
+13 -5
View File
@@ -166,7 +166,7 @@ test('reads the codeBlock directive body as the node content, the info string it
test('names the slot a codeBlock spells its language outside of', () => {
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), "unsupported-node-shape: move language=rust to the fence's info string: a fence carries the language wherever its info string can")
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
@@ -376,7 +376,7 @@ test('reads the carry fence back to the node its info string names and its JSON
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')),
'unsupported-node-shape: the adf:blockCard fence names its node\'s type: this JSON holds a type as well; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block',
)
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard')
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the adf: fence holds a type its info string can carry: spell the fence adf:blockCard and drop type from the JSON')
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON; !adf:codeBlock {language="adf:\\\\"} around a bare fence keeps it a code block'
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
@@ -441,7 +441,7 @@ test('names the number no JSON spelling carries in an opaque carry', () => {
})
test('names the mark spelling no opaque carry sits inside', () => {
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
const named = 'unsupported-node-shape: move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
@@ -1095,8 +1095,8 @@ test('reads the text break only between two text nodes CommonMark joins, buildin
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
}
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text')
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this content slot: a slot holds one text node')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this image description: the description reads as plain alt text')
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
})
@@ -1110,4 +1110,12 @@ test('leads a refusal ordinary editing meets with the edit that fixes it', () =>
content(markdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')),
'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none',
)
assert.equal(
content(markdownToAdf('!adf:codeBlock {attrs=empty}\n```js\na\n```\n```js\nb\n```\n!adf:/codeBlock\n')),
"unsupported-node-shape: remove attrs=empty to give the codeBlock the fence's language: attrs=empty holds no language, and this fence names one",
)
assert.equal(
content(markdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n')),
'unsupported-node-shape: panel spells attrs=empty beside another attribute, an argument or content: drop attrs=empty or the rest',
)
})
+6 -1
View File
@@ -256,10 +256,15 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
const attribute = nodeAttrs(node)['language']
const fromFence = first.language !== ''
const slot = languageSlot(fromFence ? first.language : attribute)
if (!fromFence && slot.kind === 'fence') {
return failure('unsupported-node-shape', `move language=${slot.info} to the fence's info string: a fence carries the language wherever its info string can`, path)
}
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
}
if (fromFence && emptyKeys(node).includes('attrs')) return failure('unsupported-node-shape', `${node.type} spells attrs=empty beside an attribute it holds`, path)
if (fromFence && emptyKeys(node).includes('attrs')) {
return failure('unsupported-node-shape', `remove attrs=empty to give the ${node.type} the fence's language: attrs=empty holds no language, and this fence names one`, path)
}
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
}
+2 -2
View File
@@ -33,7 +33,7 @@
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
| 54 | 0.2.0 | | **Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.** | 2 | 2 | 2 | 5 | 1 | 11.5 |
| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
@@ -117,7 +117,7 @@ The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesD
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either
the entry names it with its reason, or the reader stops importing it.
### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table