Merge pull request 'Read the node tables backwards, a directive to the node and marks it names' (#36) from tick-3g into main
CI / gate (push) Successful in 5s

This commit was merged in pull request #36.
This commit is contained in:
2026-09-01 14:47:43 +02:00
34 changed files with 671 additions and 150 deletions
+20 -2
View File
@@ -89,7 +89,11 @@ direction hits it. A claim code names the spelling claimed, never the node that
a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. A directive whose name reads back to no node is spelling writes rides the carry with its node. 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 `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 apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. What
the grammar itself refuses stays a claim code, key order among it; 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 its content model does not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way. A
refusal found before its position is known — the block walk's, a directive reader's — is a refusal found before its position is known — the block walk's, a directive reader's — is a
`ConvertFault`, the code and message without the path; the node walk attaches the path as it `ConvertFault`, the code and message without the path; the node walk attaches the path as it
descends, so a document reports its first error in document order. descends, so a document reports its first error in document order.
@@ -118,6 +122,14 @@ The corpus, all checked in: hand-built fixtures per node and combination; real s
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against
`markdownToAdf` and `markdownToHtml`. `markdownToAdf` and `markdownToHtml`.
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
named before its first em dash, with the attributes following `Attributes: ` — a parenthesized
value set reading `string` — and must equal the tables in `adf/`. Keep prose in those sections out
of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
both answer to the round-trip corpus and to nothing else where a node has no fixture.
## 11. Code rules ## 11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order - Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
@@ -158,9 +170,15 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
part of a construct the root already holds — a grammar stays in one file rather than splitting part of a construct the root already holds — a grammar stays in one file rather than splitting
across the seam. A rule both across the seam. A rule both
directions must answer alike — whether a list marker interrupts a paragraph — is one function directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. there too, never a copy per direction, however conservative the copy would be. Where the rule is
the emitter's own choice, input consults it rather than restating it: the parser asks
`commonMarkSpelling` which form the emitter picks, so no fixture the emitter writes can be
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy. format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so
decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a
number is the markdown flavour's choice, not ADF's.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a - Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
file does not repeat its directory in its name — `adf/document.ts`, never file does not repeat its directory in its name — `adf/document.ts`, never
`adf/adf-document.ts`. `adf/adf-document.ts`.
@@ -0,0 +1 @@
malformed-directive
+3
View File
@@ -0,0 +1,3 @@
:::adf
{"type":"rule"}
:::
@@ -0,0 +1 @@
malformed-directive
@@ -0,0 +1 @@
::media {type=file id=a-1}
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
:::panel info {bogus=1}
Part.
:::
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
:::rule
Part.
:::
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
Part :mention[@Mikael]{id=b1c2}.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
::rule
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
::rule {localId="a-1"}
+69 -56
View File
@@ -112,7 +112,10 @@ quoted value holds `}` unescaped. All values are strings at the grammar level; e
assigns types. assigns types.
Canonical form orders keys alphabetically, spells values bare wherever allowed, escapes inside Canonical form orders keys alphabetically, spells values bare wherever allowed, escapes inside
quotes in the shortest form each escape has, and omits empty `{attrs}` except where the `{` itself quotes in the shortest form each escape has, and omits empty `{attrs}` except where the `{` itself
claims the directive (`:hardBreak{}`). claims the directive (`:hardBreak{}`). Input reads that spelling alone: keys out of order, a value
quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` the name or the
`[content]` already claims, and a number or `json` value outside its canonical JSON spelling are
each a named error naming the spelling to write instead.
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right
@@ -153,7 +156,9 @@ included, is an error result naming it. The flavour never emits raw HTML.
The directive name is always the ADF node type. A container's body is the node's `content`; a The directive name is always the ADF node type. A container's body is the node's `content`; a
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is
written; validity against ADF's content models stays the author's business (AGENTS.md §14). written; validity against ADF's content models stays the author's business (AGENTS.md §14). It
parses only in the form the emitter picks, though: a directive spelling a 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 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 payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
@@ -179,21 +184,21 @@ CommonMark spells `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`
cannot — `localId` (string) on any of them, marks, and the values below — takes the directive cannot — `localId` (string) on any of them, marks, and the values below — takes the directive
form. form.
- `blockquote`, `bulletList`, `listItem` — containers, block body; no attributes beyond `localId`. - `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
- `codeBlock` — container, body one fenced code block whose info string is the language and whose - `codeBlock` — container, body one fenced code block whose info string is the language and whose
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`, content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`
`uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, the (string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or an the reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or
entity reference — rides the `language` attribute instead and the fence carries no info string; an entity reference — rides the `language` attribute instead and the fence carries no info
writing both is a named error. string; writing both is a named error.
- `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#` - `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
count, so a heading carrying none, or one that is no whole number from 1 to 6, has no CommonMark the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
spelling. CommonMark spelling.
- `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number). - `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
`order` is the first marker, so a list carrying none, one that is no whole number from 0, or one (number). `order` is the first marker, so a list carrying none, one that is no whole number
whose markers would run past 999999999, has no CommonMark spelling. from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body; no attributes beyond `localId`. - `paragraph` — container, inline body. Attributes: `localId` (string).
- `rule` — leaf; no attributes beyond `localId`. - `rule` — leaf. Attributes: `localId` (string).
```` ````
:::codeBlock {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8 wrap=true} :::codeBlock {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8 wrap=true}
@@ -205,9 +210,10 @@ fn main() {}
### Panel ### Panel
Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`). - `panel` — container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip`
Attributes: `localId` (string), `panelColor` (string), `panelIcon` (string), `panelIconId` `warning`). Attributes: `localId` (string), `panelColor` (string), `panelIcon` (string),
(string), `panelIconText` (string) — the editor writes the last four for `custom` panels. `panelIconId` (string), `panelIconText` (string) — the editor writes the last four for `custom`
panels.
``` ```
:::panel warning :::panel warning
@@ -217,8 +223,8 @@ Check the collation before importing.
### Expand ### Expand
`expand` and `nestedExpand`: containers, no arg same syntax, two node types, the name picks - `expand`, `nestedExpand` containers, no arg; same syntax, two node types, the name picks
which. Attributes: `localId` (string), `title` (string). which. Attributes: `localId` (string), `title` (string).
``` ```
:::expand {title="Full build log"} :::expand {title="Full build log"}
@@ -236,7 +242,7 @@ which. Attributes: `localId` (string), `title` (string).
`layout` (`align-end` `align-start` `center` `full-width` `wide` `wrap-left` `wrap-right`), `layout` (`align-end` `align-start` `center` `full-width` `wide` `wrap-left` `wrap-right`),
`localId` (string), `width` (number), `widthType` (`percentage` `pixel`). `localId` (string), `width` (number), `widthType` (`percentage` `pixel`).
- `caption` — container, inline body. Attributes: `localId` (string). - `caption` — container, inline body. Attributes: `localId` (string).
- `mediaGroup` — container of `::media` leaves, no attributes. - `mediaGroup` — container of `::media` leaves. Attributes: none.
``` ```
::::mediaSingle {layout=center width=50} ::::mediaSingle {layout=center width=50}
@@ -306,11 +312,13 @@ Bolt M8
::::: :::::
``` ```
Attributes — `table`: `displayMode` (`default` `fixed`), `isNumberColumnEnabled` (boolean), - `table` — container of `tableRow` containers. Attributes: `displayMode` (`default` `fixed`),
`layout` (`align-end` `align-start` `center` `default` `full-width` `wide`), `localId` `isNumberColumnEnabled` (boolean), `layout` (`align-end` `align-start` `center` `default`
(string), `width` (number, pixels). `tableRow`: `localId` (string). `tableCell` and `full-width` `wide`), `localId` (string), `width` (number, pixels).
`tableHeader`: `background` (string), `colspan` (number), `colwidth` (json, one pixel width per - `tableRow` — container of cells. Attributes: `localId` (string).
spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `middle` `top`). - `tableCell`, `tableHeader` — containers, block body. Attributes: `background` (string),
`colspan` (number), `colwidth` (json, one pixel width per spanned column), `localId` (string),
`rowspan` (number), `valign` (`bottom` `middle` `top`).
### Task and decision lists ### Task and decision lists
@@ -318,7 +326,8 @@ spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `mid
Attributes: `localId` (string). Attributes: `localId` (string).
- `taskItem` — container, inline body; the arg is the state (`DONE` `TODO`). Attributes: - `taskItem` — container, inline body; the arg is the state (`DONE` `TODO`). Attributes:
`localId` (string). `localId` (string).
- `blockTaskItem` — container, block body; arg and attributes as `taskItem`. - `blockTaskItem` — container, block body; the arg is the state as `taskItem`. Attributes:
`localId` (string).
- `decisionList` — container of `decisionItem` directives. Attributes: `localId` (string). - `decisionList` — container of `decisionItem` directives. Attributes: `localId` (string).
- `decisionItem` — container, inline body. Attributes: `localId` (string), `state` (string — - `decisionItem` — container, inline body. Attributes: `localId` (string), `state` (string —
free-form; the editor writes `DECIDED`). free-form; the editor writes `DECIDED`).
@@ -336,9 +345,9 @@ Ship it
### Layout ### Layout
`layoutSection` — container of `layoutColumn` containers; attributes: `localId` (string). - `layoutSection` — container of `layoutColumn` containers. Attributes: `localId` (string).
`layoutColumn` — container, block body; attributes: `localId` (string), `valign` (`bottom` - `layoutColumn` — container, block body. Attributes: `localId` (string), `valign` (`bottom`
`middle` `top`), `width` (number — percent). `middle` `top`), `width` (number — percent).
``` ```
::::layoutSection ::::layoutSection
@@ -353,10 +362,11 @@ Right.
### Extensions ### Extensions
`extension` — leaf. `bodiedExtension` — container, block body. `multiBodiedExtension` — - `extension`, `bodiedExtension`, `multiBodiedExtension` — a leaf, a container with a block body,
container of `extensionFrame` containers; `extensionFrame` — container, block body, no and a container of `extensionFrame` containers. Attributes: `extensionKey` (string),
attributes. The other three share: `extensionKey` (string), `extensionType` (string), `layout` `extensionType` (string), `layout` (`default` `full-width` `wide`), `localId` (string),
(`default` `full-width` `wide`), `localId` (string), `parameters` (json), `text` (string). `parameters` (json), `text` (string).
- `extensionFrame` — container, block body. Attributes: none.
``` ```
::extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":2}"} ::extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":2}"}
@@ -364,8 +374,8 @@ attributes. The other three share: `extensionKey` (string), `extensionType` (str
### Sync blocks ### Sync blocks
`syncBlock` — leaf. `bodiedSyncBlock` — container, block body. Attributes: `localId` (string), - `syncBlock`, `bodiedSyncBlock` — a leaf and a container with a block body. Attributes:
`resourceId` (string). `localId` (string), `resourceId` (string).
``` ```
::syncBlock {localId=0198f3a2-af74-7e91-b26c-70b15f4d9ec3 resourceId="ari:cloud:confluence:site/page/123"} ::syncBlock {localId=0198f3a2-af74-7e91-b26c-70b15f4d9ec3 resourceId="ari:cloud:confluence:site/page/123"}
@@ -381,18 +391,19 @@ is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spellin
into the slot. The rest take no content, `:text` included; content on a node that takes none is a into the slot. The rest take no content, `:text` included; content on a node that takes none is a
named error. named error.
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string). - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
- `hardBreak` — `localId` (string), `text` (string). (string).
- `inlineCard` — `data` (json), `localId` (string), `url` (string); real payloads carry one or - `hardBreak` — Attributes: `localId` (string), `text` (string).
the other. - `inlineCard` — Attributes: `data` (json), `localId` (string), `url` (string); real payloads
- `mediaInline` — `alt` (string), `collection` (string), `data` (json), `height` (number), `id` carry one or the other.
(string), `localId` (string), `occurrenceKey` (string), `type` (`file` `image` `link`), `width` - `mediaInline` — Attributes: `alt` (string), `collection` (string), `data` (json), `height`
(number). (number), `id` (string), `localId` (string), `occurrenceKey` (string), `type` (`file` `image`
- `mention` — `accessLevel` (`APPLICATION` `CONTAINER` `NONE` `SITE`), `id` (string), `localId` `link`), `width` (number).
(string), `text` (string), `userType` (`APP` `DEFAULT` `SPECIAL`). - `mention` — Attributes: `accessLevel` (`APPLICATION` `CONTAINER` `NONE` `SITE`), `id` (string),
- `status` — `color` (`blue` `green` `neutral` `purple` `red` `yellow`), `localId` (string), `localId` (string), `text` (string), `userType` (`APP` `DEFAULT` `SPECIAL`).
`style` (string), `text` (string). - `status` — Attributes: `color` (`blue` `green` `neutral` `purple` `red` `yellow`), `localId`
(string), `style` (string), `text` (string).
``` ```
:status[In review]{color=yellow} — :mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4} :status[In review]{color=yellow} — :mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4}
@@ -415,14 +426,16 @@ the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identic
## Marks ## Marks
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
`marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, which hold `marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, and are
no attributes beyond `link`'s `href` and `title`, and are not directive names: `:em[x]` is a named not directive names: `:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline`
error. The other four are inline directives, content required non-empty: are inline directives, content required non-empty.
- `border` — `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 13). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 13).
- `subsup` — `type` (`sub` `sup`). - `code`, `em`, `strike`, `strong` — Attributes: none.
- `textColor` — `color` (string, `#rrggbb`). - `link` — Attributes: `href` (string), `title` (string).
- `underline` — no attributes. - `subsup` — Attributes: `type` (`sub` `sup`).
- `textColor` — Attributes: `color` (string, `#rrggbb`).
- `underline` — Attributes: none.
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_:underline[x]_` gives marks `[em, underline]`, `:underline[_x_]` the reverse. outermost first: `_:underline[x]_` gives marks `[em, underline]`, `:underline[_x_]` the reverse.
+8 -6
View File
@@ -1,15 +1,17 @@
import type { AdfAttributes } from './document.ts' import type { AdfAttributes } from './document.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
type AttributeKind = 'boolean' | 'json' | 'number' | 'string' export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>> export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
export type VocabularyPair = export type VocabularyValue =
| { key: string; kind: 'boolean'; value: boolean } | { kind: 'boolean'; value: boolean }
| { key: string; kind: 'json'; value: JsonValue } | { kind: 'json'; value: JsonValue }
| { key: string; kind: 'number'; value: number } | { kind: 'number'; value: number }
| { key: string; kind: 'string'; value: string } | { kind: 'string'; value: string }
export type VocabularyPair = VocabularyValue & { key: string }
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined { export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined {
const pairs: VocabularyPair[] = [] const pairs: VocabularyPair[] = []
+1 -1
View File
@@ -41,7 +41,7 @@ const mediaAttributes: AttributeVocabulary = {
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' } const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
const blockDirectives = { export const blockDirectives = {
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' }, blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
blockquote: { attributes: localIdAttributes, contentModel: 'block' }, blockquote: { attributes: localIdAttributes, contentModel: 'block' },
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' }, bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
+1 -1
View File
@@ -37,7 +37,7 @@ export function isAdfDocument(value: unknown): value is AdfDocument {
return !('content' in value) || isNodeArray(value['content']) return !('content' in value) || isNodeArray(value['content'])
} }
function isAdfMark(value: unknown): value is AdfMark { export function isAdfMark(value: unknown): value is AdfMark {
if (!isRecord(value) || !holdsOnly(value, markKeys)) return false if (!isRecord(value) || !holdsOnly(value, markKeys)) return false
if (typeof value['type'] !== 'string') return false if (typeof value['type'] !== 'string') return false
return !('attrs' in value) || isAttributes(value['attrs']) return !('attrs' in value) || isAttributes(value['attrs'])
+4 -4
View File
@@ -5,9 +5,9 @@ export type InlineDirective = {
textAttribute?: string textAttribute?: string
} }
const inlineDirectives: Readonly<Record<string, InlineDirective>> = { export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } }, date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, textAttribute: 'text' }, emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' } }, hardBreak: { attributes: { localId: 'string', text: 'string' } },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } }, inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
mediaInline: { mediaInline: {
@@ -23,8 +23,8 @@ const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
width: 'number', width: 'number',
}, },
}, },
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, textAttribute: 'text' }, mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string' }, textAttribute: 'text' }, status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' },
} }
export function inlineDirective(type: string): InlineDirective | undefined { export function inlineDirective(type: string): InlineDirective | undefined {
+21
View File
@@ -17,6 +17,8 @@ const roundTripRoot = join(corpusRoot, 'round-trip')
const unspellableRoot = join(corpusRoot, 'unspellable') const unspellableRoot = join(corpusRoot, 'unspellable')
const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry'] const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
// A directory joins once every fixture in it reads back to its document.
const parsingDirectories = ['commonmark-subset']
function directoryNames(root: string): string[] { function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true }) return readdirSync(root, { withFileTypes: true })
@@ -88,6 +90,25 @@ for (const directory of emittingDirectories) {
} }
} }
test('every parsing directory is one of the emitting directories', () => {
assert.deepEqual(
parsingDirectories.filter((directory) => emittingDirectories.includes(directory)),
parsingDirectories,
)
})
for (const directory of parsingDirectories) {
for (const name of fixtureNames(directory, '.md')) {
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
})
}
}
function roundTripFixtures(): { name: string; path: string }[] { function roundTripFixtures(): { name: string; path: string }[] {
return emittingDirectories.flatMap((directory) => return emittingDirectories.flatMap((directory) =>
fixtureNames(directory, '.json').map((name) => ({ name: `${directory}/${name}`, path: join(roundTripRoot, directory, `${name}.json`) })), fixtureNames(directory, '.json').map((name) => ({ name: `${directory}/${name}`, path: join(roundTripRoot, directory, `${name}.json`) })),
+23
View File
@@ -0,0 +1,23 @@
import type { AdfMark } from '../adf/document.ts'
import type { JsonValue } from '../json-value.ts'
import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export const marksAttribute = 'marks'
export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = mark.attrs ?? {}
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
}
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
if (!Array.isArray(value) || value.length === 0) return undefined
const marks: AdfMark[] = []
for (const item of value) {
if (!isAdfMark(item)) return undefined
marks.push(item)
}
return serializeCanonicalJson(markValues(marks), 'compact') === serializeCanonicalJson(value, 'compact') ? marks : undefined
}
+33 -16
View File
@@ -1,15 +1,18 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { DirectiveLine } from './directive-syntax.ts' import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts' import { readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
function attributes(...pairs: [string, string][]): ReadonlyMap<string, string> { // A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding.
return new Map(pairs) type Pair = [string, string, string?]
function attributes(...pairs: Pair[]): DirectiveAttributes {
return new Map(pairs.map(([key, decoded, spelling]) => [key, { decoded, spelling: spelling ?? decoded }]))
} }
function header(colons: number, name: string, argument?: string, ...pairs: [string, string][]): { value: DirectiveLine } { function header(colons: number, name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } {
return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } } return { value: { argument, attributes: attributes(...pairs), colons, kind: 'header', name } }
} }
@@ -25,7 +28,7 @@ function inline(text: string): unknown {
return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name } return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name }
} }
function spans(text: string, name: string, content: string | undefined, ...pairs: [string, string][]): void { function spans(text: string, name: string, content: string | undefined, ...pairs: Pair[]): void {
assert.deepEqual(inline(text), { attributes: attributes(...pairs), content, length: text.length, name }) assert.deepEqual(inline(text), { attributes: attributes(...pairs), content, length: text.length, name })
} }
@@ -46,17 +49,32 @@ test('reads the leaf and container forms, their argument and their attributes',
assert.deepEqual(readDirectiveLine('::rule'), header(2, 'rule')) assert.deepEqual(readDirectiveLine('::rule'), header(2, 'rule'))
assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule')) assert.deepEqual(readDirectiveLine('::rule '), header(2, 'rule'))
assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO')) assert.deepEqual(readDirectiveLine('::taskItem TODO'), header(2, 'taskItem', 'TODO'))
assert.deepEqual(readDirectiveLine('::hardBreak {}'), header(2, 'hardBreak'))
assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file'])) assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file']))
assert.deepEqual(readDirectiveLine('::panel info {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000'])) assert.deepEqual(readDirectiveLine('::panel info {panelColor="#ff0000"} '), header(2, 'panel', 'info', ['panelColor', '#ff0000', '"#ff0000"']))
assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info')) assert.deepEqual(readDirectiveLine(':::panel info'), header(3, 'panel', 'info'))
}) })
test('decodes a quoted attribute value, the escapes {attrs} reserves included', () => { test('decodes a quoted attribute value, the escapes {attrs} reserves included', () => {
assert.deepEqual(readDirectiveLine('::extension {text="two words"}'), header(2, 'extension', undefined, ['text', 'two words'])) assert.deepEqual(readDirectiveLine('::extension {text="two words"}'), header(2, 'extension', undefined, ['text', 'two words', '"two words"']))
assert.deepEqual(readDirectiveLine('::extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'), header(2, 'extension', undefined, ['text', 'a`b&c<d|e'])) assert.deepEqual(
assert.deepEqual(readDirectiveLine('::extension {text="a\\"b\\\\c\\nd"}'), header(2, 'extension', undefined, ['text', 'a"b\\c\nd'])) readDirectiveLine('::extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'),
assert.deepEqual(readDirectiveLine('::extension {text="}{"}'), header(2, 'extension', undefined, ['text', '}{'])) header(2, 'extension', undefined, ['text', 'a`b&c<d|e', '"a\\u0060b\\u0026c\\u003cd\\u007ce"']),
)
assert.deepEqual(readDirectiveLine('::extension {text="a\\"b\\\\c\\nd"}'), header(2, 'extension', undefined, ['text', 'a"b\\c\nd', '"a\\"b\\\\c\\nd"']))
assert.deepEqual(readDirectiveLine('::extension {text="}{"}'), header(2, 'extension', undefined, ['text', '}{', '"}{"']))
})
test('names the {attrs} keys read out of the alphabetical order canonical form spells', () => {
assert.equal(fault('::media {type=file id=a-1}'), 'the {attrs} keys read in alphabetical order: id before type')
assert.deepEqual(readDirectiveLine('::media {id=a-1 type=file}'), header(2, 'media', undefined, ['id', 'a-1'], ['type', 'file']))
})
test('spells an empty {attrs} only where the brace itself claims the directive', () => {
const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive'
assert.equal(fault('::rule {}'), omitted)
assert.equal(fault(':::panel info {}'), omitted)
assert.equal(inline(':underline[a]{}'), omitted)
spans(':hardBreak{}', 'hardBreak', undefined)
}) })
test('names the directive line no spelling reads', () => { test('names the directive line no spelling reads', () => {
@@ -66,7 +84,7 @@ test('names the directive line no spelling reads', () => {
assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') assert.equal(fault('::panel info extra'), 'a directive line reads a name, one bare argument and {attrs}, one space apart')
assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') assert.equal(fault('::panel{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart')
assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') assert.equal(fault('::panel info{}'), 'a directive line reads a name, one bare argument and {attrs}, one space apart')
assert.equal(fault('::panel {} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart') assert.equal(fault('::panel {a=1} x'), 'a directive line reads a name, one bare argument and {attrs}, one space apart')
}) })
test('names the attributes no spelling reads', () => { test('names the attributes no spelling reads', () => {
@@ -97,7 +115,7 @@ test('reads an inline directive only where a bracket or a brace follows the name
assert.equal(inline(':mention @A'), 'unclaimed') assert.equal(inline(':mention @A'), 'unclaimed')
spans(':mention[@A]', 'mention', '@A') spans(':mention[@A]', 'mention', '@A')
spans(':date{timestamp=1756080000000}', 'date', undefined, ['timestamp', '1756080000000']) spans(':date{timestamp=1756080000000}', 'date', undefined, ['timestamp', '1756080000000'])
spans(':emoji[]{shortName=":tada:"}', 'emoji', '', ['shortName', ':tada:']) spans(':emoji[]{shortName=":tada:"}', 'emoji', '', ['shortName', ':tada:', '":tada:"'])
spans(':underline[ a ]', 'underline', ' a ') spans(':underline[ a ]', 'underline', ' a ')
}) })
@@ -107,10 +125,9 @@ test('binds an inline directive as a unit, its content balancing brackets like l
spans(':underline[a `]` b]', 'underline', 'a `]` b') spans(':underline[a `]` b]', 'underline', 'a `]` b')
spans(':underline[a `b c]', 'underline', 'a `b c') spans(':underline[a `b c]', 'underline', 'a `b c')
spans(':underline[:status[x]{color=red}]', 'underline', ':status[x]{color=red}') spans(':underline[:status[x]{color=red}]', 'underline', ':status[x]{color=red}')
spans(':status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold ']) spans(':status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold ', '"bold "'])
assert.deepEqual(inline(':underline[a]{}(b)'), { attributes: attributes(), content: 'a', length: 15, name: 'underline' })
assert.deepEqual(inline(':underline[a] {}'), { attributes: attributes(), content: 'a', length: 13, name: 'underline' }) assert.deepEqual(inline(':underline[a] {}'), { attributes: attributes(), content: 'a', length: 13, name: 'underline' })
assert.deepEqual(inline(':text{text=" "} and more'), { attributes: attributes(['text', ' ']), content: undefined, length: 15, name: 'text' }) assert.deepEqual(inline(':text{text=" "} and more'), { attributes: attributes(['text', ' ', '" "']), content: undefined, length: 15, name: 'text' })
}) })
test('names the inline directive left unclosed at the end of its line', () => { test('names the inline directive left unclosed at the end of its line', () => {
+48 -23
View File
@@ -1,13 +1,16 @@
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import type { VocabularyPair } from '../adf/attribute-vocabulary.ts'
import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts' import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from './backtick-runs.ts' import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
import { isJsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { runLength } from './emphasis-matching.ts' import { runLength } from './emphasis-matching.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export type DirectiveAttributes = ReadonlyMap<string, string> export type DirectiveValue = { decoded: string; spelling: string }
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
export type DirectiveLine = export type DirectiveLine =
| { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; colons: number; kind: 'header'; name: string }
@@ -19,7 +22,7 @@ export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: und
type Attributes = { attributes: DirectiveAttributes; length: number } type Attributes = { attributes: DirectiveAttributes; length: number }
type AttributePair = { end: number; key: string; value: string } type AttributePair = { end: number; key: string; value: DirectiveValue }
const bareTokenSource = '[A-Za-z0-9_-]+' const bareTokenSource = '[A-Za-z0-9_-]+'
const bareRun = new RegExp(bareTokenSource, 'y') const bareRun = new RegExp(bareTokenSource, 'y')
@@ -33,10 +36,21 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
const rawReserved = new RegExp(reservedSource) const rawReserved = new RegExp(reservedSource)
const noAttributes: DirectiveAttributes = new Map() const noAttributes: DirectiveAttributes = new Map()
const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive'
const nameFault = 'a directive name reads [a-z][A-Za-z0-9]*' const nameFault = 'a directive name reads [a-z][A-Za-z0-9]*'
const orderFault = 'the {attrs} keys read in alphabetical order'
const pairFault = 'an attribute reads key=value, the value bare or double-quoted' const pairFault = 'an attribute reads key=value, the value bare or double-quoted'
const shapeFault = 'a directive line reads a name, one bare argument and {attrs}, one space apart' const shapeFault = 'a directive line reads a name, one bare argument and {attrs}, one space apart'
export function attributeValue(text: string, kind: AttributeKind): VocabularyValue | undefined {
if (kind === 'string') return { kind, value: text }
if (kind === 'boolean') return text === 'true' || text === 'false' ? { kind, value: text === 'true' } : undefined
const parsed = parseJson(text)
if (parsed === undefined) return undefined
if (kind === 'json') return { kind, value: parsed }
return typeof parsed === 'number' ? { kind, value: parsed } : undefined
}
export function isBareToken(text: string): boolean { export function isBareToken(text: string): boolean {
return bareToken.test(text) return bareToken.test(text)
} }
@@ -65,7 +79,7 @@ export function readInlineDirective(text: string, index: number): Read<Directive
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
if (pairs.length === 0) return '' if (pairs.length === 0) return ''
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) const spelled = [...pairs].sort(([left], [right]) => keyOrder(left, right)).map(([key, value]) => `${key}=${value}`)
return `{${spelled.join(' ')}}` return `{${spelled.join(' ')}}`
} }
@@ -77,6 +91,13 @@ export function spellStringAttribute(text: string): string {
return isBareToken(text) ? text : quote(text) return isBareToken(text) ? text : quote(text)
} }
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(JSON.stringify(value.value))
return spellStringAttribute(value.value)
}
export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] { export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] {
return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)])
} }
@@ -85,11 +106,9 @@ export function unknownDirectiveFault(name: string): ConvertFault {
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` } return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node` }
} }
function spellAttributeValue(pair: VocabularyPair): string { function keyOrder(left: string, right: string): number {
if (pair.kind === 'boolean') return `${pair.value}` if (left < right) return -1
if (pair.kind === 'json') return spellJsonAttribute(pair.value) return left > right ? 1 : 0
if (pair.kind === 'number') return spellStringAttribute(JSON.stringify(pair.value))
return spellStringAttribute(pair.value)
} }
function quote(text: string): string { function quote(text: string): string {
@@ -122,6 +141,7 @@ function readDirectiveHeader(rest: string): Read<{ argument: string | undefined;
if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') { if (rest.charAt(cursor) === ' ' && rest.charAt(cursor + 1) === '{') {
const read = readAttributes(rest, cursor + 1) const read = readAttributes(rest, cursor + 1)
if (read.fault !== undefined) return { fault: read.fault } if (read.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
cursor += 1 + read.value.length cursor += 1 + read.value.length
} }
@@ -147,6 +167,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
if (text.charAt(cursor) === '{') { if (text.charAt(cursor) === '{') {
const read = readAttributes(text, cursor) const read = readAttributes(text, cursor)
if (read.fault !== undefined) return { fault: read.fault } if (read.fault !== undefined) return { fault: read.fault }
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) }
attributes = read.value.attributes attributes = read.value.attributes
cursor += read.value.length cursor += read.value.length
} }
@@ -192,8 +213,9 @@ function readCodeSpanEnd(text: string, index: number): number | undefined {
} }
function readAttributes(text: string, index: number): Read<Attributes> { function readAttributes(text: string, index: number): Read<Attributes> {
const attributes = new Map<string, string>() const attributes = new Map<string, DirectiveValue>()
let cursor = index + 1 let cursor = index + 1
let previous = ''
while (cursor < text.length && text.charAt(cursor) !== '}') { while (cursor < text.length && text.charAt(cursor) !== '}') {
if (attributes.size > 0) { if (attributes.size > 0) {
if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) } if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) }
@@ -201,8 +223,11 @@ function readAttributes(text: string, index: number): Read<Attributes> {
} }
const pair = readAttributePair(text, cursor) const pair = readAttributePair(text, cursor)
if (pair.fault !== undefined) return { fault: pair.fault } if (pair.fault !== undefined) return { fault: pair.fault }
if (attributes.has(pair.value.key)) return { fault: malformedDirective(`the attribute key ${pair.value.key} is spelled twice`) } const key = pair.value.key
attributes.set(pair.value.key, pair.value.value) if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) }
if (keyOrder(previous, key) > 0) return { fault: malformedDirective(`${orderFault}: ${key} before ${previous}`) }
previous = key
attributes.set(key, pair.value.value)
cursor = pair.value.end cursor = pair.value.end
} }
if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') } if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') }
@@ -220,29 +245,29 @@ function readAttributePair(text: string, index: number): Read<AttributePair> {
return { value: { end: quoted.value.end, key, value: quoted.value.value } } return { value: { end: quoted.value.end, key, value: quoted.value.value } }
} }
bareRun.lastIndex = start bareRun.lastIndex = start
const value = bareRun.exec(text)?.[0] const bare = bareRun.exec(text)?.[0]
if (value === undefined) return { fault: malformedDirective(pairFault) } if (bare === undefined) return { fault: malformedDirective(pairFault) }
return { value: { end: start + value.length, key, value } } return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
} }
function readQuotedValue(text: string, index: number): Read<{ end: number; value: string }> { function readQuotedValue(text: string, index: number): Read<{ end: number; value: DirectiveValue }> {
let cursor = index + 1 let cursor = index + 1
while (cursor < text.length && text.charAt(cursor) !== '"') cursor += text.charAt(cursor) === '\\' ? 2 : 1 while (cursor < text.length && text.charAt(cursor) !== '"') cursor += text.charAt(cursor) === '\\' ? 2 : 1
if (text.charAt(cursor) !== '"') return { fault: malformedDirective('the {attrs} quoted value is unclosed') } if (text.charAt(cursor) !== '"') return { fault: malformedDirective('the {attrs} quoted value is unclosed') }
const raw = text.slice(index, cursor + 1) const spelling = text.slice(index, cursor + 1)
const character = rawReserved.exec(raw)?.[0] const character = rawReserved.exec(spelling)?.[0]
if (character !== undefined) { if (character !== undefined) {
return { fault: malformedDirective(`a raw ${character} inside {attrs} breaks the directive: spell it \\u${escapeDigits(character)}`) } return { fault: malformedDirective(`a raw ${character} inside {attrs} breaks the directive: spell it \\u${escapeDigits(character)}`) }
} }
const value = parseJsonString(raw) const parsed = parseJson(spelling)
if (value === undefined) return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') } if (typeof parsed !== 'string') return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') }
return { value: { end: cursor + 1, value } } return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
} }
function parseJsonString(raw: string): string | undefined { function parseJson(raw: string): JsonValue | undefined {
try { try {
const value: unknown = JSON.parse(raw) const value: unknown = JSON.parse(raw)
return typeof value === 'string' ? value : undefined return isJsonValue(value) ? value : undefined
} catch { } catch {
return undefined return undefined
} }
+6
View File
@@ -86,6 +86,12 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
return emitDirectiveBlock(node, directive, path, depth) return emitDirectiveBlock(node, directive, path, depth)
} }
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number): Result<null> | undefined {
const readable = readableBlock(node, path, depth)
if (readable === undefined) return undefined
return readable.ok ? success(null) : readable
}
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth) if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
+3 -10
View File
@@ -1,8 +1,8 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import type { JsonValue } from '../../json-value.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
@@ -14,7 +14,7 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = node.marks ?? [] const marks = node.marks ?? []
if (marks.length > 0) spelledPairs.push(['marks', spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs) const attributes = spellAttributes(spelledPairs)
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
} }
@@ -25,10 +25,3 @@ function spellArgument(node: AdfNode, argumentAttribute: string | undefined): st
if (typeof value !== 'string' || !isBareToken(value)) return undefined if (typeof value !== 'string' || !isBareToken(value)) return undefined
return ` ${value}` return ` ${value}`
} }
function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = mark.attrs ?? {}
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
}
+1 -1
View File
@@ -90,7 +90,7 @@ test('holds a directive container open until the fence that closes it', () => {
assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [ assert.deepEqual(parseBlocks(':::panel info {panelColor="#ff0000"}\nPart.\n:::\n').blocks, [
{ {
argument: 'info', argument: 'info',
attributes: new Map([['panelColor', '#ff0000']]), attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
blocks: [{ kind: 'paragraph', text: 'Part.' }], blocks: [{ kind: 'paragraph', text: 'Part.' }],
kind: 'directive', kind: 'directive',
name: 'panel', name: 'panel',
+1 -1
View File
@@ -31,7 +31,7 @@ export type Block =
export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> } export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
type DirectiveBlock = Extract<Block, { kind: 'directive' }> export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }> type ListBlock = Extract<Block, { items: Block[][] }>
+94
View File
@@ -0,0 +1,94 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
import type { BlockDirective } from '../../adf/block-directives.ts'
import type { DirectiveAttributes, DirectiveSpan, DirectiveValue } from '../directive-syntax.ts'
import { attributeValue, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts'
import { carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
type Elsewhere = { key: string; slot: 'argument' | 'content' }
export function readBlockDirectiveNode(
name: string,
argument: string | undefined,
attributes: DirectiveAttributes,
path: ConvertErrorPath,
): Result<BlockDirectiveNode> {
if (name === carryName) {
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the fence`, path)
}
const directive = blockDirective(name)
if (directive === undefined) return faulted(unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name)
const rest = new Map(attributes)
rest.delete(marksAttribute)
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path)
if (!attrs.ok) return attrs
if (argument !== undefined) {
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument`, path)
attrs.value[argumentKey] = argument
}
const spelled = attributes.get(marksAttribute)
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks
return success({ contentModel: directive.contentModel, node: namedNode(name, attrs.value, marks.value) })
}
export function readInlineDirectiveNode(span: DirectiveSpan, path: ConvertErrorPath): Result<AdfNode> {
const directive = inlineDirective(span.name)
if (directive === undefined) return faulted(unknownDirectiveFault(span.name), path)
const slot = directive.textAttribute
if (span.content !== undefined) {
const message = slot === undefined ? `${span.name} takes no content` : `the content slot ${span.name} spells its ${slot} attribute in is unsupported`
return failure('unsupported-node-shape', message, path)
}
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
const attrs = readVocabulary(span.name, span.attributes, directive.attributes, elsewhere, path)
if (!attrs.ok) return attrs
return success(namedNode(span.name, attrs.value, undefined))
}
function readVocabulary(
type: string,
attributes: DirectiveAttributes,
vocabulary: AttributeVocabulary,
elsewhere: Elsewhere | undefined,
path: ConvertErrorPath,
): Result<AdfAttributes> {
const attrs: AdfAttributes = {}
for (const [key, spelled] of attributes) {
if (key === elsewhere?.key) {
const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot'
return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}`, path)
}
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute`, path)
const read = attributeValue(spelled.decoded, kind)
if (read === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path)
const spelling = spellAttributeValue(read)
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
attrs[key] = read.value
}
return success(attrs)
}
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
const read = attributeValue(spelled.decoded, 'json')
const marks = read === undefined || spellAttributeValue(read) !== spelled.spelling ? undefined : readMarkValues(read.value)
if (marks === undefined) {
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON`, path)
}
return success(marks)
}
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode {
const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type }
return marks === undefined ? named : { ...named, marks: [...marks] }
}
+8 -2
View File
@@ -7,7 +7,8 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readInlineDirective, unknownDirectiveFault } from '../directive-syntax.ts' import { readInlineDirective } from '../directive-syntax.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts'
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] } export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
@@ -144,7 +145,12 @@ function readDirective(scan: Scan, index: number): Result<number> {
scan.pending += ':' scan.pending += ':'
return success(index + 1) return success(index + 1)
} }
return faulted(directive.fault ?? unknownDirectiveFault(directive.value.name), scan.path) if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const node = readInlineDirectiveNode(directive.value, scan.path)
if (!node.ok) return node
flush(scan, false)
pushNode(scan, node.value)
return success(index + directive.value.length)
} }
function flush(scan: Scan, strip: boolean): void { function flush(scan: Scan, strip: boolean): void {
+103 -6
View File
@@ -3,6 +3,7 @@ import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts' import type { Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts'
import { markdownToAdf } from './markdown-to-adf.ts' import { markdownToAdf } from './markdown-to-adf.ts'
const em: AdfMark = { type: 'em' } const em: AdfMark = { type: 'em' }
@@ -134,16 +135,112 @@ test('claims a block-level colon run with no directive to parse it', () => {
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')]) assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
}) })
test('reads the three directive forms, and names the node none of them reads back to', () => { test('reads the three directive forms into the nodes the tables name', () => {
assert.equal(code(markdownToAdf('::rule\n')), 'unknown-directive-name') assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'unknown-directive-name') assert.deepEqual(content(markdownToAdf('::paragraph\n')), [{ type: 'paragraph' }])
assert.equal(code(markdownToAdf('Part :mention[@A]{id=b1c2}.\n')), 'unknown-directive-name') assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [
assert.equal(content(markdownToAdf('::rule\n')), 'unknown-directive-name: the directive name rule reads back to no node') { attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' },
assert.deepEqual(path(markdownToAdf('Part.\n\n::rule\n')), ['content', 1]) ])
assert.deepEqual(content(markdownToAdf(':::blockquote {localId=a-1}\n:::\n')), [{ attrs: { localId: 'a-1' }, type: 'blockquote' }])
assert.deepEqual(content(markdownToAdf(':::heading {level=2 localId=a-1}\nPart.\n:::\n')), [
{ attrs: { level: 2, localId: 'a-1' }, content: [text('Part.')], type: 'heading' },
])
assert.deepEqual(content(markdownToAdf('Part:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
})
test('names the directive form a node CommonMark spells refuses', () => {
const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form`
assert.equal(content(markdownToAdf('::rule\n')), named('rule'))
assert.equal(content(markdownToAdf(':::blockquote\nPart.\n:::\n')), named('blockquote'))
assert.equal(content(markdownToAdf(':::heading {level=2}\nPart.\n:::\n')), named('heading'))
assert.equal(content(markdownToAdf(':::paragraph\nPart.\n:::\n')), named('paragraph'))
assert.equal(content(markdownToAdf('::::bulletList\n:::listItem\nPart.\n:::\n::::\n')), named('bulletList'))
// The item whose first line reads back as a thematic break keeps the directive form the emitter falls back to.
assert.deepEqual(content(markdownToAdf('::::bulletList\n:::listItem\n---\n:::\n::::\n')), [bulletList(item({ type: 'rule' }))])
})
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
test('gives back the refusal the CommonMark spelling itself raises', () => {
const nested = '::::::::bulletList\n:::::::listItem\n---\n\n::::::bulletList\n:::::listItem\n---\n\n::::bulletList\n:::listItem\n---\n:::\n::::\n:::::\n::::::\n:::::::\n::::::::\n'
assert.equal(code(markdownToAdf(nested)), 'unspelled-block-separation')
})
test('names the directive name no node reads back to', () => {
assert.equal(code(markdownToAdf(':::widget info\nx\n:::\n')), 'unknown-directive-name')
assert.equal(content(markdownToAdf('::widget\n')), 'unknown-directive-name: the directive name widget reads back to no node')
assert.equal(code(markdownToAdf(':widget[x]\n')), 'unknown-directive-name')
assert.deepEqual(path(markdownToAdf('Part.\n\n::widget\n')), ['content', 1])
assert.equal(content(markdownToAdf('Part.\n:::x\n')), 'malformed-directive: a container fenced with 3 colons is unclosed') assert.equal(content(markdownToAdf('Part.\n:::x\n')), 'malformed-directive: a container fenced with 3 colons is unclosed')
assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1]) assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
}) })
test('names the reserved carry name a block directive spells', () => {
const reserved = 'malformed-directive: the name adf is reserved for the opaque carry, whose block form is the fence'
assert.equal(content(markdownToAdf('::adf\n')), reserved)
assert.equal(content(markdownToAdf(':::adf\nx\n:::\n')), reserved)
assert.equal(content(markdownToAdf('```adf\nx\n```\n')), 'malformed-directive: the info string adf is reserved for the opaque carry')
assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }])
})
test('reads each attribute value as the type its section assigns', () => {
assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [
{ attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' },
])
assert.deepEqual(content(markdownToAdf(':::table {isNumberColumnEnabled=true}\n:::\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }])
assert.deepEqual(content(markdownToAdf(':::tableCell {colwidth="[340,420]"}\n:::\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }])
assert.deepEqual(content(markdownToAdf('::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(markdownToAdf('::rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }])
assert.deepEqual(content(markdownToAdf('::rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [
{ attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], 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'
assert.equal(content(markdownToAdf('::rule {marks="[]"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="[1]"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="{}"}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks=x}\n')), named)
assert.equal(content(markdownToAdf('::rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
})
test('names the attribute a node holds no reading for', () => {
assert.equal(content(markdownToAdf('::rule {bogus=1}\n')), 'unsupported-node-shape: rule holds no bogus attribute')
assert.equal(content(markdownToAdf('::media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number')
assert.equal(content(markdownToAdf(':::table {isNumberColumnEnabled=yes}\n:::\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean')
assert.equal(content(markdownToAdf('::media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number')
assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340,"}\n:::\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
const deep = `${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}`
assert.equal(content(markdownToAdf(`:::tableCell {colwidth="${deep}"}\n:::\n`)), 'unsupported-node-shape: the colwidth attribute of tableCell is no json')
assert.equal(content(markdownToAdf(':::panel info {panelType=note}\nx\n:::\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument')
assert.equal(content(markdownToAdf('Part :mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot')
})
test('names the attribute value spelled outside the canonical form', () => {
assert.equal(content(markdownToAdf('::rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1')
assert.equal(content(markdownToAdf('::media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20')
assert.equal(content(markdownToAdf(':::tableCell {colwidth="[340, 420]"}\n:::\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', () => {
assert.equal(content(markdownToAdf('::rule x\n')), 'unsupported-node-shape: rule takes no argument')
assert.equal(content(markdownToAdf(':::rule\nPart.\n:::\n')), 'unsupported-node-shape: rule holds no content')
assert.equal(content(markdownToAdf('::bulletList\n')), 'unsupported-node-shape: bulletList spells its body in the container form, :::')
assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::')
assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
assert.equal(content(markdownToAdf(':::codeBlock\n```\nx\n```\n:::\n')), 'unsupported-node-shape: the fenced body of codeBlock is unsupported')
assert.equal(content(markdownToAdf(':::paragraph\n![a](/u)\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content')
assert.equal(
content(markdownToAdf('Part :emoji[x]{shortName=":x:"}.\n')),
'unsupported-node-shape: the content slot emoji spells its text attribute in is unsupported',
)
})
test('leaves the colon that opens no directive the text it is', () => { test('leaves the colon that opens no directive the text it is', () => {
assert.deepEqual(content(markdownToAdf('At 10:30 :smile: today.\n')), [paragraph('At 10:30 :smile: today.')]) assert.deepEqual(content(markdownToAdf('At 10:30 :smile: today.\n')), [paragraph('At 10:30 :smile: today.')])
assert.deepEqual(content(markdownToAdf('\\:mention[@A]\n')), [paragraph(':mention[@A]')]) assert.deepEqual(content(markdownToAdf('\\:mention[@A]\n')), [paragraph(':mention[@A]')])
+56 -14
View File
@@ -1,11 +1,14 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from './blocks.ts' import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { unknownDirectiveFault } from '../directive-syntax.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> { export function markdownToAdf(markdown: string): Result<AdfDocument> {
const parsed = parseBlocks(markdown) const parsed = parseBlocks(markdown)
@@ -32,9 +35,9 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'bulletList': case 'bulletList':
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
case 'code': case 'code':
return success(codeBlockNode(block.language, block.text)) return codeBlockNode(block.language, block.text, path)
case 'directive': case 'directive':
return faulted(unknownDirectiveFault(block.name), path) return directiveNode(block, definitions, path, depth)
case 'fault': case 'fault':
return faulted(block.fault, path) return faulted(block.fault, path)
case 'heading': case 'heading':
@@ -44,16 +47,50 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'orderedList': case 'orderedList':
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth) return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth)
case 'paragraph': case 'paragraph':
return contentNode({ type: 'paragraph' }, block.text, definitions, path) return paragraphNode(block.text, definitions, path)
case 'rule': case 'rule':
return success({ type: 'rule' }) return success({ type: 'rule' })
} }
} }
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, definitions, path, depth)
if (!built.ok) return built
const readable = commonMarkSpelling(built.value, path, depth)
if (readable === undefined) return built
if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
}
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read
if (blocks === undefined) {
if (contentModel === 'none' || contentModel === 'inline') return success(node)
return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::`, path)
}
if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content`, path)
if (contentModel === 'code') return failure('unsupported-node-shape', `the fenced body of ${node.type} is unsupported`, path)
if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth)
return inlineBodyNode(node, blocks, definitions, path)
}
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path)
return contentNode(node, only.text, definitions, path)
}
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content = blockNodes(blocks, definitions, path, depth + 1) const content = blockNodes(blocks, definitions, path, depth + 1)
if (!content.ok) return content if (!content.ok) return content
return success(content.value.length === 0 ? node : { ...node, content: content.value }) return success(withContent(node, content.value))
}
function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
return content.length === 0 ? node : { ...node, content: [...content] }
} }
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
@@ -66,18 +103,23 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
return success({ ...node, content }) return success({ ...node, content })
} }
function codeBlockNode(language: string, text: string): AdfNode { function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result<AdfNode> {
if (language === carryName) return failure('malformed-directive', `the info string ${carryName} is reserved for the opaque carry`, path)
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] } return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
} }
// spec/flavour.md, The CommonMark image: only a paragraph gives an image the block it needs. // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path) const content = parseInlineContent(text, definitions, path)
if (!content.ok) return content if (!content.ok) return content
const image = content.value.image const image = content.value.image
if (image !== undefined) { return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
return node.type === 'paragraph' ? success(image) : failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path) }
}
return success(content.value.nodes.length === 0 ? node : { ...node, content: content.value.nodes }) function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path)
if (!content.ok) return content
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes))
} }
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
import { blockDirectives } from './adf/block-directives.ts'
import { inlineDirectives } from './adf/inline-directives.ts'
import { markAttributes } from './adf/mark-attributes.ts'
type Declared = { attributes: AttributeVocabulary }
const specPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'flavour.md')
const introducer = 'Attributes: '
const codeFence = /^`{3,}/
const directiveName = /`([a-z][A-Za-z0-9]*)`/g
const namedType = /^`([a-z][A-Za-z0-9]*)` \(([^)]*)\)/
const owned = ' — '
function bullets(heading: string): string[] {
const items: string[] = []
let fence: string | undefined
let item: string | undefined
let inside = false
for (const line of readFileSync(specPath, 'utf8').split('\n')) {
if (line.startsWith('## ')) inside = line === `## ${heading}`
if (!inside) continue
const marker = codeFence.exec(line)?.[0]
if (fence !== undefined) {
if (marker !== undefined && marker.length >= fence.length) fence = undefined
continue
}
if (marker !== undefined) {
fence = marker
continue
}
if (line.startsWith('- ')) {
if (item !== undefined) items.push(item)
item = line.slice(2)
} else if (item !== undefined && line.startsWith(' ')) item += ` ${line.trim()}`
else if (item !== undefined) {
items.push(item)
item = undefined
}
}
return items
}
function declarations(heading: string): Record<string, Declared> {
const declared: Record<string, Declared> = {}
for (const item of bullets(heading)) {
const cut = item.indexOf(owned)
assert.notEqual(cut, -1, `${heading}: the bullet ${item} names no node ahead of a ${owned.trim()}`)
const attributes = attributeList(heading, item.slice(cut))
for (const [, name] of item.slice(0, cut).matchAll(directiveName)) {
assert.equal(declared[name ?? ''], undefined, `${heading}: ${name ?? ''} is declared twice`)
declared[name ?? ''] = { attributes }
}
}
return declared
}
function attributeList(heading: string, prose: string): AttributeVocabulary {
const at = prose.indexOf(introducer)
assert.notEqual(at, -1, `${heading}: ${prose} lists no attributes`)
let rest = prose.slice(at + introducer.length)
if (rest.startsWith('none')) return {}
const attributes: Record<string, AttributeKind> = {}
for (;;) {
const pair = namedType.exec(rest)
assert.notEqual(pair, null, `${heading}: ${rest} reads no \`name\` (type) pair`)
attributes[pair?.[1] ?? ''] = attributeKind(heading, pair?.[2] ?? '')
rest = rest.slice(pair?.[0].length ?? 0)
if (!rest.startsWith(', ')) return attributes
rest = rest.slice(2)
}
}
function attributeKind(heading: string, parenthesized: string): AttributeKind {
const first = parenthesized.split(/[\s,]/)[0] ?? ''
if (first === 'boolean' || first === 'json' || first === 'number' || first === 'string') return first
assert.ok(first.startsWith('`'), `${heading}: ${first} is neither an attribute kind nor a value set`)
return 'string'
}
function vocabularies(table: Readonly<Record<string, Declared>>): Record<string, Declared> {
return Object.fromEntries(Object.entries(table).map(([type, entry]) => [type, { attributes: entry.attributes }]))
}
test('the block node table holds the attributes spec/flavour.md gives each node', () => {
assert.deepEqual(declarations('Block nodes'), vocabularies(blockDirectives))
})
test('the inline node table holds the attributes spec/flavour.md gives each node', () => {
assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineDirectives))
})
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 }]))))
})
+50 -7
View File
@@ -293,7 +293,7 @@ detail is settled at its own milestone.
`spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost `spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost
leaves the fence it did not close a named error, which §2 prefers to closing more than the leaves the fence it did not close a named error, which §2 prefers to closing more than the
author wrote. author wrote.
- [ ] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first - [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
directory to. A parsed directive becomes its node: the name to the type and an unknown one directory to. A parsed directive becomes its node: the name to the type and an unknown one
to a named error, the arg to the attribute it names, each value to the type its section to a named error, the arg to the attribute it names, each value to the type its section
assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift
@@ -302,11 +302,29 @@ detail is settled at its own milestone.
the reserved `adf` name in block position needs an error of its own — 3f reports it as the reserved `adf` name in block position needs an error of its own — 3f reports it as
`unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name `unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name
meaning, and `adf` never will. meaning, and `adf` never will.
- [ ] **3h — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f **Settled** (the maintainer, 2026-09-01): the reserved `adf` name in block position is a
settles, read from the other side; the `codeBlock` directive's fenced body and the `malformed-directive` — the grammar section states the reservation, so it is that spelling
`language` attribute a bare fence leaves it; the media family's composition; both table the name breaks — and a well-formed directive the tables refuse is `unsupported-node-shape`,
forms, the pipe table's cell split and its named errors; and the one paragraph an inline the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the
body takes. split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as
the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a
MINOR, so strict is the reversible direction, as 3f already settled for spacing.
**Settled** (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive
choice is read back here rather than at 3h — a directive spelling a node CommonMark holds
is refused, so `::rule` and `:::blockquote` are errors while `::rule {localId=…}` is not.
The parser asks `spellsCommonMark`, the emitter's own choice, rather than restating the
per-node conditions: a copy would refuse the list whose first item reads back as a
thematic break, which the emitter does spell as a directive, and §2 breaks in silence.
Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]`
slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content
model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its
path and returns `Result`, so no second reader took it. The drift guard earned itself on
the way in: the spec's `text` attribute was missing from three inline table entries, which
the content slot spells and the vocabulary walk already passes over.
- [ ] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the
`language` attribute a bare fence leaves it; the media family's composition; and both
table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both
directions answer alike and moves to the `markdown/` root with the language attribute.
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one **Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent `container-block-separation` holds — a CommonMark block and a directive block sit adjacent
in a container body with no blank line between them. That reduces the three cases to one in a container body with no blank line between them. That reduces the three cases to one
@@ -317,16 +335,30 @@ detail is settled at its own milestone.
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here. `corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes. 3k's one-list pass is where they get fixtures or the directory goes.
Losing that cause closes the last shape input accepts and emit refuses — a CommonMark block
beside a directive one inside a list item — so the parse-then-emit fixpoint the README
promises holds from here rather than only for what the emitter wrote.
- [ ] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's - [ ] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
`text` attribute and the error a slot holding anything but one unmarked text node is; the `text` attribute and the error a slot holding anything but one unmarked text node is; the
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order, `:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,
outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's
merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's
`toEditorNormal` is built on it. `toEditorNormal` is built on it.
3g's shape leaves three: `readInlineDirectiveNode` takes the name, the attributes and the
slot's parsed text rather than the span, since `inline-content.ts` already imports it and
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
directive position want a claim code — `:em[x]` is an error forever, so
`unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal,
as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
nesting the emitter writes parses back.
- [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back: - [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
named error, a carry inside a mark spelling another, and the three carve-outs' escapes named error, a carry inside a mark spelling another, and the three carve-outs' escapes
reading as the literal text they hold. reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a
`codeBlock` from it; the refusal goes when the carry reads it.
- [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to - [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to
the version it ships — the one `html-blocks.ts` names for its start conditions — the version it ships — the one `html-blocks.ts` names for its start conditions —
`corpus/README.md` gaining the kind. `corpus/README.md` gaining the kind.
@@ -361,6 +393,17 @@ detail is settled at its own milestone.
a document that round-trips proves no other document shares its spelling — so decide here a document that round-trips proves no other document shares its spelling — so decide here
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding whether that gate stays as the parser-free, faster-failing signal or goes; the half holding
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way. no fixture duplicates is hygiene rather than a round-trip claim, and stays either way.
- [ ] **4b — The block walk's retry, before 5.** `emitBlock` walks a subtree twice wherever
`readableBlock` reads it whole and then gives up — a list item whose first line reads back
as a thematic break — and the walk below does the same, so the cost doubles per level:
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
predates 3g on both directions, and 3g's `commonMarkSpelling` gave it a second entry point.
The README's bot and pipeline personas feed markdown nobody typed, so this ships as a hang
on a small input; §11's scanning rule is the same argument one shape further in. The retry
is what to remove — one walk answering both the readable question and the directive
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
caller may hold one node object at two positions, where the cached depth and path are
another node's.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret, - [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: every the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: every
`corpus/unspellable/` document is a decision or a deferred trigger this file names, so the `corpus/unspellable/` document is a decision or a deferred trigger this file names, so the