Keep the reserved info string beside the fence it spells, and one fenced-block spelling for both
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-26 15:57:45 +02:00
parent 6f674b99b3
commit 540a0fbdcc
6 changed files with 22 additions and 15 deletions
+5 -3
View File
@@ -27,7 +27,9 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
## 3. Unknown input policy
- 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. It is
the node *type* that is unknown: a known type standing where its content model forbids stays an
error result, never a carry.
- 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
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
@@ -116,8 +118,8 @@ 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 —
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
are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks, attribute values and
a carried node's JSON are all held to 500 levels, so a deep document is a `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
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
+2 -2
View File
@@ -3,8 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect.
**Status: pre-release — `adfToMarkdown` emits every node the flavour spells, nothing else is
built.**
**Status: pre-release — `adfToMarkdown` emits every node the flavour spells but the carry's
attribute-level fallback, nothing else is built.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md).
+4 -5
View File
@@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from './result.t
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.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 BlockSpelling = 'commonmark' | 'directive'
@@ -81,7 +81,7 @@ function interruptsParagraph(node: AdfNode): boolean {
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.type === 'blockquote') return commonMarkContainer(emitBlockquote(node, path, depth))
if (node.type === 'bulletList' || node.type === 'orderedList') return commonMarkContainer(emitList(node, path, depth))
if (node.type === 'codeBlock') return commonMarkLine(node.attrs?.['language'] === carryName ? carriedBlock(node, path) : emitCodeBlock(node, path))
if (node.type === 'codeBlock') return commonMarkLine(emitCodeBlock(node, path))
if (node.type === 'heading') return commonMarkLine(emitHeading(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path)
if (node.type === 'rule') return commonMarkLine(emitRule(node, path))
@@ -160,6 +160,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
}
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
if (node.attrs?.['language'] === carryName) return carriedBlock(node, path)
const validation = validateBlockNode(node, ['language'], path)
if (!validation.ok) return validation
const info = spellCodeFenceInfo(node.attrs?.['language'], path)
@@ -174,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)
text += child.text
}
const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1))
const opening = `${fence}${info.value}`
return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`)
return success(fencedCodeBlock(info.value, text))
}
function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> {
+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 {
let longest = 0
let current = 0
+3 -4
View File
@@ -2,8 +2,8 @@ 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 { longestBacktickRun } from './backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
@@ -12,8 +12,7 @@ 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
const fence = '`'.repeat(Math.max(3, longestBacktickRun(json.value) + 1))
return success(`${fence}${carryName}\n${json.value}\n${fence}`)
return success(fencedCodeBlock(carryName, json.value))
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
@@ -24,7 +23,7 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
if (!isJsonValue(node)) {
return failure('unsupported-node-shape', `a carried node nests deeper than the ${largestNesting} levels the emitter carries`, path)
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))
}
+3 -1
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
`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
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
follows whether CommonMark can spell its URL, two sibling images differing only by an
`&amp;` land in the same refusal.