Emitter 2d: the opaque carry in both positions and the reserved info string #15
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`&` land in the same refusal.
|
||||
|
||||
Reference in New Issue
Block a user