Merge pull request 'Emitter 2d: the opaque carry in both positions and the reserved info string' (#15) from opaque-carry into main
CI / gate (push) Successful in 4s

This commit was merged in pull request #15.
This commit is contained in:
2026-08-26 16:55:16 +02:00
19 changed files with 407 additions and 34 deletions
+10 -5
View File
@@ -27,7 +27,11 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
## 3. Unknown input policy ## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and - Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands, `hardBreak`, `listItem` and `text` where a
block belongs excepted. Where a container's own spelling cannot hold the child it has — a
`bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that
instead.
- Unmappable foreign HTML element: error result naming the element — never a silent drop. - Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce - Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job. mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
@@ -116,9 +120,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell — - 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 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. 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 JSON values — an
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
waits near 2000. `Result` rather than the stack overflow that waits near 2000.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. unrepresentable.
@@ -147,7 +151,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 node carrying the same mark type twice earns stays, no shipped CSS (§4), no streaming APIs, no performance budget — so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no
streaming APIs, no performance budget —
conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a
non-goal. non-goal.
+1 -2
View File
@@ -3,8 +3,7 @@
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 every node the flavour spells but the opaque carry, **Status: pre-release — `adfToMarkdown` only, and not yet every document.**
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).
@@ -0,0 +1,33 @@
{
"content": [
{
"content": [
{
"attrs": {
"url": "https://example.com/quoted"
},
"type": "blockCard"
}
],
"type": "blockquote"
},
{
"content": [
{
"content": [
{
"attrs": {
"url": "https://example.com/listed"
},
"type": "blockCard"
}
],
"type": "listItem"
}
],
"type": "bulletList"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,17 @@
> ```adf
> {
> "attrs": {
> "url": "https://example.com/quoted"
> },
> "type": "blockCard"
> }
> ```
- ```adf
{
"attrs": {
"url": "https://example.com/listed"
},
"type": "blockCard"
}
```
@@ -0,0 +1,30 @@
{
"content": [
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "{\n \"type\": \"blockCard\"\n}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "```",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,29 @@
```adf
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "{\n \"type\": \"blockCard\"\n}",
"type": "text"
}
],
"type": "codeBlock"
}
```
````adf
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "```",
"type": "text"
}
],
"type": "codeBlock"
}
````
@@ -0,0 +1,37 @@
{
"content": [
{
"attrs": {
"url": "https://example.com/roadmap"
},
"type": "blockCard"
},
{
"attrs": {
"panelType": "info"
},
"content": [
{
"content": [
{
"text": "The card below has no spelling yet.",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"layout": "wide",
"url": "https://example.com/board",
"width": 100
},
"type": "embedCard"
}
],
"type": "panel"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,23 @@
```adf
{
"attrs": {
"url": "https://example.com/roadmap"
},
"type": "blockCard"
}
```
:::panel info
The card below has no spelling yet.
```adf
{
"attrs": {
"layout": "wide",
"url": "https://example.com/board",
"width": 100
},
"type": "embedCard"
}
```
:::
@@ -0,0 +1,102 @@
{
"content": [
{
"content": [
{
"text": "Draft: ",
"type": "text"
},
{
"attrs": {
"text": "Add a summary"
},
"type": "placeholder"
},
{
"text": " — expected Friday.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "Blocked",
"type": "text"
},
{
"text": " on ",
"type": "text"
},
{
"attrs": {
"extensionKey": "jira-issue",
"extensionType": "com.atlassian.jira.macro"
},
"marks": [
{
"type": "strong"
}
],
"type": "inlineExtension"
},
{
"text": " until the rebuild lands.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": ":",
"type": "text"
},
{
"type": "blockCard"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "strong"
}
],
"type": "placeholder"
},
{
"marks": [
{
"type": "strong"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,7 @@
Draft: :adf{json="{\"attrs\":{\"text\":\"Add a summary\"},\"type\":\"placeholder\"}"} — expected Friday.
**Blocked** on :adf{json="{\"attrs\":{\"extensionKey\":\"jira-issue\",\"extensionType\":\"com.atlassian.jira.macro\"},\"marks\":[{\"type\":\"strong\"}],\"type\":\"inlineExtension\"}"} until the rebuild lands.
\::adf{json="{\"type\":\"blockCard\"}"}
**a**:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"type\":\"placeholder\"}"}**b**
+5 -2
View File
@@ -113,8 +113,11 @@ literal-text fallback — a typo that reparses as prose is the silent loss §2 r
## The opaque carry (AGENTS.md §3) ## The opaque carry (AGENTS.md §3)
A node type the library does not know rides as its raw JSON and restores to a deep-equal node. A node no section spells where it stands — an unknown type, or a known one whose spelling belongs
Block and inline positions canonicalize differently, each fitting where it sits: to the other position — rides as its raw JSON and restores to a deep-equal node. `hardBreak`,
`listItem` and `text` where a block belongs are the exception: their spelling lives inside another
node's body, so the misplacement is a named error. Block and inline positions canonicalize
differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `adf`, body = the node's JSON — - **Block position**: a fenced code block with info string `adf`, body = the node's JSON —
two-space indent, object keys sorted. two-space indent, object keys sorted.
+34 -7
View File
@@ -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: 'blockCard' })))), ['content', 0, 'content', 1]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), []) assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
}) })
@@ -60,9 +60,9 @@ test('refuses an ordered list whose markdown start is ambiguous', () => {
test('refuses the code block info strings the fence cannot hold', () => { test('refuses the code block info strings the fence cannot hold', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { language: '' }, type: 'codeBlock' }))), 'ambiguous-attribute-spelling') assert.equal(code(adfToMarkdown(document({ attrs: { language: '' }, type: 'codeBlock' }))), 'ambiguous-attribute-spelling')
assert.equal(code(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), 'reserved-adf-language')
assert.equal(code(adfToMarkdown(document({ attrs: { language: 'a`b' }, type: 'codeBlock' }))), 'unspellable-code-block-language') assert.equal(code(adfToMarkdown(document({ attrs: { language: 'a`b' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
assert.equal(code(adfToMarkdown(document({ attrs: { language: ' sql' }, type: 'codeBlock' }))), 'unspellable-code-block-language') assert.equal(code(adfToMarkdown(document({ attrs: { language: ' sql' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
assert.equal(code(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
}) })
test('refuses a link destination CommonMark cannot spell', () => { test('refuses a link destination CommonMark cannot spell', () => {
@@ -113,10 +113,33 @@ test('refuses two adjacent lists of the same kind', () => {
assert.equal(code(adfToMarkdown(document(list, list))), 'unspellable-adjacent-lists') assert.equal(code(adfToMarkdown(document(list, list))), 'unspellable-adjacent-lists')
}) })
test('refuses a node type the canonical form does not cover', () => { test('carries a node type no section spells', () => {
assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type') assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf\n{\n "type": "blockCard"\n}\n```\n')
assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type') assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n')
assert.equal(code(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), 'unsupported-node-type') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), ':adf{json="{\\"type\\":\\"blockCard\\"}"}\n')
})
test('carries the code block whose language is the reserved info string', () => {
assert.equal(
markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))),
'```adf\n{\n "attrs": {\n "language": "adf"\n },\n "type": "codeBlock"\n}\n```\n',
)
})
test('breaks a mark run at the node it carries', () => {
const strong: AdfMark = { type: 'strong' }
const carried: AdfNode = { marks: [strong], type: 'placeholder' }
assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [strong], text: 'a', type: 'text' }, carried, { marks: [strong], text: 'b', type: 'text' })))),
'**a**:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"placeholder\\"}"}**b**\n',
)
})
test('refuses a carried node nested deeper than the emitter carries', () => {
let node: AdfNode = { type: 'blockCard' }
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-node-shape')
}) })
test('refuses a node whose content model the canonical form cannot emit', () => { test('refuses a node whose content model the canonical form cannot emit', () => {
@@ -373,7 +396,10 @@ 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(
markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ type: 'blockCard' }], type: 'paragraph' }))))),
'| :adf{json="{\\"type\\":\\"blockCard\\"}"} |\n| --- |\n',
)
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: '\fa', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace') 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(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| :text{text=" "}a |\n| --- |\n') assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| :text{text=" "}a |\n| --- |\n')
@@ -383,6 +409,7 @@ test('spells a table as a pipe table only where every row and cell is plain', ()
const fallsBack = (node: AdfNode): boolean => directive(adfToMarkdown(table(row(cell('tableHeader', { content: [node], type: 'paragraph' }))))) 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({ marks: [{ type: 'code' }], text: 'a|b', type: 'text' }))
assert.ok(fallsBack({ attrs: { style: 'a|b' }, type: 'status' })) assert.ok(fallsBack({ attrs: { style: 'a|b' }, type: 'status' }))
assert.ok(fallsBack({ attrs: { url: 'https://example.com/?a|b' }, type: 'blockCard' }))
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')
}) })
+9 -7
View File
@@ -5,11 +5,12 @@ import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
import { tryImage } from './markdown-image.ts' import { tryImage } from './markdown-image.ts'
import { emitInlineLine } from './markdown-inline.ts' import { emitInlineLine } from './markdown-inline.ts'
import { tryPipeTable } from './markdown-pipe-table.ts' import { tryPipeTable } from './markdown-pipe-table.ts'
import { carriedBlock, carryName } 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 { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts' import { isAdfDocument } from './adf-document.ts'
import { largestNesting } from './nesting.ts' import { largestNesting } from './nesting.ts'
import { longestBacktickRun } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
type BlockContainer = 'directive' | 'document' | 'list-item' type BlockContainer = 'directive' | 'document' | 'list-item'
type BlockSpelling = 'commonmark' | 'directive' type BlockSpelling = 'commonmark' | 'directive'
@@ -93,7 +94,7 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') { if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') {
return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path) return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path)
} }
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path) return commonMarkLine(carriedBlock(node, path))
} }
function commonMarkLine(text: Result<string>): Result<EmittedBlock> { function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
@@ -159,6 +160,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
} }
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
if (node.attrs?.['language'] === carryName) return carriedBlock(node, path)
const validation = validateBlockNode(node, ['language'], path) const validation = validateBlockNode(node, ['language'], path)
if (!validation.ok) return validation if (!validation.ok) return validation
const info = spellCodeFenceInfo(node.attrs?.['language'], path) const info = spellCodeFenceInfo(node.attrs?.['language'], path)
@@ -173,9 +175,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath) if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text text += child.text
} }
const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1)) return success(fencedCodeBlock(info.value, text))
const opening = `${fence}${info.value}`
return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`)
} }
function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> { function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> {
@@ -184,10 +184,12 @@ function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorP
if (language === '') { if (language === '') {
return failure('ambiguous-attribute-spelling', 'an empty codeBlock language and an absent one share one markdown spelling', path) return failure('ambiguous-attribute-spelling', 'an empty codeBlock language and an absent one share one markdown spelling', path)
} }
if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry', path)
if (/[`\n\r]/.test(language) || language !== language.trim()) { if (/[`\n\r]/.test(language) || language !== language.trim()) {
return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace', path) return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace', path)
} }
if (holdsEntityReference(language)) {
return failure('unspellable-code-block-language', 'a fence info string shaped like an entity reference decodes on the way back', path)
}
return success(language) return success(language)
} }
+5
View File
@@ -1,3 +1,8 @@
export function fencedCodeBlock(info: string, body: string): string {
const fence = '`'.repeat(Math.max(3, longestBacktickRun(body) + 1))
return body === '' ? `${fence}${info}\n${fence}` : `${fence}${info}\n${body}\n${fence}`
}
export function longestBacktickRun(text: string): number { export function longestBacktickRun(text: string): number {
let longest = 0 let longest = 0
let current = 0 let current = 0
+1 -1
View File
@@ -13,7 +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', 'inline-nodes'] const emittingDirectories = ['block-nodes', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
function directoryNames(root: string): string[] { function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true }) return readdirSync(root, { withFileTypes: true })
+15 -5
View File
@@ -4,6 +4,7 @@ import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineC
import { inlineDirective, markDirective, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.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 { 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'
@@ -131,7 +132,8 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
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
const mark = (node.marks ?? [])[depth] // spec/flavour.md, Marks.
const mark = carries(node) ? undefined : (node.marks ?? [])[depth]
if (mark === undefined) { if (mark === undefined) {
runs.push({ index, kind: 'plain', node }) runs.push({ index, kind: 'plain', node })
continue continue
@@ -147,15 +149,23 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] return [...context.path, 'content', index]
} }
function carries(node: AdfNode): boolean {
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<InlineSegment[]> {
const path = nodePath(context, index) const path = nodePath(context, index)
if (carries(node)) {
const carried = carriedInline(node, path)
if (!carried.ok) return carried
return success([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)
if (node.type === 'text') return emitText(node, context, path)
if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
const directive = inlineDirective(node.type) 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) if (directive !== undefined) return emitInlineDirective(node, directive, path)
return emitInlineDirective(node, directive, path) if (node.type === 'hardBreak') return emitHardBreak(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<InlineSegment[]> {
+29
View File
@@ -0,0 +1,29 @@
import type { AdfNode } from './adf-document.ts'
import type { JsonSpelling } from './canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { isJsonValue } from './json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from './nesting.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
export const carryName = 'adf'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'two-space', path)
if (!json.ok) return json
return success(fencedCodeBlock(carryName, json.value))
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path)
if (!json.ok) return json
return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`)
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
if (!isJsonValue(node)) {
return failure('unsupported-node-shape', `a carried node's JSON nests deeper than the ${largestNesting} levels the emitter carries`, path)
}
return success(serializeCanonicalJson(node, spelling))
}
-2
View File
@@ -1,7 +1,6 @@
export type ConvertErrorCode = export type ConvertErrorCode =
| 'ambiguous-attribute-spelling' | 'ambiguous-attribute-spelling'
| 'not-an-adf-document' | 'not-an-adf-document'
| 'reserved-adf-language'
| 'unspellable-adjacent-lists' | 'unspellable-adjacent-lists'
| 'unspellable-character' | 'unspellable-character'
| 'unspellable-code-block-language' | 'unspellable-code-block-language'
@@ -17,7 +16,6 @@ export type ConvertErrorCode =
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-heading-level' | 'unsupported-heading-level'
| 'unsupported-node-shape' | 'unsupported-node-shape'
| 'unsupported-node-type'
export type ConvertErrorPath = readonly (number | string)[] export type ConvertErrorPath = readonly (number | string)[]
+20 -3
View File
@@ -42,7 +42,9 @@ detail is settled at its own milestone.
directive block in a container body — an `expand` whose content is `paragraph` "A" then a directive block in a container body — an `expand` whose content is `paragraph` "A" then a
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on `panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
pick here; `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's pick here; the answer governs every unknown node type too, the block carry counting as a
CommonMark block since its spelling is a fenced code block.
`unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now `::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
follows whether CommonMark can spell its URL, two sibling images differing only by an follows whether CommonMark can spell its URL, two sibling images differing only by an
`&amp;` land in the same refusal. `&amp;` land in the same refusal.
@@ -80,7 +82,9 @@ detail is settled at its own milestone.
one place. 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` — carried whole ahead of the
attribute fallback 2e owes, since the reservation leaves that node no other spelling
whatever 1d decides for its `localId`.
- [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into - [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`: the three carve-outs and their escapes, mark runs — the `corpus/round-trip/combinations/`: the three carve-outs and their escapes, mark runs — the
longest-run rule, attributes included — and the runs a carry breaks, a mark spelling that longest-run rule, attributes included — and the runs a carry breaks, a mark spelling that
@@ -97,6 +101,16 @@ detail is settled at its own milestone.
error `spec/flavour.md` promises. And `escaping: 'attribute'` earns its keep at the 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 `\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. today, since a carried segment holds only spaces, tabs and newlines.
The carry's fallback triggers land here too: `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
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 `\u007c` rule is wider than
the pipe: a quoted attribute value spells `` ` ``, `&` and `<` raw today, so a backtick
pair in an `inlineCard`'s `data`, a `status`'s `style` or the inline carry's `json` reads
back as a code span, an entity or raw HTML inside the value. One escape settles all four,
and the pick decides whether a directive or a code span wins where they overlap (3). The
carry spells `escaping: 'none'` meanwhile, which is what makes `tryPipeCell` refuse the
pipe form for a carry whose JSON holds a pipe.
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
@@ -112,7 +126,10 @@ detail is settled at its own milestone.
it must return). The raw-HTML element mapping is empty until milestone 6, so at `0.1.0` it must return). The raw-HTML element mapping is empty until milestone 6, so at `0.1.0`
every raw-HTML construct in input is an error result. The CommonMark spec suite runs every raw-HTML construct in input is an error result. The CommonMark spec suite runs
against it from here (§10). The parser owes `~` the same `can_open`/`can_close` the emitter against it from here (§10). The parser owes `~` the same `can_open`/`can_close` the emitter
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, and
the precedence between a directive and the constructs a raw attribute value opens inside it —
a code span, an entity, raw HTML — which one directive alone already reaches until 2e's
escape lands.
`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. Three files do not move whole: `block-directives.ts` and still mechanical. Three files do not move whole: `block-directives.ts` and