Merge pull request 'Carry what no section or mark spelling writes, and drop the mark refusal' (#24) from tick-2e4 into main
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit was merged in pull request #24.
This commit is contained in:
@@ -82,7 +82,8 @@ closed list a consumer may switch exhaustively, the message free text, the path
|
|||||||
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
|
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
|
||||||
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
||||||
cause; where one cause recurs across node types, one code covers them all and `path` and `message`
|
cause; where one cause recurs across node types, one code covers them all and `path` and `message`
|
||||||
say which.
|
say which. A cause the carry answers gets no code: a mark no spelling writes rides the carry with
|
||||||
|
its node.
|
||||||
|
|
||||||
## 9. Release automation
|
## 9. Release automation
|
||||||
|
|
||||||
@@ -153,9 +154,8 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
|
|||||||
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
|
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
|
||||||
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
|
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
|
||||||
so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no
|
so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no
|
||||||
streaming APIs, no performance budget —
|
streaming APIs, no performance budget — real documents are kilobytes. A CLI is a later goal
|
||||||
conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a
|
(`todo.md`), not a non-goal.
|
||||||
non-goal.
|
|
||||||
|
|
||||||
## 15. The working loop
|
## 15. The working loop
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"rounded": true
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Check the collation.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"panelType": "extra info"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Two words fit no arg slot.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "See ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"localId": 5,
|
||||||
|
"text": "In review"
|
||||||
|
},
|
||||||
|
"type": "status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " now.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
```adf
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"rounded": true
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Check the collation.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "panel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```adf
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"panelType": "extra info"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Two words fit no arg slot.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "panel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See :adf{json="{\"attrs\":{\"localId\":5,\"text\":\"In review\"},\"type\":\"status\"}"} now.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Reviewed ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"annotationType": "inlineComment",
|
||||||
|
"id": "0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90"
|
||||||
|
},
|
||||||
|
"type": "annotation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "audited",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " twice.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"colour": "red"
|
||||||
|
},
|
||||||
|
"type": "em"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "urgent",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "flag",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a\nb",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "Release notes",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "em"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": ".",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": ",",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "c",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Reviewed :adf{json="{\"marks\":[{\"attrs\":{\"annotationType\":\"inlineComment\",\"id\":\"0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90\"},\"type\":\"annotation\"}],\"text\":\"audited\",\"type\":\"text\"}"} twice.
|
||||||
|
|
||||||
|
:adf{json="{\"marks\":[{\"attrs\":{\"colour\":\"red\"},\"type\":\"em\"}],\"text\":\"urgent\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
:adf{json="{\"marks\":[{\"type\":\"code\"},{\"type\":\"strong\"}],\"text\":\"flag\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
:adf{json="{\"marks\":[{\"type\":\"code\"}],\"text\":\"a\\nb\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
:adf{json="{\"marks\":[{\"type\":\"link\"}],\"text\":\"Release notes\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
a:adf{json="{\"marks\":[{\"type\":\"em\"}],\"text\":\".\",\"type\":\"text\"}"}b:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"text\":\",\",\"type\":\"text\"}"}c
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
un:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"text\":\"-real\",\"type\":\"text\"}"}istic
|
||||||
@@ -1 +0,0 @@
|
|||||||
unspellable-mark
|
|
||||||
+8
-5
@@ -386,11 +386,14 @@ restores the array, not a set — and opens each spelling once over the longest
|
|||||||
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
|
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
|
||||||
node the emitter carries, so no emitted carry sits inside a mark spelling.
|
node the emitter carries, so no emitted carry sits inside a mark spelling.
|
||||||
|
|
||||||
An inline node whose marks no nesting spells — a mark type not listed here, an attribute no
|
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
|
||||||
spelling holds, an order putting a code span outside another mark, `code` on text holding a
|
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
|
||||||
newline, or a spelling CommonMark's flanking rules cannot open or close where the run sits
|
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a
|
||||||
(`un**-real**istic`) — rides the inline carry whole. An opaque carry inside a mark spelling is a
|
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or
|
||||||
named error in input: the carry restores its node exactly, marks included (AGENTS.md §3).
|
close where the run sits (`un**-real**istic`) — rides the inline carry whole. A value the spelling
|
||||||
|
holds but CommonMark cannot write — a link destination or title — is a named error instead. An
|
||||||
|
opaque carry inside a mark spelling is a named error in input: the carry restores its node
|
||||||
|
exactly, marks included (AGENTS.md §3).
|
||||||
|
|
||||||
```
|
```
|
||||||
:textColor[**Overdue**]{color="#ae2e24"}, H:subsup[2]{type=sub}O, :underline[signed].
|
:textColor[**Overdue**]{color="#ae2e24"}, H:subsup[2]{type=sub}O, :underline[signed].
|
||||||
|
|||||||
+105
-45
@@ -85,26 +85,36 @@ test('refuses a link title CommonMark cannot spell', () => {
|
|||||||
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title')
|
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a link attribute no markdown spelling holds', () => {
|
test('carries a link mark the link spelling cannot write', () => {
|
||||||
|
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
|
||||||
assert.equal(
|
assert.equal(
|
||||||
code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', id: 'x' }, type: 'link' }], text: 't', type: 'text' })))),
|
carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }),
|
||||||
'unspellable-mark',
|
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
)
|
)
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 4 }, type: 'link' }], text: 't', type: 'text' })))), 'unspellable-mark')
|
assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'link' }], text: 't', type: 'text' })))), 'unsupported-node-shape')
|
assert.equal(carried({ type: 'link' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark the canonical spellings cannot nest', () => {
|
test('carries a mark the canonical spellings cannot nest', () => {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'annotation' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
const carried = (...marks: AdfMark[]): string => markdown(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' }))))
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
assert.equal(carried({ type: 'annotation' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"annotation\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
assert.equal(
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { localId: 'x' }, type: 'code' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
carried({ type: 'code' }, { type: 'strong' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"},{\\"type\\":\\"strong\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
carried({ attrs: { colour: 'red' }, type: 'em' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"colour\\":\\"red\\"},\\"type\\":\\"em\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
carried({ attrs: { localId: 'x' }, type: 'code' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"localId\\":\\"x\\"},\\"type\\":\\"code\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses whitespace CommonMark cannot hold', () => {
|
test('refuses whitespace CommonMark cannot hold', () => {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace')
|
assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), 'unspellable-whitespace')
|
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), 'unspellable-whitespace')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'em' }], text: 'x ', type: 'text' }, { text: 'y', type: 'text' })))), 'unspellable-mark')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a line whose start block parsing would claim', () => {
|
test('refuses a line whose start block parsing would claim', () => {
|
||||||
@@ -150,6 +160,7 @@ test('refuses a carried node nested deeper than the emitter carries', () => {
|
|||||||
|
|
||||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
test('refuses a node whose content model the canonical form cannot emit', () => {
|
||||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||||
|
assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape')
|
||||||
assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape')
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape')
|
||||||
@@ -164,8 +175,11 @@ test('refuses an ordered list no marker spells', () => {
|
|||||||
assert.equal(code(adfToMarkdown(list(999999999, 2))), 'unspellable-list-marker')
|
assert.equal(code(adfToMarkdown(list(999999999, 2))), 'unspellable-list-marker')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a code span over anything but one text node', () => {
|
test('carries a code mark over anything but text', () => {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))), 'unspellable-mark')
|
assert.equal(
|
||||||
|
markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"}],\\"type\\":\\"hardBreak\\"}"}\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a heading level outside the ATX range', () => {
|
test('refuses a heading level outside the ATX range', () => {
|
||||||
@@ -207,18 +221,30 @@ test('wraps adjacent nodes carrying one mark once, and a differing mark twice',
|
|||||||
assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n')
|
assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark spelling that cannot open or close where it sits', () => {
|
test('carries a mark spelling that cannot open or close where it sits', () => {
|
||||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||||
const strong: AdfMark = { type: 'strong' }
|
const strong: AdfMark = { type: 'strong' }
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' })))), 'unspellable-mark')
|
assert.equal(
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' })))), 'unspellable-mark')
|
emitted({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' }),
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('.a', strong))))), 'unspellable-mark')
|
'un:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\"-real\\",\\"type\\":\\"text\\"}"}istic\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
emitted(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"C++\\",\\"type\\":\\"text\\"}"}ish\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
emitted({ text: 'x', type: 'text' }, marked('.a', strong)),
|
||||||
|
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\".a\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n')
|
assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n')
|
||||||
assert.equal(emitted(marked('a.', strong)), '**a.**\n')
|
assert.equal(emitted(marked('a.', strong)), '**a.**\n')
|
||||||
assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n')
|
assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n')
|
||||||
const em: AdfMark = { type: 'em' }
|
const em: AdfMark = { type: 'em' }
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong))))), 'unspellable-mark')
|
assert.equal(
|
||||||
|
emitted({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong)),
|
||||||
|
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a.\\",\\"type\\":\\"text\\"}"}**b**\n',
|
||||||
|
)
|
||||||
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
|
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -252,7 +278,10 @@ test('escapes a literal delimiter that would merge with an emitted one', () => {
|
|||||||
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
|
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
|
||||||
assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n')
|
assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n')
|
||||||
assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n')
|
assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' }))))), 'unspellable-mark')
|
assert.equal(
|
||||||
|
emitted({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' })),
|
||||||
|
'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strike\\"}],\\"text\\":\\"~a\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
||||||
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
|
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
|
||||||
assert.equal(emitted({ text: '`a', type: 'text' }, marked('b', { type: 'code' })), '\\`a`b`\n')
|
assert.equal(emitted({ text: '`a', type: 'text' }, marked('b', { type: 'code' })), '\\`a`b`\n')
|
||||||
@@ -289,14 +318,24 @@ test('refuses the characters CommonMark rewrites', () => {
|
|||||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a text node carrying no text at all', () => {
|
test('refuses a text node the spelling would empty out', () => {
|
||||||
|
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape')
|
||||||
|
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), 'unsupported-node-shape')
|
||||||
|
assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||||
|
assert.equal(code(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||||
const em = { type: 'em' }
|
const em = { type: 'em' }
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))), 'unspellable-whitespace')
|
assert.equal(
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))), 'unspellable-whitespace')
|
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"hardBreak\\"}"}b\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"\u00a0a\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('pads a code span whose edges CommonMark would strip', () => {
|
test('pads a code span whose edges CommonMark would strip', () => {
|
||||||
@@ -329,17 +368,32 @@ test('spells a block directive as its node type, arg and attributes', () => {
|
|||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a directive attribute no section spells', () => {
|
test('carries a directive attribute no section spells', () => {
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { rounded: true }, type: 'panel' }))), 'unspelled-node-attribute')
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { toString: 'x' }, type: 'panel' }))), 'unspelled-node-attribute')
|
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n')
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { localId: 4 }, type: 'panel' }))), 'unsupported-node-shape')
|
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n')
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { width: '50' }, type: 'layoutColumn' }))), 'unsupported-node-shape')
|
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n')
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }))), 'unsupported-node-shape')
|
assert.equal(
|
||||||
|
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
|
||||||
|
'```adf\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
|
||||||
|
'```adf\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
|
||||||
|
':::mediaGroup\n```adf\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n:::\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses an arg slot value no bare token spells', () => {
|
test('carries an arg slot value no bare token spells', () => {
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { panelType: 'extra info' }, type: 'panel' }))), 'unspelled-node-attribute')
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||||
assert.equal(code(adfToMarkdown(document({ attrs: { state: 2 }, type: 'taskItem' }))), 'unspelled-node-attribute')
|
assert.equal(
|
||||||
|
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
||||||
|
'```adf\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n',
|
||||||
|
)
|
||||||
|
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('carries a block node mark in the reserved attribute', () => {
|
test('carries a block node mark in the reserved attribute', () => {
|
||||||
@@ -376,8 +430,8 @@ test('spells the image form for exactly the centered external media shape', () =
|
|||||||
assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url }))
|
assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url }))
|
||||||
assert.ok(fallback({ type: 'external', url: 'https://example.com/a b>c' }))
|
assert.ok(fallback({ type: 'external', url: 'https://example.com/a b>c' }))
|
||||||
assert.ok(fallback({ alt: ' moon ', type: 'external', url }))
|
assert.ok(fallback({ alt: ' moon ', type: 'external', url }))
|
||||||
assert.equal(code(adfToMarkdown(single({ alt: 4, type: 'external', url }))), 'unsupported-node-shape')
|
assert.ok(fallback({ alt: 4, type: 'external', url }))
|
||||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url: 4 }))), 'unsupported-node-shape')
|
assert.ok(fallback({ type: 'external', url: 4 }))
|
||||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -435,12 +489,13 @@ test('spells an inline node as a directive with its content slot and attributes'
|
|||||||
assert.equal(emitted({ attrs: { url: 'a`b&c<d|e' }, type: 'inlineCard' }), ':inlineCard{url="a\\u0060b\\u0026c\\u003cd\\u007ce"}\n')
|
assert.equal(emitted({ attrs: { url: 'a`b&c<d|e' }, type: 'inlineCard' }), ':inlineCard{url="a\\u0060b\\u0026c\\u003cd\\u007ce"}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses an inline node attribute no section spells', () => {
|
test('carries an inline node attribute no section spells', () => {
|
||||||
const refused = (node: AdfNode): string => code(adfToMarkdown(document(paragraph(node))))
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||||
assert.equal(refused({ attrs: { rounded: true }, type: 'status' }), 'unspelled-node-attribute')
|
assert.equal(carried({ attrs: { rounded: true }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"rounded\\":true},\\"type\\":\\"status\\"}"}\n')
|
||||||
assert.equal(refused({ attrs: { toString: 'x' }, type: 'status' }), 'unspelled-node-attribute')
|
assert.equal(carried({ attrs: { toString: 'x' }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"toString\\":\\"x\\"},\\"type\\":\\"status\\"}"}\n')
|
||||||
assert.equal(refused({ attrs: { color: 4 }, type: 'status' }), 'unsupported-node-shape')
|
assert.equal(carried({ attrs: { color: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"color\\":4},\\"type\\":\\"status\\"}"}\n')
|
||||||
assert.equal(refused({ attrs: { width: '2' }, type: 'mediaInline' }), 'unsupported-node-shape')
|
assert.equal(carried({ attrs: { width: '2' }, type: 'mediaInline' }), ':adf{json="{\\"attrs\\":{\\"width\\":\\"2\\"},\\"type\\":\\"mediaInline\\"}"}\n')
|
||||||
|
assert.equal(carried({ attrs: { text: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"text\\":4},\\"type\\":\\"status\\"}"}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses the content and slot an inline directive has no room for', () => {
|
test('refuses the content and slot an inline directive has no room for', () => {
|
||||||
@@ -449,7 +504,6 @@ test('refuses the content and slot an inline directive has no room for', () => {
|
|||||||
assert.equal(refused({ text: 'x', type: 'status' }), 'unsupported-node-shape')
|
assert.equal(refused({ text: 'x', type: 'status' }), 'unsupported-node-shape')
|
||||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), 'unsupported-node-shape')
|
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), 'unsupported-node-shape')
|
||||||
assert.equal(refused({ text: 'x', type: 'hardBreak' }), 'unsupported-node-shape')
|
assert.equal(refused({ text: 'x', type: 'hardBreak' }), 'unsupported-node-shape')
|
||||||
assert.equal(refused({ attrs: { text: 4 }, type: 'status' }), 'unsupported-node-shape')
|
|
||||||
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace')
|
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace')
|
||||||
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character')
|
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character')
|
||||||
})
|
})
|
||||||
@@ -477,10 +531,16 @@ test('spells the directive marks around the longest run they cover', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark directive attribute no spelling holds', () => {
|
test('carries a mark directive attribute no spelling holds', () => {
|
||||||
const refused = (mark: AdfMark): string => code(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
||||||
assert.equal(refused({ attrs: { width: 2 }, type: 'border' }), 'unspellable-mark')
|
assert.equal(
|
||||||
assert.equal(refused({ attrs: { size: '2' }, type: 'border' }), 'unspellable-mark')
|
carried({ attrs: { width: 2 }, type: 'border' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"width\\":2},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
carried({ attrs: { size: '2' }, type: 'border' }),
|
||||||
|
':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"size\\":\\"2\\"},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('carries whitespace CommonMark strips in the reserved text directive', () => {
|
test('carries whitespace CommonMark strips in the reserved text directive', () => {
|
||||||
|
|||||||
+13
-8
@@ -110,19 +110,17 @@ function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
|
|||||||
|
|
||||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
|
||||||
const header = spellDirectiveHeader(node, directive, path)
|
|
||||||
if (!header.ok) return header
|
|
||||||
const content = node.content ?? []
|
const content = node.content ?? []
|
||||||
if (directive.body === 'none') {
|
if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
|
||||||
if (content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
|
const header = spellDirectiveHeader(node, directive)
|
||||||
return success({ fenceColons: 2, spelling: 'directive', text: `::${header.value}` })
|
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||||
}
|
if (directive.body === 'none') return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
|
||||||
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
|
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
|
||||||
if (!body.ok) return body
|
if (!body.ok) return body
|
||||||
const fenceColons = Math.max(3, body.value.fenceColons + 1)
|
const fenceColons = Math.max(3, body.value.fenceColons + 1)
|
||||||
const fence = ':'.repeat(fenceColons)
|
const fence = ':'.repeat(fenceColons)
|
||||||
const lines = body.value.text === '' ? '' : `${body.value.text}\n`
|
const lines = body.value.text === '' ? '' : `${body.value.text}\n`
|
||||||
return success({ fenceColons, spelling: 'directive', text: `${fence}${header.value}\n${lines}${fence}` })
|
return success({ fenceColons, spelling: 'directive', text: `${fence}${header}\n${lines}${fence}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
|
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
|
||||||
@@ -165,7 +163,14 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
|||||||
let text = ''
|
let text = ''
|
||||||
for (const [index, child] of (node.content ?? []).entries()) {
|
for (const [index, child] of (node.content ?? []).entries()) {
|
||||||
const childPath = [...path, 'content', index]
|
const childPath = [...path, 'content', index]
|
||||||
if (child.type !== 'text' || typeof child.text !== 'string' || child.text === '' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) {
|
if (
|
||||||
|
child.type !== 'text' ||
|
||||||
|
typeof child.text !== 'string' ||
|
||||||
|
child.text === '' ||
|
||||||
|
(child.content ?? []).length > 0 ||
|
||||||
|
(child.marks ?? []).length > 0 ||
|
||||||
|
Object.keys(child.attrs ?? {}).length > 0
|
||||||
|
) {
|
||||||
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath)
|
return failure('unsupported-node-shape', 'a codeBlock holds plain text nodes only', childPath)
|
||||||
}
|
}
|
||||||
if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath)
|
if (/\r/.test(child.text)) return failure('unspellable-whitespace', 'a codeBlock holds no carriage return CommonMark keeps', childPath)
|
||||||
|
|||||||
+13
-16
@@ -1,7 +1,6 @@
|
|||||||
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from './json-value.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
|
||||||
import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
|
|
||||||
|
|
||||||
export type BlockDirective = {
|
export type BlockDirective = {
|
||||||
argument?: string
|
argument?: string
|
||||||
@@ -80,24 +79,22 @@ export function blockDirective(type: string): BlockDirective | undefined {
|
|||||||
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined
|
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective): string | undefined {
|
||||||
const argument = spellArgument(node, directive, path)
|
const argument = spellArgument(node, directive)
|
||||||
if (!argument.ok) return argument
|
if (argument === undefined) return undefined
|
||||||
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
|
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
|
||||||
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
if (pairs === undefined) return undefined
|
||||||
const marks = node.marks ?? []
|
const marks = node.marks ?? []
|
||||||
if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||||
const attributes = spellAttributes(spelled.pairs)
|
const attributes = spellAttributes(pairs)
|
||||||
return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`)
|
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellArgument(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
function spellArgument(node: AdfNode, directive: BlockDirective): string | undefined {
|
||||||
const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument]
|
const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument]
|
||||||
if (value === undefined) return success('')
|
if (value === undefined) return ''
|
||||||
if (typeof value !== 'string' || !isBareToken(value)) {
|
if (typeof value !== 'string' || !isBareToken(value)) return undefined
|
||||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${directive.argument} holds no bare token the arg slot spells`, path)
|
return ` ${value}`
|
||||||
}
|
|
||||||
return success(` ${value}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function markValues(marks: readonly AdfMark[]): JsonValue {
|
function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts'
|
import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from './json-value.ts'
|
||||||
import { failure, type ConvertErrorPath, type Result } from './result.ts'
|
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
|
|
||||||
export type AttributeFault = { key: string; kind: AttributeKind | undefined }
|
|
||||||
|
|
||||||
export type SpelledPairs = { fault: AttributeFault; pairs?: undefined } | { fault?: undefined; pairs: [string, string][] }
|
|
||||||
|
|
||||||
const bareToken = /^[A-Za-z0-9_-]+$/
|
const bareToken = /^[A-Za-z0-9_-]+$/
|
||||||
|
|
||||||
// spec/flavour.md, Attributes.
|
// spec/flavour.md, Attributes.
|
||||||
@@ -16,22 +11,17 @@ export function isBareToken(text: string): boolean {
|
|||||||
return bareToken.test(text)
|
return bareToken.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attributeFailure<T>(type: string, fault: AttributeFault, path: ConvertErrorPath): Result<T> {
|
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): [string, string][] | undefined {
|
||||||
if (fault.kind === undefined) return failure('unspelled-node-attribute', `the ${type} attribute ${fault.key} has no canonical markdown spelling`, path)
|
|
||||||
return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): SpelledPairs {
|
|
||||||
const pairs: [string, string][] = []
|
const pairs: [string, string][] = []
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
if (key === slot) continue
|
if (key === slot) continue
|
||||||
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
||||||
if (kind === undefined) return { fault: { key, kind: undefined } }
|
if (kind === undefined) return undefined
|
||||||
const spelled = spellAttributeValue(value, kind)
|
const spelled = spellAttributeValue(value, kind)
|
||||||
if (spelled === undefined) return { fault: { key, kind } }
|
if (spelled === undefined) return undefined
|
||||||
pairs.push([key, spelled])
|
pairs.push([key, spelled])
|
||||||
}
|
}
|
||||||
return { pairs }
|
return pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
||||||
import type { AttributeFault } from './directive-attributes.ts'
|
import { spellAttributes, vocabularyPairs } from './directive-attributes.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
|
||||||
import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts'
|
|
||||||
|
|
||||||
export type InlineDirective = {
|
export type InlineDirective = {
|
||||||
attributes: AttributeVocabulary
|
attributes: AttributeVocabulary
|
||||||
@@ -53,19 +51,12 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
|||||||
return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined
|
return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> {
|
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
||||||
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
|
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
|
||||||
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
return pairs === undefined ? undefined : spellAttributes(pairs)
|
||||||
return success(spellAttributes(spelled.pairs))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> {
|
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
|
||||||
const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
|
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
|
||||||
if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path)
|
return pairs === undefined ? undefined : spellAttributes(pairs)
|
||||||
return success(spellAttributes(spelled.pairs))
|
|
||||||
}
|
|
||||||
|
|
||||||
function markFailure<T>(type: string, fault: AttributeFault, path: ConvertErrorPath): Result<T> {
|
|
||||||
if (fault.kind === undefined) return failure('unspellable-mark', `the ${type} spelling holds no ${fault.key} attribute`, path)
|
|
||||||
return failure('unspellable-mark', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -4,15 +4,17 @@ export type EmphasisRole = 'close' | 'open'
|
|||||||
|
|
||||||
export type InlineEscaping = 'backslash' | 'bracketed' | 'none'
|
export type InlineEscaping = 'backslash' | 'bracketed' | 'none'
|
||||||
|
|
||||||
|
export type NodeRange = { first: number; last: number }
|
||||||
|
|
||||||
export type InlineSegment =
|
export type InlineSegment =
|
||||||
| { emphasis: EmphasisRole; escaping: 'none'; mark: string; text: string }
|
| { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string }
|
||||||
| { emphasis?: undefined; escaping: InlineEscaping; text: string }
|
| { emphasis?: undefined; escaping: InlineEscaping; text: string }
|
||||||
|
|
||||||
export type AssembledLine = { line: string; unspellableMark: string | undefined }
|
export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined }
|
||||||
|
|
||||||
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||||
|
|
||||||
type DelimiterRun = { character: string; closeMark: string | undefined; end: number; openMark: string | undefined; start: number }
|
type DelimiterRun = { character: string; closeNodes: NodeRange | undefined; end: number; openNodes: NodeRange | undefined; start: number }
|
||||||
|
|
||||||
const delimiters = ['*', '_', '`', '~']
|
const delimiters = ['*', '_', '`', '~']
|
||||||
|
|
||||||
@@ -72,15 +74,15 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
|
|||||||
placements.push(output.length)
|
placements.push(output.length)
|
||||||
output += scan.charAt(index)
|
output += scan.charAt(index)
|
||||||
}
|
}
|
||||||
return { line: output, unspellableMark: unspellableMark(segments, output, placements) }
|
return { line: output, unspellableRun: unspellableRun(segments, output, placements) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function unspellableMark(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined {
|
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined {
|
||||||
for (const run of delimiterRuns(segments, placements)) {
|
for (const run of delimiterRuns(segments, placements)) {
|
||||||
const before = charAt(output, run.start - 1)
|
const before = charAt(output, run.start - 1)
|
||||||
const after = output.charAt(run.end)
|
const after = output.charAt(run.end)
|
||||||
if (run.openMark !== undefined && !isLeftFlanking(before, after)) return run.openMark
|
if (run.openNodes !== undefined && !isLeftFlanking(before, after)) return run.openNodes
|
||||||
if (run.closeMark !== undefined && !isRightFlanking(before, after)) return run.closeMark
|
if (run.closeNodes !== undefined && !isRightFlanking(before, after)) return run.closeNodes
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -96,16 +98,16 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly
|
|||||||
const end = start + segment.text.length
|
const end = start + segment.text.length
|
||||||
const previous = runs[runs.length - 1]
|
const previous = runs[runs.length - 1]
|
||||||
if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) {
|
if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) {
|
||||||
previous.closeMark = previous.closeMark ?? (closes ? segment.mark : undefined)
|
previous.closeNodes = previous.closeNodes ?? (closes ? segment.nodes : undefined)
|
||||||
previous.end = end
|
previous.end = end
|
||||||
previous.openMark = previous.openMark ?? (closes ? undefined : segment.mark)
|
previous.openNodes = previous.openNodes ?? (closes ? undefined : segment.nodes)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
runs.push({
|
runs.push({
|
||||||
character: segment.text.charAt(0),
|
character: segment.text.charAt(0),
|
||||||
closeMark: closes ? segment.mark : undefined,
|
closeNodes: closes ? segment.nodes : undefined,
|
||||||
end,
|
end,
|
||||||
openMark: closes ? undefined : segment.mark,
|
openNodes: closes ? undefined : segment.nodes,
|
||||||
start,
|
start,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-87
@@ -1,36 +1,42 @@
|
|||||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||||
import type { InlineDirective } from './inline-directives.ts'
|
import type { InlineDirective } from './inline-directives.ts'
|
||||||
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './markdown-escaping.ts'
|
||||||
import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts'
|
import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts'
|
||||||
import { largestNesting } from './nesting.ts'
|
import { largestNesting } from './nesting.ts'
|
||||||
import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts'
|
import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink } from './commonmark-grammar.ts'
|
||||||
import { carriedInline } from './opaque-carry.ts'
|
import { carriedInline } from './opaque-carry.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { longestBacktickRun } from './backtick-runs.ts'
|
import { longestBacktickRun } from './backtick-runs.ts'
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
|
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
|
||||||
|
|
||||||
|
type EmittedLine = { line: string; segments: InlineSegment[] }
|
||||||
|
|
||||||
|
type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] }
|
||||||
|
|
||||||
type InlineContext = {
|
type InlineContext = {
|
||||||
atBlockEnd: boolean
|
atBlockEnd: boolean
|
||||||
bracketed: boolean
|
bracketed: boolean
|
||||||
|
carried: ReadonlySet<number>
|
||||||
path: ConvertErrorPath
|
path: ConvertErrorPath
|
||||||
spansLines: boolean
|
spansLines: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
||||||
|
|
||||||
|
type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string }
|
||||||
|
|
||||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||||
const segments = lineSegments(nodes, container, path)
|
const emitted = emitLine(nodes, container, path)
|
||||||
if (!segments.ok) return segments
|
if (!emitted.ok) return emitted
|
||||||
return finishLine(segments.value, container, path)
|
return success(emitted.value.line)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||||
const segments = lineSegments(nodes, 'table-cell', path)
|
const emitted = emitLine(nodes, 'table-cell', path)
|
||||||
if (!segments.ok) return undefined
|
if (!emitted.ok) return undefined
|
||||||
if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
|
if (emitted.value.segments.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
|
||||||
const line = finishLine(segments.value, 'table-cell', path)
|
return emitted.value.line
|
||||||
return line.ok ? line.value : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||||
@@ -38,23 +44,42 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
|
|||||||
const destination = spellDestination(href, path)
|
const destination = spellDestination(href, path)
|
||||||
if (!destination.ok) return undefined
|
if (!destination.ok) return undefined
|
||||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||||
const line = finishLine([syntax('`)], 'paragraph', path)
|
const attempt = attemptLine([syntax('`)], 'paragraph', path)
|
||||||
return line.ok ? line.value : undefined
|
return attempt.ok ? attempt.value.line : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
|
// A demand names a run no spelling holds, and a carried node joins no run, so every pass carries at least one more node.
|
||||||
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, path, spansLines: container === 'paragraph' })
|
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
|
||||||
if (!segments.ok) return segments
|
const carried = new Set<number>()
|
||||||
return success(carryStrippedWhitespace(segments.value))
|
for (;;) {
|
||||||
}
|
const emission = lineSegments(nodes, container, path, carried)
|
||||||
|
if (!emission.ok) return emission
|
||||||
function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
if (emission.value.carry !== undefined) {
|
||||||
const assembled = assembleInlineLine(segments, container)
|
carryRange(carried, emission.value.carry)
|
||||||
if (assembled.unspellableMark !== undefined) {
|
continue
|
||||||
return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path)
|
}
|
||||||
|
const attempt = attemptLine(emission.value.segments, container, path)
|
||||||
|
if (!attempt.ok) return attempt
|
||||||
|
if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments })
|
||||||
|
carryRange(carried, attempt.value.carry)
|
||||||
}
|
}
|
||||||
const line = assembled.line
|
}
|
||||||
for (const [index, single] of line.split('\n').entries()) {
|
|
||||||
|
function carryRange(carried: Set<number>, range: NodeRange): void {
|
||||||
|
for (let index = range.first; index <= range.last; index += 1) carried.add(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet<number>): Result<Emission> {
|
||||||
|
const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' })
|
||||||
|
if (!emission.ok) return emission
|
||||||
|
if (emission.value.carry !== undefined) return emission
|
||||||
|
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
|
||||||
|
const assembled = assembleInlineLine(segments, container)
|
||||||
|
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun })
|
||||||
|
for (const [index, single] of assembled.line.split('\n').entries()) {
|
||||||
if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) {
|
if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) {
|
||||||
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
|
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
|
||||||
}
|
}
|
||||||
@@ -62,7 +87,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer
|
|||||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return success(line)
|
return success({ line: assembled.line })
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Inline nodes.
|
// spec/flavour.md, Inline nodes.
|
||||||
@@ -110,27 +135,28 @@ function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<nul
|
|||||||
return success(null)
|
return success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<InlineSegment[]> {
|
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> {
|
||||||
if (depth > largestNesting) {
|
if (depth > largestNesting) {
|
||||||
return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
|
return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
|
||||||
}
|
}
|
||||||
const runs = inlineRuns(nodes, depth, firstIndex)
|
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [offset, run] of runs.entries()) {
|
for (const [offset, run] of runs.entries()) {
|
||||||
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
||||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
||||||
if (!emitted.ok) return emitted
|
if (!emitted.ok) return emitted
|
||||||
segments.push(...emitted.value)
|
if (emitted.value.carry !== undefined) return emitted
|
||||||
|
segments.push(...emitted.value.segments)
|
||||||
}
|
}
|
||||||
return success(segments)
|
return success({ segments })
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number): InlineRun[] {
|
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
|
||||||
const runs: InlineRun[] = []
|
const runs: InlineRun[] = []
|
||||||
for (const [offset, node] of nodes.entries()) {
|
for (const [offset, node] of nodes.entries()) {
|
||||||
const index = firstIndex + offset
|
const index = firstIndex + offset
|
||||||
// spec/flavour.md, Marks.
|
// spec/flavour.md, Marks.
|
||||||
const mark = carries(node) ? undefined : (node.marks ?? [])[depth]
|
const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth]
|
||||||
if (mark === undefined) {
|
if (mark === undefined) {
|
||||||
runs.push({ index, kind: 'plain', node })
|
runs.push({ index, kind: 'plain', node })
|
||||||
continue
|
continue
|
||||||
@@ -146,49 +172,50 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
|||||||
return [...context.path, 'content', index]
|
return [...context.path, 'content', index]
|
||||||
}
|
}
|
||||||
|
|
||||||
function carries(node: AdfNode): boolean {
|
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||||
|
if (carried.has(index)) return true
|
||||||
return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined
|
return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<InlineSegment[]> {
|
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||||
const path = nodePath(context, index)
|
const path = nodePath(context, index)
|
||||||
if (carries(node)) {
|
if (carries(node, context.carried, index)) {
|
||||||
const carried = carriedInline(node, path)
|
const carried = carriedInline(node, path)
|
||||||
if (!carried.ok) return carried
|
if (!carried.ok) return carried
|
||||||
return success([syntax(carried.value)])
|
return success({ segments: [syntax(carried.value)] })
|
||||||
}
|
}
|
||||||
const types = (node.marks ?? []).map((mark) => mark.type)
|
const types = (node.marks ?? []).map((mark) => mark.type)
|
||||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||||
const directive = inlineDirective(node.type)
|
const directive = inlineDirective(node.type)
|
||||||
if (directive !== undefined) return emitInlineDirective(node, directive, path)
|
if (directive !== undefined) return emitInlineDirective(node, directive, index, path)
|
||||||
if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
|
if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
|
||||||
return emitText(node, context, path)
|
return emitText(node, context, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||||
const unspelled = Object.keys(node.attrs ?? {})[0]
|
const unspelled = Object.keys(node.attrs ?? {})[0]
|
||||||
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path)
|
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
const empty = refuseContentAndText(node, path)
|
const empty = refuseContentAndText(node, path)
|
||||||
if (!empty.ok) return empty
|
if (!empty.ok) return empty
|
||||||
if (context.spansLines && !context.atBlockEnd) return success([syntax('\\\n')])
|
if (context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
|
||||||
return success([syntax(spellLeafDirective('hardBreak', ''))])
|
return success({ segments: [syntax(spellLeafDirective('hardBreak', ''))] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||||
const empty = refuseContentAndText(node, path)
|
const empty = refuseContentAndText(node, path)
|
||||||
if (!empty.ok) return empty
|
if (!empty.ok) return empty
|
||||||
const attributes = spellInlineNodeAttributes(node, directive, path)
|
const attributes = spellInlineNodeAttributes(node, directive)
|
||||||
if (!attributes.ok) return attributes
|
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||||
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
|
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
|
||||||
if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))])
|
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
|
||||||
if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path)
|
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||||
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
|
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
|
||||||
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
||||||
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||||
return success([syntax(`:${node.type}[`), ...content, syntax(`]${attributes.value}`)])
|
return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||||
const unspelled = Object.keys(node.attrs ?? {})[0]
|
const unspelled = Object.keys(node.attrs ?? {})[0]
|
||||||
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path)
|
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
||||||
@@ -197,60 +224,52 @@ function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath)
|
|||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||||
return success(parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })))
|
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
|
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||||
const path = nodePath(context, index)
|
const path = nodePath(context, index)
|
||||||
|
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||||
const spelling = markSpelling(mark.type)
|
const spelling = markSpelling(mark.type)
|
||||||
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
if (spelling === undefined) return success({ carry: range })
|
||||||
const attributes = spellMarkAttributes(mark, spelling.attributes, path)
|
const attributes = spellMarkAttributes(mark, spelling.attributes)
|
||||||
if (!attributes.ok) return attributes
|
if (attributes === undefined) return success({ carry: range })
|
||||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, path)
|
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, mark, spelling.spelling, depth, index, context, path)
|
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||||
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, index, context, path)
|
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path)
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)])
|
if (inner.value.carry !== undefined) return inner
|
||||||
|
return success({ segments: [syntax(`:${mark.type}[`), ...inner.value.segments, syntax(`]${attributes}`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitEmphasis(
|
function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
|
||||||
nodes: readonly AdfNode[],
|
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||||
mark: AdfMark,
|
|
||||||
spelling: string,
|
|
||||||
depth: number,
|
|
||||||
index: number,
|
|
||||||
context: InlineContext,
|
|
||||||
path: ConvertErrorPath,
|
|
||||||
): Result<InlineSegment[]> {
|
|
||||||
const inner = emitRun(nodes, depth + 1, index, context)
|
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const carried = carryStrippedWhitespace(inner.value)
|
if (inner.value.carry !== undefined) return inner
|
||||||
const text = carried.map((segment) => segment.text).join('')
|
const carried = carryStrippedWhitespace(inner.value.segments)
|
||||||
if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
|
return success({
|
||||||
return success([
|
segments: [
|
||||||
{ emphasis: 'open', escaping: 'none', mark: mark.type, text: spelling },
|
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling },
|
||||||
...carried,
|
...carried,
|
||||||
{ emphasis: 'close', escaping: 'none', mark: mark.type, text: spelling },
|
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling },
|
||||||
])
|
],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
let text = ''
|
let text = ''
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type !== 'text' || typeof node.text !== 'string' || node.text === '') return failure('unspellable-mark', 'a code span holds text nodes only', path)
|
if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range })
|
||||||
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
||||||
|
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path)
|
||||||
text += node.text
|
text += node.text
|
||||||
}
|
}
|
||||||
if (/[\n\r]/.test(text)) return failure('unspellable-mark', 'a code span holds no newline', path)
|
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||||
const padded = needsPadding(text) ? ` ${text} ` : text
|
const padded = needsPadding(text) ? ` ${text} ` : text
|
||||||
return success([syntax(`${fence}${padded}${fence}`)])
|
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
|
||||||
}
|
|
||||||
|
|
||||||
function holdsEdgeWhitespace(text: string): boolean {
|
|
||||||
return isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsPadding(text: string): boolean {
|
function needsPadding(text: string): boolean {
|
||||||
@@ -258,20 +277,21 @@ function needsPadding(text: string): boolean {
|
|||||||
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||||
const href = mark.attrs?.['href']
|
const href = mark.attrs?.['href']
|
||||||
const title = mark.attrs?.['title']
|
const title = mark.attrs?.['title']
|
||||||
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path)
|
if (typeof href !== 'string') return success({ carry: range })
|
||||||
const node = nodes[0]
|
const node = nodes[0]
|
||||||
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
|
||||||
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([syntax(`<${href}>`)])
|
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
|
||||||
const destination = spellDestination(href, path)
|
const destination = spellDestination(href, path)
|
||||||
if (!destination.ok) return destination
|
if (!destination.ok) return destination
|
||||||
const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('')
|
const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('')
|
||||||
if (!spelledTitle.ok) return spelledTitle
|
if (!spelledTitle.ok) return spelledTitle
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
|
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([syntax('['), ...inner.value, syntax(`](${destination.value}${spelledTitle.value})`)])
|
if (inner.value.carry !== undefined) return inner
|
||||||
|
return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type ConvertErrorCode =
|
|||||||
| 'unspellable-link-destination'
|
| 'unspellable-link-destination'
|
||||||
| 'unspellable-link-title'
|
| 'unspellable-link-title'
|
||||||
| 'unspellable-list-marker'
|
| 'unspellable-list-marker'
|
||||||
| 'unspellable-mark'
|
|
||||||
| 'unspellable-whitespace'
|
| 'unspellable-whitespace'
|
||||||
| 'unspelled-block-marks'
|
| 'unspelled-block-marks'
|
||||||
| 'unspelled-block-separation'
|
| 'unspelled-block-separation'
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ detail is settled at its own milestone.
|
|||||||
CommonMark's own precedence between a directive and a code span, and collapsed
|
CommonMark's own precedence between a directive and a code span, and collapsed
|
||||||
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
|
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
|
||||||
to match: a `](` inside a directive escapes no text `[`.
|
to match: a `](` inside a directive escapes no text `[`.
|
||||||
- [ ] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
|
- [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
|
||||||
cannot spell — an attrs key no section lists, a value that is not the section's type, an
|
cannot spell — an attrs key no section lists, a value that is not the section's type, an
|
||||||
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
|
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
|
||||||
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
|
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
|
||||||
added to that list is the odd one out: `unspellableMark` finds it after assembly and
|
added to that list is the odd one out: `unspellableMark` finds it after assembly and
|
||||||
names a mark type against the line's path, so the failing run needs identifying before
|
names a mark type against the line's path, so the failing run needs identifying before
|
||||||
the carry can replace the refusal `corpus/unspellable/mark-inside-word` pins.
|
the carry can replace the refusal `mark-inside-word` pinned.
|
||||||
- [ ] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
|
- [ ] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
|
||||||
than isolating one, and the gate's collision property: no two corpus documents may emit
|
than isolating one, and the gate's collision property: no two corpus documents may emit
|
||||||
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
|
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
|
||||||
|
|||||||
Reference in New Issue
Block a user