Emitter 2c: the inline node directives, the directive marks and the carried whitespace #13
@@ -111,6 +111,11 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
|
|||||||
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
|
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
|
||||||
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
||||||
the spot.
|
the spot.
|
||||||
|
- Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
|
||||||
|
refuses it — which is how the whitespace carry finds a line edge.
|
||||||
|
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell —
|
||||||
|
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the
|
||||||
|
general form owns the refusal. Refusing there refuses a document the general form spells.
|
||||||
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values
|
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and attribute values
|
||||||
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
|
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
|
||||||
waits near 2000.
|
waits near 2000.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
|
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
|
||||||
an HTML dialect.
|
an HTML dialect.
|
||||||
|
|
||||||
**Status: pre-release — `adfToMarkdown` emits the CommonMark subset and the block nodes, nothing
|
**Status: pre-release — `adfToMarkdown` emits every node the flavour spells but the opaque carry,
|
||||||
else is built.**
|
nothing else is built.**
|
||||||
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
||||||
[`spec/flavour.md`](spec/flavour.md).
|
[`spec/flavour.md`](spec/flavour.md).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=94 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=95 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+10
-7
@@ -63,7 +63,9 @@ literal bracket. Whitespace at either edge of `[content]`, space or tab, is part
|
|||||||
survives inline parsing. Each section below says whether content is required. `:` opens a
|
survives inline parsing. Each section below says whether content is required. `:` opens a
|
||||||
directive only when the name is followed immediately by `[` or `{`, and `{attrs}` must follow
|
directive only when the name is followed immediately by `[` or `{`, and `{attrs}` must follow
|
||||||
`]` (or the name) with no gap — anything else (`10:30`, `:smile:`, a stray `{…}` in text) is
|
`]` (or the name) with no gap — anything else (`10:30`, `:smile:`, a stray `{…}` in text) is
|
||||||
literal text.
|
literal text. An inline directive binds as a unit before bracket matching, the way a code span
|
||||||
|
does: a `]` or `(` inside its `{attrs}` is the directive's, never the enclosing content's, and a
|
||||||
|
`(` after its closing `]` opens no link.
|
||||||
|
|
||||||
**Container block**:
|
**Container block**:
|
||||||
|
|
||||||
@@ -99,8 +101,9 @@ form, and omits empty `{attrs}` except where the `{` itself claims the directive
|
|||||||
(`:hardBreak{}`).
|
(`:hardBreak{}`).
|
||||||
|
|
||||||
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
||||||
directive syntax — the leading `:` of a would-be directive, `]` inside content; outside code
|
directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right
|
||||||
spans and code blocks, a backslash before `:` in input yields a literal colon.
|
after a directive's closing `]`, which would otherwise be read as the attributes it has none
|
||||||
|
of; outside code spans and code blocks, a backslash before `:` in input yields a literal colon.
|
||||||
|
|
||||||
**Malformed directives are error results**, named: an unclosed container at end of input, a body
|
**Malformed directives are error results**, named: an unclosed container at end of input, a body
|
||||||
fence line of the container's length or longer, a bare colon-run line outside any container or
|
fence line of the container's length or longer, a bare colon-run line outside any container or
|
||||||
@@ -214,10 +217,10 @@ One header row plus plain inline cells is a pipe table; anything richer is the d
|
|||||||
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
|
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
|
||||||
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
||||||
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
||||||
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` in a code span,
|
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
|
||||||
link destination or link title: backslash escapes are inert in everything the inline layer spells
|
inline layer spells as syntax — a code span, a link destination or title, an inline directive's
|
||||||
as syntax, so pipe form cannot spell that pipe and the table takes the directive form. A pipe table
|
attributes: backslash escapes are inert there, so pipe form cannot spell that pipe and the table
|
||||||
parses back to exactly that shape.
|
takes the directive form. A pipe table parses back to exactly that shape.
|
||||||
|
|
||||||
```
|
```
|
||||||
| Part | Qty |
|
| Part | Qty |
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type AdfAttributes = { [key: string]: JsonValue }
|
|||||||
|
|
||||||
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
|
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
|
||||||
|
|
||||||
|
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
|
||||||
|
|
||||||
export type AdfMark = {
|
export type AdfMark = {
|
||||||
attrs?: AdfAttributes
|
attrs?: AdfAttributes
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test('names the node a refusal came from', () => {
|
|||||||
const unspellable: AdfNode = { attrs: { localId: 'a' }, type: 'paragraph' }
|
const unspellable: AdfNode = { attrs: { localId: 'a' }, type: 'paragraph' }
|
||||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'mention' })))), ['content', 0, 'content', 1])
|
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'blockCard' })))), ['content', 0, 'content', 1])
|
||||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,17 +93,15 @@ test('refuses a link attribute no markdown spelling holds', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark the canonical spellings cannot nest', () => {
|
test('refuses a mark the canonical spellings cannot nest', () => {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'underline' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'annotation' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses whitespace CommonMark cannot hold', () => {
|
test('refuses whitespace CommonMark cannot hold', () => {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: ' lead', type: 'text' })))), 'unspellable-whitespace')
|
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'trail ', type: 'text' })))), 'unspellable-whitespace')
|
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\nb', type: 'text' })))), 'unspellable-whitespace')
|
|
||||||
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({ marks: [{ type: 'em' }], text: 'x ', type: 'text' }, { text: 'y', 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', () => {
|
||||||
@@ -118,7 +116,7 @@ test('refuses two adjacent lists of the same kind', () => {
|
|||||||
test('refuses a node type the canonical form does not cover', () => {
|
test('refuses a node type the canonical form does not cover', () => {
|
||||||
assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type')
|
assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type')
|
||||||
assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type')
|
assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type')
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ type: 'mention' })))), 'unsupported-node-type')
|
assert.equal(code(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), 'unsupported-node-type')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
test('refuses a node whose content model the canonical form cannot emit', () => {
|
||||||
@@ -230,6 +228,9 @@ test('escapes a literal delimiter that would merge with an emitted one', () => {
|
|||||||
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: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\n')
|
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\n')
|
||||||
|
assert.equal(emitted(marked('x', { type: 'underline' }), { text: '{}', type: 'text' }), ':underline[x]\\{}\n')
|
||||||
|
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }, { text: '{color=red}', type: 'text' }), ':status[]\\{color=red}\n')
|
||||||
|
assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('escapes a hyphen underline a hard break would expose', () => {
|
test('escapes a hyphen underline a hard break would expose', () => {
|
||||||
@@ -372,10 +373,85 @@ test('spells a table as a pipe table only where every row and cell is plain', ()
|
|||||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
|
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
|
||||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
|
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
|
||||||
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))), 'unspelled-node-attribute')
|
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))), 'unspelled-node-attribute')
|
||||||
|
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ type: 'blockCard' }], type: 'paragraph' }))))), 'unsupported-node-type')
|
||||||
|
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: '\fa', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace')
|
||||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
||||||
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace')
|
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| :text{text=" "}a |\n| --- |\n')
|
||||||
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
||||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
||||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
||||||
|
const fallsBack = (node: AdfNode): boolean => directive(adfToMarkdown(table(row(cell('tableHeader', { content: [node], type: 'paragraph' })))))
|
||||||
|
assert.ok(fallsBack({ marks: [{ type: 'code' }], text: 'a|b', type: 'text' }))
|
||||||
|
assert.ok(fallsBack({ attrs: { style: 'a|b' }, type: 'status' }))
|
||||||
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('spells an inline node as a directive with its content slot and attributes', () => {
|
||||||
|
const emitted = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||||
|
assert.equal(emitted({ attrs: { timestamp: '1756080000000' }, type: 'date' }), ':date{timestamp=1756080000000}\n')
|
||||||
|
assert.equal(emitted({ type: 'mention' }), ':mention{}\n')
|
||||||
|
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }), ':status[]\n')
|
||||||
|
assert.equal(emitted({ attrs: { color: 'yellow', text: 'In review' }, type: 'status' }), ':status[In review]{color=yellow}\n')
|
||||||
|
assert.equal(emitted({ attrs: { id: '1f389', text: 'a]b' }, type: 'emoji' }), ':emoji[a\\]b]{id=1f389}\n')
|
||||||
|
assert.equal(emitted({ attrs: { data: { url: 'https://example.com/' } }, type: 'inlineCard' }), ':inlineCard{data="{\\"url\\":\\"https://example.com/\\"}"}\n')
|
||||||
|
assert.equal(emitted({ attrs: { height: 24 }, type: 'mediaInline' }), ':mediaInline{height=24}\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses an inline node attribute no section spells', () => {
|
||||||
|
const refused = (node: AdfNode): string => code(adfToMarkdown(document(paragraph(node))))
|
||||||
|
assert.equal(refused({ attrs: { rounded: true }, type: 'status' }), 'unspelled-node-attribute')
|
||||||
|
assert.equal(refused({ attrs: { toString: 'x' }, type: 'status' }), 'unspelled-node-attribute')
|
||||||
|
assert.equal(refused({ attrs: { color: 4 }, type: 'status' }), 'unsupported-node-shape')
|
||||||
|
assert.equal(refused({ attrs: { width: '2' }, type: 'mediaInline' }), 'unsupported-node-shape')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses the content and slot an inline directive has no room for', () => {
|
||||||
|
const refused = (node: AdfNode): string => code(adfToMarkdown(document(paragraph(node, { text: 'y', type: 'text' }))))
|
||||||
|
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], 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({ 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\u0000b' }, type: 'status' }), 'unspellable-character')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells the directive marks around the longest run they cover', () => {
|
||||||
|
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||||
|
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||||
|
const underline: AdfMark = { type: 'underline' }
|
||||||
|
assert.equal(emitted(marked('x', underline)), ':underline[x]\n')
|
||||||
|
assert.equal(emitted(marked('a', underline), marked('b', underline)), ':underline[ab]\n')
|
||||||
|
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), ':subsup[x]{type=sub}\n')
|
||||||
|
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), ':textColor[x]{color="#ae2e24"}\n')
|
||||||
|
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), ':border[x]{color="#091e42" size=2}\n')
|
||||||
|
assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_:underline[x]_\n')
|
||||||
|
assert.equal(emitted(marked('x', underline, { type: 'em' })), ':underline[_x_]\n')
|
||||||
|
assert.equal(emitted(marked('a', underline), { marks: [underline], type: 'hardBreak' }, marked('b', underline)), ':underline[a:hardBreak{}b]\n')
|
||||||
|
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
|
||||||
|
assert.equal(
|
||||||
|
emitted(marked('a', underline, link), { marks: [underline, link], type: 'hardBreak' }, marked('b', underline, link)),
|
||||||
|
':underline[[a:hardBreak{}b](https://example.com/)]\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
emitted(marked('a', link), { marks: [link], type: 'hardBreak' }, marked('b', link)),
|
||||||
|
'[a\\\nb](https://example.com/)\n',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses a mark directive attribute no spelling holds', () => {
|
||||||
|
const refused = (mark: AdfMark): string => code(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
||||||
|
assert.equal(refused({ attrs: { width: 2 }, type: 'border' }), 'unspellable-mark')
|
||||||
|
assert.equal(refused({ attrs: { size: '2' }, type: 'border' }), 'unspellable-mark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('carries whitespace CommonMark strips in the reserved text directive', () => {
|
||||||
|
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||||
|
assert.equal(emitted({ text: ' lead', type: 'text' }), ':text{text=" "}lead\n')
|
||||||
|
assert.equal(emitted({ text: 'trail ', type: 'text' }), 'trail:text{text=" "}\n')
|
||||||
|
assert.equal(emitted({ text: 'a\nb', type: 'text' }), 'a:text{text="\\n"}b\n')
|
||||||
|
assert.equal(emitted({ text: '\t', type: 'text' }), ':text{text="\\t"}\n')
|
||||||
|
assert.equal(emitted({ text: 'a ', type: 'text' }, { type: 'hardBreak' }, { text: ' b', type: 'text' }), 'a:text{text=" "}\\\n:text{text=" "}b\n')
|
||||||
|
assert.equal(emitted({ marks: [{ type: 'em' }], text: ' a ', type: 'text' }), '_:text{text=" "}a:text{text=" "}_\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 1 }, content: [{ text: 'x ', type: 'text' }], type: 'heading' }))), '# x:text{text=" "}\n')
|
||||||
|
})
|
||||||
|
|||||||
+11
-7
@@ -2,9 +2,9 @@ import type { AdfDocument, AdfNode } from './adf-document.ts'
|
|||||||
import type { BlockDirective } from './block-directives.ts'
|
import type { BlockDirective } from './block-directives.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from './json-value.ts'
|
||||||
import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
|
import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
|
||||||
import { emitImage } from './markdown-image.ts'
|
import { tryImage } from './markdown-image.ts'
|
||||||
import { emitInlineLine } from './markdown-inline.ts'
|
import { emitInlineLine } from './markdown-inline.ts'
|
||||||
import { emitPipeTable } from './markdown-pipe-table.ts'
|
import { tryPipeTable } from './markdown-pipe-table.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
||||||
import { isAdfDocument } from './adf-document.ts'
|
import { isAdfDocument } from './adf-document.ts'
|
||||||
@@ -98,7 +98,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
|
|||||||
|
|
||||||
function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
|
function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
|
||||||
if (!text.ok) return text
|
if (!text.ok) return text
|
||||||
return success({ fenceColons: 0, spelling: 'commonmark', text: text.value })
|
return success(commonMarkText(text.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonMarkText(text: string): EmittedBlock {
|
||||||
|
return { fenceColons: 0, spelling: 'commonmark', text }
|
||||||
}
|
}
|
||||||
|
|
||||||
function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
|
function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
|
||||||
@@ -131,15 +135,15 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const image = emitImage(node, path)
|
const image = tryImage(node, path)
|
||||||
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
||||||
return commonMarkLine(image)
|
return success(commonMarkText(image))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const pipe = emitPipeTable(node, path)
|
const pipe = tryPipeTable(node, path)
|
||||||
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
|
||||||
return commonMarkLine(pipe)
|
return success(commonMarkText(pipe))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||||
|
|||||||
+23
-30
@@ -1,15 +1,15 @@
|
|||||||
import type { AdfMark, AdfNode, AttributeKind } 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 { failure, success, type ConvertErrorPath, type Result } from './result.ts'
|
||||||
import { isBareToken, spellAttributeValue, spellAttributes, spellJsonAttribute } from './directive-attributes.ts'
|
import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
|
||||||
|
|
||||||
export type BlockDirective = {
|
export type BlockDirective = {
|
||||||
argument?: string
|
argument?: string
|
||||||
attributes: Readonly<Record<string, AttributeKind>>
|
attributes: AttributeVocabulary
|
||||||
body: 'block' | 'inline' | 'none'
|
body: 'block' | 'inline' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellAttributes: Readonly<Record<string, AttributeKind>> = {
|
const cellAttributes: AttributeVocabulary = {
|
||||||
background: 'string',
|
background: 'string',
|
||||||
colspan: 'number',
|
colspan: 'number',
|
||||||
colwidth: 'json',
|
colwidth: 'json',
|
||||||
@@ -18,9 +18,9 @@ const cellAttributes: Readonly<Record<string, AttributeKind>> = {
|
|||||||
valign: 'string',
|
valign: 'string',
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', title: 'string' }
|
const expandAttributes: AttributeVocabulary = { localId: 'string', title: 'string' }
|
||||||
|
|
||||||
const extensionAttributes: Readonly<Record<string, AttributeKind>> = {
|
const extensionAttributes: AttributeVocabulary = {
|
||||||
extensionKey: 'string',
|
extensionKey: 'string',
|
||||||
extensionType: 'string',
|
extensionType: 'string',
|
||||||
layout: 'string',
|
layout: 'string',
|
||||||
@@ -29,9 +29,9 @@ const extensionAttributes: Readonly<Record<string, AttributeKind>> = {
|
|||||||
text: 'string',
|
text: 'string',
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string' }
|
const itemAttributes: AttributeVocabulary = { localId: 'string' }
|
||||||
|
|
||||||
const mediaAttributes: Readonly<Record<string, AttributeKind>> = {
|
const mediaAttributes: AttributeVocabulary = {
|
||||||
alt: 'string',
|
alt: 'string',
|
||||||
collection: 'string',
|
collection: 'string',
|
||||||
height: 'number',
|
height: 'number',
|
||||||
@@ -43,7 +43,7 @@ const mediaAttributes: Readonly<Record<string, AttributeKind>> = {
|
|||||||
width: 'number',
|
width: 'number',
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncBlockAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', resourceId: 'string' }
|
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
|
||||||
|
|
||||||
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
|
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
|
||||||
blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' },
|
blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' },
|
||||||
@@ -81,30 +81,23 @@ export function blockDirective(type: string): BlockDirective | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
||||||
const pairs: [string, string][] = []
|
const argument = spellArgument(node, directive, path)
|
||||||
let argument = ''
|
if (!argument.ok) return argument
|
||||||
for (const [key, value] of Object.entries(node.attrs ?? {})) {
|
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
|
||||||
if (key === directive.argument) {
|
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
||||||
if (typeof value !== 'string' || !isBareToken(value)) {
|
|
||||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} holds no bare token the arg slot spells`, path)
|
|
||||||
}
|
|
||||||
argument = ` ${value}`
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const kind = attributeKind(directive, key)
|
|
||||||
if (kind === undefined) return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} has no canonical markdown spelling`, path)
|
|
||||||
const spelled = spellAttributeValue(value, kind)
|
|
||||||
if (spelled === undefined) return failure('unsupported-node-shape', `the ${node.type} attribute ${key} holds no ${kind}`, path)
|
|
||||||
pairs.push([key, spelled])
|
|
||||||
}
|
|
||||||
const marks = node.marks ?? []
|
const marks = node.marks ?? []
|
||||||
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||||
const attributes = spellAttributes(pairs)
|
const attributes = spellAttributes(spelled.pairs)
|
||||||
return success(`${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`)
|
return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function attributeKind(directive: BlockDirective, key: string): AttributeKind | undefined {
|
function spellArgument(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
|
||||||
return Object.hasOwn(directive.attributes, key) ? directive.attributes[key] : undefined
|
const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument]
|
||||||
|
if (value === undefined) return success('')
|
||||||
|
if (typeof value !== 'string' || !isBareToken(value)) {
|
||||||
|
return failure('unspelled-node-attribute', `the ${node.type} attribute ${directive.argument} holds no bare token the arg slot spells`, path)
|
||||||
|
}
|
||||||
|
return success(` ${value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function markValues(marks: readonly AdfMark[]): JsonValue {
|
function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||||
|
|||||||
+5
-11
@@ -13,8 +13,7 @@ const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
|||||||
const roundTripRoot = join(corpusRoot, 'round-trip')
|
const roundTripRoot = join(corpusRoot, 'round-trip')
|
||||||
const unspellableRoot = join(corpusRoot, 'unspellable')
|
const unspellableRoot = join(corpusRoot, 'unspellable')
|
||||||
|
|
||||||
const emittingDirectories = ['block-nodes', 'commonmark-subset']
|
const emittingDirectories = ['block-nodes', 'commonmark-subset', 'inline-nodes']
|
||||||
const pendingDirectories = ['inline-nodes']
|
|
||||||
|
|
||||||
function directoryNames(root: string): string[] {
|
function directoryNames(root: string): string[] {
|
||||||
return readdirSync(root, { withFileTypes: true })
|
return readdirSync(root, { withFileTypes: true })
|
||||||
@@ -41,21 +40,16 @@ function corpusJsonPaths(): string[] {
|
|||||||
.sort()
|
.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
test('every round-trip directory is either emitting or explicitly pending', () => {
|
test('every round-trip directory emits', () => {
|
||||||
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories, ...pendingDirectories].sort())
|
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort())
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const directory of [...emittingDirectories, ...pendingDirectories].sort()) {
|
|
||||||
test(`${directory} pairs every .json with a .md`, () => {
|
|
||||||
assert.deepEqual(fixtureNames(directory, '.json'), fixtureNames(directory, '.md'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const directory of emittingDirectories) {
|
for (const directory of emittingDirectories) {
|
||||||
const names = [...new Set([...fixtureNames(directory, '.json'), ...fixtureNames(directory, '.md')])].sort()
|
const names = [...new Set([...fixtureNames(directory, '.json'), ...fixtureNames(directory, '.md')])].sort()
|
||||||
|
|
||||||
test(`${directory} holds fixtures`, () => {
|
test(`${directory} pairs every .json with a .md`, () => {
|
||||||
assert.ok(names.length > 0, `${directory} is expected to emit but holds no fixture pairs`)
|
assert.ok(names.length > 0, `${directory} is expected to emit but holds no fixture pairs`)
|
||||||
|
assert.deepEqual(fixtureNames(directory, '.json'), fixtureNames(directory, '.md'))
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
import type { AttributeKind } 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_-]+$/
|
||||||
|
|
||||||
export function isBareToken(text: string): boolean {
|
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> {
|
||||||
|
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][] = []
|
||||||
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
|
if (key === slot) continue
|
||||||
|
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
||||||
|
if (kind === undefined) return { fault: { key, kind: undefined } }
|
||||||
|
const spelled = spellAttributeValue(value, kind)
|
||||||
|
if (spelled === undefined) return { fault: { key, kind } }
|
||||||
|
pairs.push([key, spelled])
|
||||||
|
}
|
||||||
|
return { pairs }
|
||||||
|
}
|
||||||
|
|
||||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||||
if (pairs.length === 0) return ''
|
if (pairs.length === 0) return ''
|
||||||
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`)
|
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`)
|
||||||
@@ -17,15 +40,15 @@ export function spellAttributes(pairs: readonly (readonly [string, string])[]):
|
|||||||
export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined {
|
export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined {
|
||||||
if (kind === 'json') return spellJsonAttribute(value)
|
if (kind === 'json') return spellJsonAttribute(value)
|
||||||
if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined
|
if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined
|
||||||
if (kind === 'number') return typeof value === 'number' ? spell(JSON.stringify(value)) : undefined
|
if (kind === 'number') return typeof value === 'number' ? spellStringAttribute(JSON.stringify(value)) : undefined
|
||||||
return typeof value === 'string' ? spell(value) : undefined
|
return typeof value === 'string' ? spellStringAttribute(value) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellJsonAttribute(value: JsonValue): string {
|
export function spellJsonAttribute(value: JsonValue): string {
|
||||||
return quote(serializeCanonicalJson(value, 'compact'))
|
return quote(serializeCanonicalJson(value, 'compact'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function spell(text: string): string {
|
export function spellStringAttribute(text: string): string {
|
||||||
return isBareToken(text) ? text : quote(text)
|
return isBareToken(text) ? text : quote(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
|
||||||
|
import type { AttributeFault } 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 = {
|
||||||
|
attributes: AttributeVocabulary
|
||||||
|
slot?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||||
|
date: { attributes: { localId: 'string', timestamp: 'string' } },
|
||||||
|
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' },
|
||||||
|
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
|
||||||
|
mediaInline: {
|
||||||
|
attributes: {
|
||||||
|
alt: 'string',
|
||||||
|
collection: 'string',
|
||||||
|
data: 'json',
|
||||||
|
height: 'number',
|
||||||
|
id: 'string',
|
||||||
|
localId: 'string',
|
||||||
|
occurrenceKey: 'string',
|
||||||
|
type: 'string',
|
||||||
|
width: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, slot: 'text' },
|
||||||
|
status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const markDirectives: Readonly<Record<string, AttributeVocabulary>> = {
|
||||||
|
border: { color: 'string', size: 'number' },
|
||||||
|
subsup: { type: 'string' },
|
||||||
|
textColor: { color: 'string' },
|
||||||
|
underline: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inlineDirective(type: string): InlineDirective | undefined {
|
||||||
|
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDirective(type: string): AttributeVocabulary | undefined {
|
||||||
|
return Object.hasOwn(markDirectives, type) ? markDirectives[type] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> {
|
||||||
|
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
|
||||||
|
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
|
||||||
|
return success(spellAttributes(spelled.pairs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> {
|
||||||
|
const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
|
||||||
|
if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path)
|
||||||
|
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)
|
||||||
|
}
|
||||||
+30
-25
@@ -1,8 +1,12 @@
|
|||||||
import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
|
||||||
|
|
||||||
|
export type EmphasisRole = 'close' | 'open'
|
||||||
|
|
||||||
|
export type InlineEscaping = 'attribute' | 'backslash' | 'bracketed' | 'none'
|
||||||
|
|
||||||
export type InlineSegment =
|
export type InlineSegment =
|
||||||
| { kind: 'emphasis-close' | 'emphasis-open'; mark: string; text: string }
|
| { emphasis: EmphasisRole; escaping: 'none'; mark: string; text: string }
|
||||||
| { kind: 'link-text' | 'literal' | 'syntax'; text: string }
|
| { emphasis?: undefined; escaping: InlineEscaping; text: string }
|
||||||
|
|
||||||
export type AssembledLine = { line: string; unspellableMark: string | undefined }
|
export type AssembledLine = { line: string; unspellableMark: string | undefined }
|
||||||
|
|
||||||
@@ -14,7 +18,7 @@ const delimiters = ['*', '_', '`', '~']
|
|||||||
|
|
||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||||
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
|
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
|
||||||
const linkOpener = /\](?=[([:])/
|
const linkOpener = /\](?=[([:])/
|
||||||
const unicodePunctuation = /[\p{P}\p{S}]/u
|
const unicodePunctuation = /[\p{P}\p{S}]/u
|
||||||
|
|
||||||
@@ -36,8 +40,8 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
|||||||
for (let index = 0; index < resolved.length; index += 1) {
|
for (let index = 0; index < resolved.length; index += 1) {
|
||||||
const segment = resolved[index]
|
const segment = resolved[index]
|
||||||
if (segment === undefined) continue
|
if (segment === undefined) continue
|
||||||
if (segment.kind === 'emphasis-open') open.push(index)
|
if (segment.emphasis === 'open') open.push(index)
|
||||||
if (segment.kind !== 'emphasis-close') continue
|
if (segment.emphasis !== 'close') continue
|
||||||
const openerIndex = open.pop()
|
const openerIndex = open.pop()
|
||||||
const opener = openerIndex === undefined ? undefined : resolved[openerIndex]
|
const opener = openerIndex === undefined ? undefined : resolved[openerIndex]
|
||||||
if (openerIndex === undefined || opener === undefined) continue
|
if (openerIndex === undefined || opener === undefined) continue
|
||||||
@@ -53,15 +57,15 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
|||||||
|
|
||||||
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||||
const scan = segments.map((segment) => segment.text).join('')
|
const scan = segments.map((segment) => segment.text).join('')
|
||||||
const kinds: InlineSegment['kind'][] = []
|
const escapings: InlineEscaping[] = []
|
||||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) kinds.push(segment.kind)
|
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
|
||||||
const escaped = new Set<number>()
|
const escaped = new Set<number>()
|
||||||
const placements: number[] = []
|
const placements: number[] = []
|
||||||
let output = ''
|
let output = ''
|
||||||
for (let index = 0; index < scan.length; index += 1) {
|
for (let index = 0; index < scan.length; index += 1) {
|
||||||
const kind = kinds[index]
|
const escaping = escapings[index]
|
||||||
const escapable = kind === 'literal' || kind === 'link-text'
|
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||||
if (escapable && (mergesWithSyntax(scan, kinds, index) || opensConstruct(scan, index, kind === 'link-text', container, escaped))) {
|
if (escapable && (mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, index, escaping === 'bracketed', container, escaped))) {
|
||||||
output += '\\'
|
output += '\\'
|
||||||
escaped.add(index)
|
escaped.add(index)
|
||||||
}
|
}
|
||||||
@@ -87,8 +91,8 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly
|
|||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
const start = placements[cursor] ?? 0
|
const start = placements[cursor] ?? 0
|
||||||
cursor += segment.text.length
|
cursor += segment.text.length
|
||||||
if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue
|
if (segment.emphasis === undefined) continue
|
||||||
const closes = segment.kind === 'emphasis-close'
|
const closes = segment.emphasis === 'close'
|
||||||
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)) {
|
||||||
@@ -108,28 +112,29 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly
|
|||||||
return runs
|
return runs
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergesWithSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number): boolean {
|
function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(kinds[index + 1])
|
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(escapings[index + 1])
|
||||||
|
if (character === '{') return scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1])
|
||||||
if (!delimiters.includes(character)) return false
|
if (!delimiters.includes(character)) return false
|
||||||
return touchesSyntax(scan, kinds, index, -1) || touchesSyntax(scan, kinds, index, 1)
|
return touchesSyntax(scan, escapings, index, -1) || touchesSyntax(scan, escapings, index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function touchesSyntax(scan: string, kinds: readonly (InlineSegment['kind'] | undefined)[], index: number, step: number): boolean {
|
function touchesSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number, step: number): boolean {
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
let cursor = index + step
|
let cursor = index + step
|
||||||
while (scan.charAt(cursor) === character && !isSyntax(kinds[cursor])) cursor += step
|
while (scan.charAt(cursor) === character && !isSyntax(escapings[cursor])) cursor += step
|
||||||
return scan.charAt(cursor) === character && isSyntax(kinds[cursor])
|
return scan.charAt(cursor) === character && isSyntax(escapings[cursor])
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSyntax(kind: InlineSegment['kind'] | undefined): boolean {
|
function isSyntax(escaping: InlineEscaping | undefined): boolean {
|
||||||
return kind === 'emphasis-close' || kind === 'emphasis-open' || kind === 'syntax'
|
return escaping === 'attribute' || escaping === 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
function opensConstruct(scan: string, index: number, inBrackets: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
||||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
if (container === 'heading' && closesHeading(scan, index)) return true
|
||||||
if (container === 'paragraph' && claimsLineStart(scan, index)) return true
|
if (container === 'paragraph' && claimsLineStart(scan, index)) return true
|
||||||
return claimsCharacter(scan, index, inLinkText, container, escaped)
|
return claimsCharacter(scan, index, inBrackets, container, escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimsLineStart(scan: string, index: number): boolean {
|
function claimsLineStart(scan: string, index: number): boolean {
|
||||||
@@ -145,15 +150,15 @@ function closesHeading(scan: string, index: number): boolean {
|
|||||||
return index === 0 || /[ \t]/.test(scan.charAt(index - 1))
|
return index === 0 || /[ \t]/.test(scan.charAt(index - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimsCharacter(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
function claimsCharacter(scan: string, index: number, inBrackets: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
const rest = scan.slice(index)
|
const rest = scan.slice(index)
|
||||||
if (inLinkText && (character === '[' || character === ']')) return true
|
if (inBrackets && (character === '[' || character === ']')) return true
|
||||||
if (character === '|') return container === 'table-cell'
|
if (character === '|') return container === 'table-cell'
|
||||||
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
||||||
if (character === '&') return startsEntityReference(rest)
|
if (character === '&') return startsEntityReference(rest)
|
||||||
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
||||||
if (character === ':') return inlineDirective.test(rest)
|
if (character === ':') return inlineDirectiveOpener.test(rest)
|
||||||
if (character === '[') return linkOpener.test(rest)
|
if (character === '[') return linkOpener.test(rest)
|
||||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||||
if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped)
|
if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped)
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import type { AdfNode } from './adf-document.ts'
|
import type { AdfNode } from './adf-document.ts'
|
||||||
import type { ConvertErrorPath, Result } from './result.ts'
|
import type { ConvertErrorPath } from './result.ts'
|
||||||
import { emitImageLine } from './markdown-inline.ts'
|
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
|
import { tryImageLine } from './markdown-inline.ts'
|
||||||
|
|
||||||
const centeredMediaSingle = '{"layout":"center"}'
|
const centeredMediaSingle = '{"layout":"center"}'
|
||||||
const imageAttributes = ['alt', 'type', 'url']
|
const imageAttributes = ['alt', 'type', 'url']
|
||||||
|
|
||||||
export function emitImage(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
|
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||||
const image = imageShape(node)
|
const image = imageShape(node)
|
||||||
if (image === undefined) return undefined
|
if (image === undefined) return undefined
|
||||||
const line = emitImageLine(image.alt, image.url, [...path, 'content', 0])
|
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||||
return line.ok ? line : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||||
|
|||||||
+141
-33
@@ -1,37 +1,53 @@
|
|||||||
import type { AdfMark, AdfNode } from './adf-document.ts'
|
import type { AdfMark, AdfNode } from './adf-document.ts'
|
||||||
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
import type { InlineDirective } from './inline-directives.ts'
|
||||||
|
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
|
||||||
|
import { inlineDirective, markDirective, 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, isUnicodeWhitespace } from './commonmark-grammar.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'
|
||||||
|
|
||||||
type InlineContext = {
|
type InlineContext = {
|
||||||
atBlockEnd: boolean
|
atBlockEnd: boolean
|
||||||
container: LineContainer
|
bracketed: boolean
|
||||||
inLinkText: boolean
|
|
||||||
path: ConvertErrorPath
|
path: ConvertErrorPath
|
||||||
|
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 }
|
||||||
|
|
||||||
|
const emphasisSpellings: Readonly<Record<string, string>> = { em: '_', strike: '~~', strong: '**' }
|
||||||
const linkAttributes = ['href', 'title']
|
const linkAttributes = ['href', 'title']
|
||||||
|
|
||||||
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 = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
|
const segments = lineSegments(nodes, container, path)
|
||||||
if (!segments.ok) return segments
|
if (!segments.ok) return segments
|
||||||
return finishLine(segments.value, container, path)
|
return finishLine(segments.value, container, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result<string> {
|
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||||
if (alt !== undefined && /^[ \t]|[ \t]$|[\n\r]/.test(alt)) {
|
const segments = lineSegments(nodes, 'table-cell', path)
|
||||||
return failure('unspellable-whitespace', 'a media alt holds whitespace no image description spells', path)
|
if (!segments.ok) return undefined
|
||||||
|
if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
|
||||||
|
const line = finishLine(segments.value, 'table-cell', path)
|
||||||
|
return line.ok ? line.value : undefined
|
||||||
}
|
}
|
||||||
if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path)
|
|
||||||
|
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||||
|
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||||
const destination = spellDestination(href, path)
|
const destination = spellDestination(href, path)
|
||||||
if (!destination.ok) return destination
|
if (!destination.ok) return undefined
|
||||||
const description: InlineSegment[] = alt === undefined ? [] : [{ kind: 'link-text', text: alt }]
|
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||||
return finishLine([{ kind: 'syntax', text: '` }], 'paragraph', path)
|
const line = finishLine([syntax('`)], 'paragraph', path)
|
||||||
|
return line.ok ? line.value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
|
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, path, spansLines: container === 'paragraph' })
|
||||||
|
if (!segments.ok) return segments
|
||||||
|
return success(carryStrippedWhitespace(segments.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||||
@@ -51,6 +67,51 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer
|
|||||||
return success(line)
|
return success(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spec/flavour.md, Inline nodes.
|
||||||
|
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||||
|
const carried: InlineSegment[] = []
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
const previous = segments[index - 1]
|
||||||
|
const next = segments[index + 1]
|
||||||
|
const leading = previous === undefined || previous.text.includes('\n')
|
||||||
|
const trailing = next === undefined || next.text.includes('\n')
|
||||||
|
carried.push(...carryEdges(segment, leading, trailing))
|
||||||
|
}
|
||||||
|
return carried
|
||||||
|
}
|
||||||
|
|
||||||
|
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
||||||
|
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||||
|
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||||
|
const body = segment.text.slice(head.length)
|
||||||
|
const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : ''
|
||||||
|
const middle = body.slice(0, body.length - tail.length)
|
||||||
|
const edges: InlineSegment[] = []
|
||||||
|
if (head !== '') edges.push(carriedText(head))
|
||||||
|
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||||
|
if (tail !== '') edges.push(carriedText(tail))
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
function carriedText(text: string): InlineSegment {
|
||||||
|
return { escaping: 'attribute', text: spellLeafDirective('text', spellAttributes([['text', spellStringAttribute(text)]])) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellLeafDirective(name: string, attributes: string): string {
|
||||||
|
return `:${name}${attributes === '' ? '{}' : attributes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntax(text: string): InlineSegment {
|
||||||
|
return { escaping: 'none', text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
||||||
|
if ((node.content ?? []).length > 0 || node.text !== undefined) {
|
||||||
|
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text`, path)
|
||||||
|
}
|
||||||
|
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<InlineSegment[]> {
|
||||||
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)
|
||||||
@@ -88,38 +149,85 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
|||||||
|
|
||||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<InlineSegment[]> {
|
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<InlineSegment[]> {
|
||||||
const path = nodePath(context, index)
|
const path = nodePath(context, index)
|
||||||
if (node.type !== 'hardBreak' && node.type !== 'text') {
|
|
||||||
return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`, path)
|
|
||||||
}
|
|
||||||
const unspelled = Object.keys(node.attrs ?? {})[0]
|
|
||||||
if (unspelled !== undefined) {
|
|
||||||
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
|
|
||||||
}
|
|
||||||
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)
|
||||||
if (node.type === 'hardBreak') {
|
if (node.type === 'text') return emitText(node, context, path)
|
||||||
if (context.container === 'paragraph' && !context.atBlockEnd) return success([{ kind: 'syntax', text: '\\\n' }])
|
if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
|
||||||
return success([{ kind: 'syntax', text: ':hardBreak{}' }])
|
const directive = inlineDirective(node.type)
|
||||||
|
if (directive === undefined) return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`, path)
|
||||||
|
return emitInlineDirective(node, directive, path)
|
||||||
}
|
}
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
|
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell', path)
|
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)
|
||||||
|
const empty = refuseContentAndText(node, path)
|
||||||
|
if (!empty.ok) return empty
|
||||||
|
if (context.spansLines && !context.atBlockEnd) return success([syntax('\\\n')])
|
||||||
|
return success([syntax(spellLeafDirective('hardBreak', ''))])
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
|
const empty = refuseContentAndText(node, path)
|
||||||
|
if (!empty.ok) return empty
|
||||||
|
const attributes = spellInlineNodeAttributes(node, directive, path)
|
||||||
|
if (!attributes.ok) return attributes
|
||||||
|
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
|
||||||
|
if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))])
|
||||||
|
if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, 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)
|
||||||
|
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||||
|
return success([syntax(`:${node.type}[`), ...content, syntax(`]${attributes.value}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
|
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 (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)
|
||||||
|
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||||
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)
|
||||||
return success([{ kind: context.inLinkText ? 'link-text' : 'literal', text: node.text }])
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
|
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||||
|
return success(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<InlineSegment[]> {
|
||||||
if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index))
|
if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index))
|
||||||
if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context)
|
if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context)
|
||||||
const path = nodePath(context, index)
|
const path = nodePath(context, index)
|
||||||
const spelling = mark.type === 'em' ? '_' : mark.type === 'strike' ? '~~' : mark.type === 'strong' ? '**' : undefined
|
const spelling = Object.hasOwn(emphasisSpellings, mark.type) ? emphasisSpellings[mark.type] : undefined
|
||||||
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
if (spelling !== undefined) return emitEmphasis(nodes, mark, spelling, depth, index, context, path)
|
||||||
|
const vocabulary = markDirective(mark.type)
|
||||||
|
if (vocabulary === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
||||||
|
const attributes = spellMarkAttributes(mark, vocabulary, path)
|
||||||
|
if (!attributes.ok) return attributes
|
||||||
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||||
|
if (!inner.ok) return inner
|
||||||
|
return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitEmphasis(
|
||||||
|
nodes: readonly AdfNode[],
|
||||||
|
mark: AdfMark,
|
||||||
|
spelling: string,
|
||||||
|
depth: number,
|
||||||
|
index: number,
|
||||||
|
context: InlineContext,
|
||||||
|
path: ConvertErrorPath,
|
||||||
|
): Result<InlineSegment[]> {
|
||||||
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path)
|
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path)
|
||||||
const inner = emitRun(nodes, depth + 1, index, context)
|
const inner = emitRun(nodes, depth + 1, index, context)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const text = inner.value.map((segment) => segment.text).join('')
|
const carried = carryStrippedWhitespace(inner.value)
|
||||||
|
const text = carried.map((segment) => segment.text).join('')
|
||||||
if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
|
if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
|
||||||
return success([{ kind: 'emphasis-open', mark: mark.type, text: spelling }, ...inner.value, { kind: 'emphasis-close', mark: mark.type, text: spelling }])
|
return success([
|
||||||
|
{ emphasis: 'open', escaping: 'none', mark: mark.type, text: spelling },
|
||||||
|
...carried,
|
||||||
|
{ emphasis: 'close', escaping: 'none', mark: mark.type, text: spelling },
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
@@ -133,7 +241,7 @@ function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErr
|
|||||||
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([{ kind: 'syntax', text: `${fence}${padded}${fence}` }])
|
return success([syntax(`${fence}${padded}${fence}`)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdsEdgeWhitespace(text: string): boolean {
|
function holdsEdgeWhitespace(text: string): boolean {
|
||||||
@@ -155,14 +263,14 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index
|
|||||||
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
|
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
|
||||||
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([{ kind: 'syntax', text: `<${href}>` }])
|
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([syntax(`<${href}>`)])
|
||||||
const destination = spellDestination(href, path)
|
const destination = spellDestination(href, path)
|
||||||
if (!destination.ok) return destination
|
if (!destination.ok) return destination
|
||||||
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
||||||
if (!spelledTitle.ok) return spelledTitle
|
if (!spelledTitle.ok) return spelledTitle
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, inLinkText: true })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([{ kind: 'syntax', text: '[' }, ...inner.value, { kind: 'syntax', text: `](${destination.value}${spelledTitle.value})` }])
|
return success([syntax('['), ...inner.value, syntax(`](${destination.value}${spelledTitle.value})`)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { AdfNode } from './adf-document.ts'
|
import type { AdfNode } from './adf-document.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import { tryPipeCell } from './markdown-inline.ts'
|
||||||
import { emitInlineLine } from './markdown-inline.ts'
|
import type { ConvertErrorPath } from './result.ts'
|
||||||
import { success, type ConvertErrorPath, type Result } from './result.ts'
|
|
||||||
|
|
||||||
export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
|
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||||
const rows = pipeRows(node)
|
const rows = pipeRows(node)
|
||||||
if (rows === undefined) return undefined
|
if (rows === undefined) return undefined
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
@@ -11,14 +10,14 @@ export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<str
|
|||||||
const cells: string[] = []
|
const cells: string[] = []
|
||||||
for (const [cellIndex, paragraph] of row.entries()) {
|
for (const [cellIndex, paragraph] of row.entries()) {
|
||||||
const content = paragraph.content ?? []
|
const content = paragraph.content ?? []
|
||||||
const line = content.length === 0 ? success('') : emitInlineLine(content, 'table-cell', [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||||
if (!line.ok) return line
|
if (line === undefined) return undefined
|
||||||
cells.push(line.value)
|
cells.push(line)
|
||||||
}
|
}
|
||||||
lines.push(`| ${cells.join(' | ')} |`)
|
lines.push(`| ${cells.join(' | ')} |`)
|
||||||
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
||||||
}
|
}
|
||||||
return success(lines.join('\n'))
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||||
@@ -49,18 +48,5 @@ function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
|||||||
const content = cell.content ?? []
|
const content = cell.content ?? []
|
||||||
const paragraph = content[0]
|
const paragraph = content[0]
|
||||||
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined
|
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined
|
||||||
return (paragraph.content ?? []).some(spellsPipeAsSyntax) ? undefined : paragraph
|
return paragraph
|
||||||
}
|
|
||||||
|
|
||||||
// A pipe the inline layer emits as syntax takes no backslash, so the cell has no pipe spelling.
|
|
||||||
function spellsPipeAsSyntax(child: AdfNode): boolean {
|
|
||||||
return (child.marks ?? []).some((mark) => {
|
|
||||||
if (mark.type === 'code') return (child.text ?? '').includes('|')
|
|
||||||
if (mark.type !== 'link') return false
|
|
||||||
return holdsPipe(mark.attrs?.['href']) || holdsPipe(mark.attrs?.['title'])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function holdsPipe(value: JsonValue | undefined): boolean {
|
|
||||||
return typeof value === 'string' && value.includes('|')
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,13 +71,13 @@ detail is settled at its own milestone.
|
|||||||
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
|
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
|
||||||
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
|
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
|
||||||
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
|
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
|
||||||
- [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment.kind` splits into
|
- [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
|
||||||
its two axes here — escapability (`attribute` for `:text{text="…"}`, `backslash`, `none`)
|
two axes — escapability (`attribute` for `:text{text="…"}`, `backslash`, `bracketed`,
|
||||||
and the emphasis role — rather than gaining a third value that means one of each. A lone
|
`none`) and the emphasis role. A lone surrogate in a text node emits verbatim and becomes
|
||||||
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
|
U+FFFD on any UTF-8 encode, a §2 break plain text still holds open — attribute values
|
||||||
plain text still holds open — attribute values already escape it. The inline directives
|
already escape it. The pipe form's fallback reads the emitted segments rather than naming
|
||||||
arriving here emit their attribute values as syntax, so `spellsPipeAsSyntax` grows with
|
the nodes whose attribute values spell a pipe as syntax, so 2e's `\u007c` narrows it in
|
||||||
them alongside the `|` escaping `spec/flavour.md` already mandates in a pipe cell.
|
one place.
|
||||||
- [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into
|
- [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into
|
||||||
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
|
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
|
||||||
info string, and the `codeBlock` whose language is `adf`.
|
info string, and the `codeBlock` whose language is `adf`.
|
||||||
@@ -90,6 +90,13 @@ detail is settled at its own milestone.
|
|||||||
paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`).
|
paragraph line inside a container body shaped like a closing fence (`:::`, `::: x`).
|
||||||
Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a
|
Guard `fenceNestingFault`'s bare-run pop here too — a run shorter than the open fence is a
|
||||||
fault, not a close — which today's emitter cannot reach.
|
fault, not a close — which today's emitter cannot reach.
|
||||||
|
Two moves land before the attribute spelling changes. One mark vocabulary:
|
||||||
|
`emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
|
||||||
|
`inline-directives.ts`, which holds four of the nine marks while the rest are branch
|
||||||
|
literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named
|
||||||
|
error `spec/flavour.md` promises. And `escaping: 'attribute'` earns its keep at the
|
||||||
|
`\u007c` rule or collapses into `none`: nothing the escaper does tells the two apart
|
||||||
|
today, since a carried segment holds only spaces, tabs and newlines.
|
||||||
The gate gains the collision property here: no two corpus documents may emit the same
|
The gate gains the collision property here: no two corpus documents may emit the same
|
||||||
bytes — one spelling for two documents is a round-trip break no parser can undo, and it is
|
bytes — one spelling for two documents is a round-trip break no parser can undo, and it is
|
||||||
provable without one. It also settles the emitter's one known approximation: delimiter
|
provable without one. It also settles the emitter's one known approximation: delimiter
|
||||||
@@ -108,11 +115,17 @@ detail is settled at its own milestone.
|
|||||||
assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin.
|
assumes — CommonMark flanking, as for `*` — which `spec/flavour.md` does not yet pin.
|
||||||
`src/` gets its hierarchy at the same split — `adf/`,
|
`src/` gets its hierarchy at the same split — `adf/`,
|
||||||
`markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is
|
`markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is
|
||||||
still mechanical. `block-directives.ts` is the one file that does not move whole: the node
|
still mechanical. Three files do not move whole: `block-directives.ts` and
|
||||||
table is ADF knowledge milestones 6-7 need too and belongs in `adf/`, `spellDirectiveHeader`
|
`inline-directives.ts` each hold a node table milestones 6-7 need in `adf/` beside a
|
||||||
in `markdown/`. `AttributeKind` stays above both — it is the vocabulary a string-typed
|
markdown spelling that belongs in `markdown/`, and `directive-attributes.ts` fuses the
|
||||||
attribute grammar needs, which is why HTML will want it too, not a markdown spelling. The table is a second copy of `spec/flavour.md`'s prose with no drift guard,
|
format-neutral conformance walk (`vocabularyPairs`) with the markdown value spelling HTML
|
||||||
and a mistyped attribute name degrades into a false refusal no test catches.
|
has no use for. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts`
|
||||||
|
here too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six
|
||||||
|
concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary`
|
||||||
|
stay above all of it — the vocabulary a string-typed attribute grammar needs, which is why
|
||||||
|
HTML will want them too, not a markdown spelling. Both node tables are a second copy of
|
||||||
|
`spec/flavour.md`'s prose with no drift guard, and a mistyped attribute name degrades into a
|
||||||
|
false refusal no test catches.
|
||||||
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and
|
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and
|
||||||
3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality
|
3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality
|
||||||
the round-trip asserts, which over normalized input is the canonical serializer's compact
|
the round-trip asserts, which over normalized input is the canonical serializer's compact
|
||||||
|
|||||||
Reference in New Issue
Block a user