Answer the review: one shape predicate, and the readable spellings give way
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -125,9 +125,11 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
|
||||
`matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and
|
||||
opener search stay whole: broken into named steps they drift from the algorithm being faithful is
|
||||
the whole point of.
|
||||
- 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.
|
||||
- A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe
|
||||
table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only
|
||||
where the general form fails on the same node. Refusing there refuses a document the general
|
||||
form spells, so a refusal the general form does not share belongs in the general form or
|
||||
nowhere.
|
||||
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
|
||||
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
|
||||
`Result` rather than the stack overflow that waits near 2000.
|
||||
|
||||
@@ -51,6 +51,27 @@
|
||||
}
|
||||
],
|
||||
"type": "codeBlock"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"panelType": "info"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"language": "text",
|
||||
"wrap": true
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": ":::",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "codeBlock"
|
||||
}
|
||||
],
|
||||
"type": "panel"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
|
||||
@@ -21,3 +21,11 @@ SELECT 1
|
||||
y
|
||||
```
|
||||
:::
|
||||
|
||||
::::panel info
|
||||
:::codeBlock {wrap=true}
|
||||
```text
|
||||
:::
|
||||
```
|
||||
:::
|
||||
::::
|
||||
|
||||
+13
-14
@@ -18,8 +18,9 @@ normalizes to it through the round-trip.
|
||||
inline, so a `~~~` line opens a CommonMark tilde code fence.
|
||||
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's
|
||||
`order` attribute. Continuation lines align with the first character after the marker
|
||||
(two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none before
|
||||
a nested list. Blank lines between items normalize away; ADF does not record tightness.
|
||||
(two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none
|
||||
between a nested list and a CommonMark block above it. Blank lines between items normalize
|
||||
away; ADF does not record tightness.
|
||||
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
|
||||
- ATX headings (`#` … `######`); setext input normalizes to ATX.
|
||||
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick
|
||||
@@ -31,8 +32,8 @@ normalizes to it through the round-trip.
|
||||
inside it, directive syntax and `~~` included.
|
||||
- Thematic break `---`.
|
||||
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where
|
||||
CommonMark admits no spelling — the end of a block, inside a heading — or where the node carries
|
||||
an attribute, it is the inline directive.
|
||||
CommonMark admits no spelling — the end of a block, inside a heading — or where the node
|
||||
carries an attribute, it is the inline directive.
|
||||
- An empty paragraph — real payloads carry them — is `::paragraph`.
|
||||
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes.
|
||||
`<url>` autolink form only when the text equals the destination and the destination is a valid
|
||||
@@ -155,9 +156,8 @@ editor-normal ADF reads an empty attrs object, marks array or content array as t
|
||||
|
||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
||||
`json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
|
||||
Three child nodes hold inline content rather than blocks (`caption`, `decisionItem`,
|
||||
`taskItem`): their body is at most one paragraph, whose inline content becomes the node's
|
||||
`content`; any other body is a named error.
|
||||
A section saying its body is inline takes at most one paragraph, whose inline content becomes
|
||||
the node's `content`; any other body is a named error, and a node holding no content is the leaf.
|
||||
|
||||
A node the sections cannot spell rides the opaque carry: an attrs key its section does not
|
||||
list, a value that is not the section's type, or an arg-slot value that is no bare token. In
|
||||
@@ -181,8 +181,7 @@ form.
|
||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number).
|
||||
`order` is the first marker, so a list carrying none, or one whose markers would run past
|
||||
999999999, has no CommonMark spelling.
|
||||
- `paragraph` — container, inline body; holding no content it is the leaf `::paragraph`. No
|
||||
attributes beyond `localId`.
|
||||
- `paragraph` — container, inline body; no attributes beyond `localId`.
|
||||
- `rule` — leaf; no attributes beyond `localId`.
|
||||
|
||||
````
|
||||
@@ -364,11 +363,11 @@ attributes. The other three share: `extensionKey` (string), `extensionType` (str
|
||||
|
||||
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
|
||||
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
|
||||
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content parsing to anything
|
||||
but one unmarked text node — adjacent identical-mark text nodes merged first — is a named
|
||||
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the
|
||||
slot. The rest take no content, `:text` included; content on a node that takes none is a named
|
||||
error.
|
||||
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
||||
parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first —
|
||||
is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach
|
||||
into the slot. The rest take no content, `:text` included; content on a node that takes none is a
|
||||
named error.
|
||||
|
||||
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
|
||||
|
||||
@@ -29,6 +29,11 @@ const documentKeys = ['content', 'type', 'version']
|
||||
const markKeys = ['attrs', 'type']
|
||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||
|
||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
|
||||
return holdsOnly(node.attrs ?? {}, attributes)
|
||||
}
|
||||
|
||||
export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false
|
||||
if (value['type'] !== 'doc') return false
|
||||
|
||||
+23
-12
@@ -41,8 +41,11 @@ test('refuses a document version the markdown cannot carry', () => {
|
||||
assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
|
||||
})
|
||||
|
||||
test('refuses a text node attribute the canonical form does not spell', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))), 'unspelled-node-attribute')
|
||||
test('carries a text node attribute no spelling holds', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))),
|
||||
':adf{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => {
|
||||
@@ -160,9 +163,12 @@ test('refuses a carried node nested deeper than the emitter carries', () => {
|
||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a list its own markers cannot hold as a directive', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n')
|
||||
})
|
||||
|
||||
test('spells an ordered list no marker fits as a directive', () => {
|
||||
@@ -321,12 +327,12 @@ test('escapes a hyphen underline a hard break would expose', () => {
|
||||
assert.equal(line('=='), 'foo\\\n\\==\n')
|
||||
})
|
||||
|
||||
test('refuses a list item whose marker completes a thematic break', () => {
|
||||
test('spells a list item whose marker completes a thematic break as a directive', () => {
|
||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
||||
assert.equal(code(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), 'unspellable-line-start')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '::::bulletList\n:::listItem\n---\n:::\n::::\n')
|
||||
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), 'unspellable-line-start')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '::::bulletList\n:::listItem\n- -\n:::\n::::\n')
|
||||
})
|
||||
|
||||
test('refuses the characters CommonMark rewrites', () => {
|
||||
@@ -382,7 +388,8 @@ test('spells a block directive as its node type, arg and attributes', () => {
|
||||
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), ':::caption\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), ':::caption\nx\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '::caption\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
|
||||
})
|
||||
|
||||
@@ -429,9 +436,10 @@ test('separates two directive blocks in a container body by one line, two Common
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel({ type: 'caption' }, { type: 'caption' }))), '::::panel info\n:::caption\n:::\n:::caption\n:::\n::::\n')
|
||||
assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation')
|
||||
const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n')
|
||||
assert.equal(code(adfToMarkdown(panel(text('a'), caption))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(caption, text('a')))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation')
|
||||
})
|
||||
|
||||
@@ -451,6 +459,9 @@ test('spells the image form for exactly the centered external media shape', () =
|
||||
assert.ok(fallback({ alt: 4, type: 'external', url }))
|
||||
assert.ok(fallback({ type: 'external', url: 4 }))
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||
const media: AdfNode = { attrs: { type: 'external', url }, type: 'media' }
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a mediaSingle the image form does not fit as a directive', () => {
|
||||
|
||||
+14
-24
@@ -8,7 +8,7 @@ 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 { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
|
||||
import { isAdfDocument } from './adf-document.ts'
|
||||
import { carriesOnly, isAdfDocument } from './adf-document.ts'
|
||||
import { largestNesting } from './nesting.ts'
|
||||
import { fencedCodeBlock } from './backtick-runs.ts'
|
||||
|
||||
@@ -120,8 +120,7 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv
|
||||
if (directive.body === 'code') return emitCodeDirective(node, directive, path)
|
||||
const header = spellDirectiveHeader(node, directive)
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
// spec/flavour.md, The CommonMark blocks: an empty paragraph is the leaf.
|
||||
if (directive.body === 'none' || (node.type === 'paragraph' && content.length === 0)) {
|
||||
if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) {
|
||||
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
|
||||
}
|
||||
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
|
||||
@@ -140,7 +139,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||
if (!commonMarkHead(node, [])) return undefined
|
||||
if (!carriesOnly(node, [])) return undefined
|
||||
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
const text = inner.value.text
|
||||
@@ -151,7 +150,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
|
||||
}
|
||||
|
||||
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!commonMarkHead(node, ['language'])) return undefined
|
||||
if (!carriesOnly(node, ['language'])) return undefined
|
||||
const info = fenceInfo(node.attrs?.['language'])
|
||||
if (info === undefined) return undefined
|
||||
const text = codeBlockText(node, path)
|
||||
@@ -198,7 +197,7 @@ function fenceInfo(language: JsonValue | undefined): string | undefined {
|
||||
}
|
||||
|
||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!commonMarkHead(node, ['level'])) return undefined
|
||||
if (!carriesOnly(node, ['level'])) return undefined
|
||||
const level = node.attrs?.['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||
const hashes = '#'.repeat(level)
|
||||
@@ -211,17 +210,16 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
|
||||
|
||||
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
|
||||
const ordered = node.type === 'orderedList'
|
||||
if (!commonMarkHead(node, ordered ? ['order'] : [])) return undefined
|
||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
||||
const items = node.content ?? []
|
||||
const start = listStart(node, items.length)
|
||||
if (start === undefined || items.some((item) => !commonMarkHead(item, []))) return undefined
|
||||
if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path)
|
||||
if (start === undefined || items.length === 0) return undefined
|
||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
||||
const lines: string[] = []
|
||||
let fenceColons = 0
|
||||
for (const [offset, item] of items.entries()) {
|
||||
const itemPath = [...path, 'content', offset]
|
||||
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath)
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath, depth)
|
||||
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth)
|
||||
if (emitted === undefined) return undefined
|
||||
if (!emitted.ok) return emitted
|
||||
fenceColons = Math.max(fenceColons, emitted.value.fenceColons)
|
||||
lines.push(emitted.value.text)
|
||||
@@ -236,33 +234,25 @@ function listStart(node: AdfNode, items: number): number | undefined {
|
||||
return start + items - 1 > largestListMarker ? undefined : start
|
||||
}
|
||||
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
|
||||
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
|
||||
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
|
||||
if (!inner.ok) return inner
|
||||
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
|
||||
const indent = ' '.repeat(marker.length)
|
||||
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
||||
const first = lines[0] ?? ''
|
||||
if (isThematicBreak(first)) {
|
||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
|
||||
}
|
||||
if (isThematicBreak(lines[0] ?? '')) return undefined
|
||||
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
const content = node.content ?? []
|
||||
if (content.length === 0 || !commonMarkHead(node, [])) return undefined
|
||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
||||
const line = emitInlineLine(content, 'paragraph', path)
|
||||
if (!line.ok) return line
|
||||
return success(commonMarkText(line.value))
|
||||
}
|
||||
|
||||
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
|
||||
if (!commonMarkHead(node, []) || (node.content ?? []).length > 0) return undefined
|
||||
if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined
|
||||
return success(commonMarkText('---'))
|
||||
}
|
||||
|
||||
function commonMarkHead(node: AdfNode, spelled: readonly string[]): boolean {
|
||||
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
|
||||
return Object.keys(node.attrs ?? {}).every((key) => spelled.includes(key))
|
||||
}
|
||||
|
||||
@@ -93,7 +93,8 @@ export function blockDirective(type: string): BlockDirective | undefined {
|
||||
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
|
||||
const argument = spellArgument(node, directive)
|
||||
if (argument === undefined) return undefined
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.argument, ...spelledByBody])
|
||||
const spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody]
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled)
|
||||
if (pairs === undefined) return undefined
|
||||
const marks = node.marks ?? []
|
||||
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
|
||||
|
||||
@@ -11,11 +11,7 @@ export function isBareToken(text: string): boolean {
|
||||
return bareToken.test(text)
|
||||
}
|
||||
|
||||
export function vocabularyPairs(
|
||||
attrs: AdfAttributes,
|
||||
vocabulary: AttributeVocabulary,
|
||||
spelledElsewhere: readonly (string | undefined)[],
|
||||
): [string, string][] | undefined {
|
||||
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): [string, string][] | undefined {
|
||||
const pairs: [string, string][] = []
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (spelledElsewhere.includes(key)) continue
|
||||
|
||||
@@ -53,7 +53,7 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
||||
}
|
||||
|
||||
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.slot])
|
||||
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot === undefined ? [] : [directive.slot])
|
||||
return pairs === undefined ? undefined : spellAttributes(pairs)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdfNode } from './adf-document.ts'
|
||||
import { carriesOnly } from './adf-document.ts'
|
||||
import type { ConvertErrorPath } from './result.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
import { tryImageLine } from './markdown-inline.ts'
|
||||
@@ -15,12 +16,12 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
const content = node.content ?? []
|
||||
const media = content[0]
|
||||
if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined
|
||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) return undefined
|
||||
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
|
||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined
|
||||
const attrs = media.attrs ?? {}
|
||||
const alt = attrs['alt']
|
||||
const url = attrs['url']
|
||||
if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external' || typeof url !== 'string') return undefined
|
||||
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
|
||||
if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined
|
||||
return { alt, url }
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
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)
|
||||
const directive = inlineDirective(node.type)
|
||||
if (directive === undefined) return emitText(node, context, path)
|
||||
if (directive === undefined) return emitText(node, context, index, path)
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
|
||||
return emitInlineDirective(node, directive, index, path)
|
||||
}
|
||||
@@ -215,9 +215,8 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
|
||||
return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] })
|
||||
}
|
||||
|
||||
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
|
||||
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)
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdfNode } from './adf-document.ts'
|
||||
import { carriesOnly } from './adf-document.ts'
|
||||
import { tryPipeCell } from './markdown-inline.ts'
|
||||
import type { ConvertErrorPath } from './result.ts'
|
||||
|
||||
@@ -23,16 +24,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||
const rows = node.content ?? []
|
||||
const columns = (rows[0]?.content ?? []).length
|
||||
if (!isPlain(node) || columns === 0) return undefined
|
||||
if (!carriesOnly(node, []) || columns === 0) return undefined
|
||||
const grid: AdfNode[][] = []
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const cells = row.content ?? []
|
||||
if (row.type !== 'tableRow' || !isPlain(row) || cells.length !== columns) return undefined
|
||||
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
|
||||
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
||||
const paragraphs: AdfNode[] = []
|
||||
for (const cell of cells) {
|
||||
const paragraph = plainParagraph(cell)
|
||||
if (paragraph === undefined || cell.type !== wanted || !isPlain(cell)) return undefined
|
||||
if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined
|
||||
paragraphs.push(paragraph)
|
||||
}
|
||||
grid.push(paragraphs)
|
||||
@@ -40,13 +41,9 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||
return grid
|
||||
}
|
||||
|
||||
function isPlain(node: AdfNode): boolean {
|
||||
return Object.keys(node.attrs ?? {}).length === 0 && (node.marks ?? []).length === 0 && node.text === undefined
|
||||
}
|
||||
|
||||
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
||||
const content = cell.content ?? []
|
||||
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' || !carriesOnly(paragraph, [])) return undefined
|
||||
return paragraph
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type ConvertErrorCode =
|
||||
| 'unspellable-link-title'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unspelled-block-separation'
|
||||
| 'unspelled-node-attribute'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-node-shape'
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ detail is settled at its own milestone.
|
||||
the inline carry. `commonmark-subset/` green.
|
||||
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
|
||||
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
|
||||
separation turns that refusal into an emission. Block separation becomes
|
||||
separation turns that refusal into an emission. The test is broader than the name it
|
||||
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
|
||||
paragraph continues, a code block say, is refused too — and the same answer narrows it. Block separation becomes
|
||||
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case
|
||||
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
|
||||
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
|
||||
|
||||
Reference in New Issue
Block a user