Read the node tables backwards, a directive to the node and marks it names #36
@@ -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
|
||||
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
|
||||
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
|
||||
`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.
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
- 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
|
||||
across the seam. A rule both
|
||||
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
|
||||
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
|
||||
file does not repeat its directory in its name — `adf/document.ts`, never
|
||||
`adf/adf-document.ts`.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
malformed-directive
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
:::rule
|
||||
Part.
|
||||
:::
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-node-shape
|
||||
@@ -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"}
|
||||
+67
-54
@@ -112,7 +112,10 @@ quoted value holds `}` unescaped. All values are strings at the grammar level; e
|
||||
assigns types.
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`,
|
||||
`uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, the
|
||||
reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or an
|
||||
entity reference — rides the `language` attribute instead and the fence carries no info string;
|
||||
writing both is a named error.
|
||||
- `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#`
|
||||
count, so a heading carrying none, or one that is no whole number from 1 to 6, has no CommonMark
|
||||
spelling.
|
||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number).
|
||||
`order` is the first marker, so a list carrying none, one that is no whole number from 0, or one
|
||||
whose markers would run past 999999999, has no CommonMark spelling.
|
||||
- `paragraph` — container, inline body; no attributes beyond `localId`.
|
||||
- `rule` — leaf; no attributes beyond `localId`.
|
||||
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`
|
||||
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
|
||||
the reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or
|
||||
an entity reference — rides the `language` attribute instead and the fence carries no info
|
||||
string; writing both is a named error.
|
||||
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||
CommonMark spelling.
|
||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
||||
(number). `order` is the first marker, so a list carrying none, one that is no whole number
|
||||
from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
||||
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
||||
- `rule` — leaf. Attributes: `localId` (string).
|
||||
|
||||
````
|
||||
:::codeBlock {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8 wrap=true}
|
||||
@@ -205,9 +210,10 @@ fn main() {}
|
||||
|
||||
### Panel
|
||||
|
||||
Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`).
|
||||
Attributes: `localId` (string), `panelColor` (string), `panelIcon` (string), `panelIconId`
|
||||
(string), `panelIconText` (string) — the editor writes the last four for `custom` panels.
|
||||
- `panel` — container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip`
|
||||
`warning`). Attributes: `localId` (string), `panelColor` (string), `panelIcon` (string),
|
||||
`panelIconId` (string), `panelIconText` (string) — the editor writes the last four for `custom`
|
||||
panels.
|
||||
|
||||
```
|
||||
:::panel warning
|
||||
@@ -217,7 +223,7 @@ Check the collation before importing.
|
||||
|
||||
### 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).
|
||||
|
||||
```
|
||||
@@ -236,7 +242,7 @@ which. Attributes: `localId` (string), `title` (string).
|
||||
`layout` (`align-end` `align-start` `center` `full-width` `wide` `wrap-left` `wrap-right`),
|
||||
`localId` (string), `width` (number), `widthType` (`percentage` `pixel`).
|
||||
- `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}
|
||||
@@ -306,11 +312,13 @@ Bolt M8
|
||||
:::::
|
||||
```
|
||||
|
||||
Attributes — `table`: `displayMode` (`default` `fixed`), `isNumberColumnEnabled` (boolean),
|
||||
`layout` (`align-end` `align-start` `center` `default` `full-width` `wide`), `localId`
|
||||
(string), `width` (number, pixels). `tableRow`: `localId` (string). `tableCell` and
|
||||
`tableHeader`: `background` (string), `colspan` (number), `colwidth` (json, one pixel width per
|
||||
spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `middle` `top`).
|
||||
- `table` — container of `tableRow` containers. Attributes: `displayMode` (`default` `fixed`),
|
||||
`isNumberColumnEnabled` (boolean), `layout` (`align-end` `align-start` `center` `default`
|
||||
`full-width` `wide`), `localId` (string), `width` (number, pixels).
|
||||
- `tableRow` — container of cells. Attributes: `localId` (string).
|
||||
- `tableCell`, `tableHeader` — containers, block body. Attributes: `background` (string),
|
||||
`colspan` (number), `colwidth` (json, one pixel width per spanned column), `localId` (string),
|
||||
`rowspan` (number), `valign` (`bottom` `middle` `top`).
|
||||
|
||||
### Task and decision lists
|
||||
|
||||
@@ -318,7 +326,8 @@ spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `mid
|
||||
Attributes: `localId` (string).
|
||||
- `taskItem` — container, inline body; the arg is the state (`DONE` `TODO`). Attributes:
|
||||
`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).
|
||||
- `decisionItem` — container, inline body. Attributes: `localId` (string), `state` (string —
|
||||
free-form; the editor writes `DECIDED`).
|
||||
@@ -336,8 +345,8 @@ Ship it
|
||||
|
||||
### Layout
|
||||
|
||||
`layoutSection` — container of `layoutColumn` containers; attributes: `localId` (string).
|
||||
`layoutColumn` — container, block body; attributes: `localId` (string), `valign` (`bottom`
|
||||
- `layoutSection` — container of `layoutColumn` containers. Attributes: `localId` (string).
|
||||
- `layoutColumn` — container, block body. Attributes: `localId` (string), `valign` (`bottom`
|
||||
`middle` `top`), `width` (number — percent).
|
||||
|
||||
```
|
||||
@@ -353,10 +362,11 @@ Right.
|
||||
|
||||
### Extensions
|
||||
|
||||
`extension` — leaf. `bodiedExtension` — container, block body. `multiBodiedExtension` —
|
||||
container of `extensionFrame` containers; `extensionFrame` — container, block body, no
|
||||
attributes. The other three share: `extensionKey` (string), `extensionType` (string), `layout`
|
||||
(`default` `full-width` `wide`), `localId` (string), `parameters` (json), `text` (string).
|
||||
- `extension`, `bodiedExtension`, `multiBodiedExtension` — a leaf, a container with a block body,
|
||||
and a container of `extensionFrame` containers. Attributes: `extensionKey` (string),
|
||||
`extensionType` (string), `layout` (`default` `full-width` `wide`), `localId` (string),
|
||||
`parameters` (json), `text` (string).
|
||||
- `extensionFrame` — container, block body. Attributes: none.
|
||||
|
||||
```
|
||||
::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
|
||||
|
||||
`syncBlock` — leaf. `bodiedSyncBlock` — container, block body. Attributes: `localId` (string),
|
||||
`resourceId` (string).
|
||||
- `syncBlock`, `bodiedSyncBlock` — a leaf and a container with a block body. Attributes:
|
||||
`localId` (string), `resourceId` (string).
|
||||
|
||||
```
|
||||
::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
|
||||
named error.
|
||||
|
||||
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
|
||||
- `hardBreak` — `localId` (string), `text` (string).
|
||||
- `inlineCard` — `data` (json), `localId` (string), `url` (string); real payloads carry one or
|
||||
the other.
|
||||
- `mediaInline` — `alt` (string), `collection` (string), `data` (json), `height` (number), `id`
|
||||
(string), `localId` (string), `occurrenceKey` (string), `type` (`file` `image` `link`), `width`
|
||||
(number).
|
||||
- `mention` — `accessLevel` (`APPLICATION` `CONTAINER` `NONE` `SITE`), `id` (string), `localId`
|
||||
(string), `text` (string), `userType` (`APP` `DEFAULT` `SPECIAL`).
|
||||
- `status` — `color` (`blue` `green` `neutral` `purple` `red` `yellow`), `localId` (string),
|
||||
`style` (string), `text` (string).
|
||||
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
||||
(string).
|
||||
- `hardBreak` — Attributes: `localId` (string), `text` (string).
|
||||
- `inlineCard` — Attributes: `data` (json), `localId` (string), `url` (string); real payloads
|
||||
carry one or the other.
|
||||
- `mediaInline` — Attributes: `alt` (string), `collection` (string), `data` (json), `height`
|
||||
(number), `id` (string), `localId` (string), `occurrenceKey` (string), `type` (`file` `image`
|
||||
`link`), `width` (number).
|
||||
- `mention` — Attributes: `accessLevel` (`APPLICATION` `CONTAINER` `NONE` `SITE`), `id` (string),
|
||||
`localId` (string), `text` (string), `userType` (`APP` `DEFAULT` `SPECIAL`).
|
||||
- `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}
|
||||
@@ -415,14 +426,16 @@ the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identic
|
||||
## Marks
|
||||
|
||||
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
|
||||
no attributes beyond `link`'s `href` and `title`, and are not directive names: `:em[x]` is a named
|
||||
error. The other four are inline directives, content required non-empty:
|
||||
`marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, and are
|
||||
not directive names: `:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline`
|
||||
are inline directives, content required non-empty.
|
||||
|
||||
- `border` — `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
|
||||
- `subsup` — `type` (`sub` `sup`).
|
||||
- `textColor` — `color` (string, `#rrggbb`).
|
||||
- `underline` — no attributes.
|
||||
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
|
||||
- `code`, `em`, `strike`, `strong` — Attributes: none.
|
||||
- `link` — Attributes: `href` (string), `title` (string).
|
||||
- `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,
|
||||
outermost first: `_:underline[x]_` gives marks `[em, underline]`, `:underline[_x_]` the reverse.
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { AdfAttributes } from './document.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 VocabularyPair =
|
||||
| { key: string; kind: 'boolean'; value: boolean }
|
||||
| { key: string; kind: 'json'; value: JsonValue }
|
||||
| { key: string; kind: 'number'; value: number }
|
||||
| { key: string; kind: 'string'; value: string }
|
||||
export type VocabularyValue =
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'json'; value: JsonValue }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'string'; value: string }
|
||||
|
||||
export type VocabularyPair = VocabularyValue & { key: string }
|
||||
|
||||
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined {
|
||||
const pairs: VocabularyPair[] = []
|
||||
|
||||
@@ -41,7 +41,7 @@ const mediaAttributes: AttributeVocabulary = {
|
||||
|
||||
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
|
||||
|
||||
const blockDirectives = {
|
||||
export const blockDirectives = {
|
||||
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
blockquote: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
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 (typeof value['type'] !== 'string') return false
|
||||
return !('attrs' in value) || isAttributes(value['attrs'])
|
||||
|
||||
@@ -5,9 +5,9 @@ export type InlineDirective = {
|
||||
textAttribute?: string
|
||||
}
|
||||
|
||||
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
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' } },
|
||||
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
|
||||
mediaInline: {
|
||||
@@ -23,8 +23,8 @@ const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
width: 'number',
|
||||
},
|
||||
},
|
||||
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, textAttribute: 'text' },
|
||||
status: { attributes: { color: 'string', localId: 'string', style: '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', text: 'string' }, textAttribute: 'text' },
|
||||
}
|
||||
|
||||
export function inlineDirective(type: string): InlineDirective | undefined {
|
||||
|
||||
@@ -17,6 +17,8 @@ const roundTripRoot = join(corpusRoot, 'round-trip')
|
||||
const unspellableRoot = join(corpusRoot, 'unspellable')
|
||||
|
||||
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[] {
|
||||
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 }[] {
|
||||
return emittingDirectories.flatMap((directory) =>
|
||||
fixtureNames(directory, '.json').map((name) => ({ name: `${directory}/${name}`, path: join(roundTripRoot, directory, `${name}.json`) })),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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 { readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
|
||||
|
||||
function attributes(...pairs: [string, string][]): ReadonlyMap<string, string> {
|
||||
return new Map(pairs)
|
||||
// A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding.
|
||||
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 } }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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('::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('::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'))
|
||||
})
|
||||
|
||||
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="a\\u0060b\\u0026c\\u003cd\\u007ce"}'), header(2, 'extension', undefined, ['text', 'a`b&c<d|e']))
|
||||
assert.deepEqual(readDirectiveLine('::extension {text="a\\"b\\\\c\\nd"}'), header(2, 'extension', undefined, ['text', 'a"b\\c\nd']))
|
||||
assert.deepEqual(readDirectiveLine('::extension {text="}{"}'), header(2, 'extension', undefined, ['text', '}{']))
|
||||
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', '"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', () => {
|
||||
@@ -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{}'), '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', () => {
|
||||
@@ -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')
|
||||
spans(':mention[@A]', 'mention', '@A')
|
||||
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 ')
|
||||
})
|
||||
|
||||
@@ -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 c]', 'underline', 'a `b c')
|
||||
spans(':underline[:status[x]{color=red}]', 'underline', ':status[x]{color=red}')
|
||||
spans(':status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold '])
|
||||
assert.deepEqual(inline(':underline[a]{}(b)'), { attributes: attributes(), content: 'a', length: 15, name: 'underline' })
|
||||
spans(':status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold ', '"bold "'])
|
||||
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', () => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { VocabularyPair } from '../adf/attribute-vocabulary.ts'
|
||||
import { backslashEscape, claimsDirectiveLine } from './commonmark-grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { runLength } from './emphasis-matching.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 =
|
||||
| { 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 AttributePair = { end: number; key: string; value: string }
|
||||
type AttributePair = { end: number; key: string; value: DirectiveValue }
|
||||
|
||||
const bareTokenSource = '[A-Za-z0-9_-]+'
|
||||
const bareRun = new RegExp(bareTokenSource, 'y')
|
||||
@@ -33,10 +36,21 @@ const quotedEscapes = new RegExp(reservedSource, 'g')
|
||||
const rawReserved = new RegExp(reservedSource)
|
||||
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 orderFault = 'the {attrs} keys read in alphabetical order'
|
||||
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'
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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(' ')}}`
|
||||
}
|
||||
|
||||
@@ -77,6 +91,13 @@ export function spellStringAttribute(text: string): string {
|
||||
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][] {
|
||||
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` }
|
||||
}
|
||||
|
||||
function spellAttributeValue(pair: VocabularyPair): string {
|
||||
if (pair.kind === 'boolean') return `${pair.value}`
|
||||
if (pair.kind === 'json') return spellJsonAttribute(pair.value)
|
||||
if (pair.kind === 'number') return spellStringAttribute(JSON.stringify(pair.value))
|
||||
return spellStringAttribute(pair.value)
|
||||
function keyOrder(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
return left > right ? 1 : 0
|
||||
}
|
||||
|
||||
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) === '{') {
|
||||
const read = readAttributes(rest, cursor + 1)
|
||||
if (read.fault !== undefined) return { fault: read.fault }
|
||||
if (read.value.attributes.size === 0) return { fault: malformedDirective(emptyFault) }
|
||||
attributes = read.value.attributes
|
||||
cursor += 1 + read.value.length
|
||||
}
|
||||
@@ -147,6 +167,7 @@ function readNestedDirective(text: string, index: number, depth: number): Read<D
|
||||
if (text.charAt(cursor) === '{') {
|
||||
const read = readAttributes(text, cursor)
|
||||
if (read.fault !== undefined) return { fault: read.fault }
|
||||
if (read.value.attributes.size === 0 && content !== undefined) return { fault: malformedDirective(emptyFault) }
|
||||
attributes = read.value.attributes
|
||||
cursor += read.value.length
|
||||
}
|
||||
@@ -192,8 +213,9 @@ function readCodeSpanEnd(text: string, index: number): number | undefined {
|
||||
}
|
||||
|
||||
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 previous = ''
|
||||
while (cursor < text.length && text.charAt(cursor) !== '}') {
|
||||
if (attributes.size > 0) {
|
||||
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)
|
||||
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`) }
|
||||
attributes.set(pair.value.key, pair.value.value)
|
||||
const key = pair.value.key
|
||||
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
|
||||
}
|
||||
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 } }
|
||||
}
|
||||
bareRun.lastIndex = start
|
||||
const value = bareRun.exec(text)?.[0]
|
||||
if (value === undefined) return { fault: malformedDirective(pairFault) }
|
||||
return { value: { end: start + value.length, key, value } }
|
||||
const bare = bareRun.exec(text)?.[0]
|
||||
if (bare === undefined) return { fault: malformedDirective(pairFault) }
|
||||
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
|
||||
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') }
|
||||
const raw = text.slice(index, cursor + 1)
|
||||
const character = rawReserved.exec(raw)?.[0]
|
||||
const spelling = text.slice(index, cursor + 1)
|
||||
const character = rawReserved.exec(spelling)?.[0]
|
||||
if (character !== undefined) {
|
||||
return { fault: malformedDirective(`a raw ${character} inside {attrs} breaks the directive: spell it \\u${escapeDigits(character)}`) }
|
||||
}
|
||||
const value = parseJsonString(raw)
|
||||
if (value === undefined) return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') }
|
||||
return { value: { end: cursor + 1, value } }
|
||||
const parsed = parseJson(spelling)
|
||||
if (typeof parsed !== 'string') return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') }
|
||||
return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): string | undefined {
|
||||
function parseJson(raw: string): JsonValue | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw)
|
||||
return typeof value === 'string' ? value : undefined
|
||||
return isJsonValue(value) ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -86,6 +86,12 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
|
||||
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 {
|
||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
|
||||
|
||||
@@ -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 { JsonValue } from '../../json-value.ts'
|
||||
import { blockArgument } from '../block-directive-arguments.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'
|
||||
|
||||
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
|
||||
const spelledPairs = spellVocabulary(pairs)
|
||||
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)
|
||||
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
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
{
|
||||
argument: 'info',
|
||||
attributes: new Map([['panelColor', '#ff0000']]),
|
||||
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
|
||||
blocks: [{ kind: 'paragraph', text: 'Part.' }],
|
||||
kind: 'directive',
|
||||
name: 'panel',
|
||||
|
||||
@@ -31,7 +31,7 @@ export type Block =
|
||||
|
||||
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[][] }>
|
||||
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { mergeAdjacentText } from '../../adf/editor-normal.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[] }
|
||||
|
||||
@@ -144,7 +145,12 @@ function readDirective(scan: Scan, index: number): Result<number> {
|
||||
scan.pending += ':'
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
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')])
|
||||
})
|
||||
|
||||
test('reads the three directive forms, and names the node none of them reads back to', () => {
|
||||
assert.equal(code(markdownToAdf('::rule\n')), 'unknown-directive-name')
|
||||
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'unknown-directive-name')
|
||||
assert.equal(code(markdownToAdf('Part :mention[@A]{id=b1c2}.\n')), 'unknown-directive-name')
|
||||
assert.equal(content(markdownToAdf('::rule\n')), 'unknown-directive-name: the directive name rule reads back to no node')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n\n::rule\n')), ['content', 1])
|
||||
test('reads the three directive forms into the nodes the tables name', () => {
|
||||
assert.deepEqual(content(markdownToAdf('::rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('::paragraph\n')), [{ type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf(' :::panel info\nPart.\n:::\n')), [
|
||||
{ attrs: { panelType: 'info' }, content: [paragraph('Part.')], type: 'panel' },
|
||||
])
|
||||
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.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\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', () => {
|
||||
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]')])
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { 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 { largestNesting } from '../../nesting.ts'
|
||||
import { parseBlocks } from './blocks.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> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
@@ -32,9 +35,9 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
|
||||
case 'bulletList':
|
||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
|
||||
case 'code':
|
||||
return success(codeBlockNode(block.language, block.text))
|
||||
return codeBlockNode(block.language, block.text, path)
|
||||
case 'directive':
|
||||
return faulted(unknownDirectiveFault(block.name), path)
|
||||
return directiveNode(block, definitions, path, depth)
|
||||
case 'fault':
|
||||
return faulted(block.fault, path)
|
||||
case 'heading':
|
||||
@@ -44,16 +47,50 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
|
||||
case 'orderedList':
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth)
|
||||
case 'paragraph':
|
||||
return contentNode({ type: 'paragraph' }, block.text, definitions, path)
|
||||
return paragraphNode(block.text, definitions, path)
|
||||
case '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> {
|
||||
const content = blockNodes(blocks, definitions, path, depth + 1)
|
||||
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> {
|
||||
@@ -66,18 +103,23 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
|
||||
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' }
|
||||
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.
|
||||
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
|
||||
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, definitions, path)
|
||||
if (!content.ok) return content
|
||||
const image = content.value.image
|
||||
if (image !== undefined) {
|
||||
return node.type === 'paragraph' ? success(image) : failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
||||
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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 }]))))
|
||||
})
|
||||
@@ -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
|
||||
leaves the fence it did not close a named error, which §2 prefers to closing more than the
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name
|
||||
meaning, and `adf` never will.
|
||||
- [ ] **3h — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f
|
||||
settles, read from the other side; the `codeBlock` directive's fenced body and the
|
||||
`language` attribute a bare fence leaves it; the media family's composition; both table
|
||||
forms, the pipe table's cell split and its named errors; and the one paragraph an inline
|
||||
body takes.
|
||||
**Settled** (the maintainer, 2026-09-01): the reserved `adf` name in block position is a
|
||||
`malformed-directive` — the grammar section states the reservation, so it is that spelling
|
||||
the name breaks — and a well-formed directive the tables refuse is `unsupported-node-shape`,
|
||||
the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the
|
||||
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
|
||||
`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
|
||||
@@ -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.
|
||||
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.
|
||||
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
|
||||
`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,
|
||||
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
|
||||
`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:
|
||||
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
|
||||
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
|
||||
the version it ships — the one `html-blocks.ts` names for its start conditions —
|
||||
`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
|
||||
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.
|
||||
- [ ] **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,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user