3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer principles round 1: 0 🔴, 7 🟡; mint task ids within the engine limits, fold == into inline-tokens.ts, keep one frame per quote level, pass the list's path once

This commit is contained in:
2026-10-22 21:11:12 +02:00
committed by Lillem4n
parent 8f28d7bcc2
commit e1f37cde43
13 changed files with 88 additions and 77 deletions
+1 -1
View File
@@ -5,5 +5,5 @@ export type { JsonValue } from './json-value.ts'
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
export { isAdfDocument } from './adf/document.ts'
export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
@@ -203,6 +203,7 @@ test('alternates the marker between adjacent lists of a kind, the marker change
const ordered: AdfNode = { attrs: { order: 9 }, content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [paragraph({ text: 'y', type: 'text' }), paragraph({ text: 'z', type: 'text' })], type: 'listItem' }], type: 'orderedList' }
assert.equal(markdown(adfToLosslessMarkdown(document(ordered, ordered))), '9. x\n10. y\n\n z\n\n9) x\n10) y\n\n z\n')
assert.equal(markdown(adfToLosslessMarkdown(document(list, ordered, list))), '- x\n\n9. x\n10. y\n\n z\n\n- x\n')
assert.equal(markdown(adfToLosslessMarkdown(document(list, paragraph({ text: 'y', type: 'text' }), list))), '- x\n\ny\n\n- x\n')
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), '!adf:panel info\n- x\n\n* x\n!adf:/panel\n')
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
assert.equal(markdown(adfToLosslessMarkdown(document(nested))), '- - x\n * x\n')
+6 -6
View File
@@ -141,7 +141,7 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const own = writing.flavour === 'portable' ? writing.blocks.spell(node, path, depth, writing) : undefined
if (own !== undefined) return own
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
if (node.type === 'blockquote') return holdsOnlyAttributes(node, []) ? quotedBlocks(node, path, depth, writing, (body) => alertEscaped(body, writing)) : undefined
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node, writing.flavour))
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
@@ -198,15 +198,15 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
}
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!holdsOnlyAttributes(node, [])) return undefined
return quotedBlocks(node, path, depth, writing, (body) => (writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body))
function alertEscaped(body: string, writing: Writing): string {
return writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body
}
export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, text: (body: string) => string): Result<EmittedBlock> {
// Each quote speller calls it directly, not through a wrapper, so a quote level spends one frame between walks (docs/decisions.md §Nothing recurses unbounded).
export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, bodyText: (body: string) => string): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
return success(commonMarkText(quoted(text(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom))
return success(commonMarkText(quoted(bodyText(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom))
}
function tryCodeBlock(node: AdfNode, flavour: WrittenFlavour): string | undefined {
+1 -1
View File
@@ -9,7 +9,7 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter } from '../highlight-syntax.ts'
import { highlightDelimiter } from '../inline-tokens.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
+1 -2
View File
@@ -2,10 +2,9 @@ import type { Claims, WrittenFlavour } from '../flavour.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { delimiterRunToken, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { delimiterRunToken, highlightFlanking, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightFlanking } from '../highlight-syntax.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
+1 -1
View File
@@ -2,7 +2,7 @@ export type Flavour = 'commonmark' | 'lossless' | 'portable'
export type WrittenFlavour = Exclude<Flavour, 'commonmark'>
// What a reader reads past CommonMark, and so what its writer escapes (docs/decisions.md §Each call names its flavour).
// What a reader reads past CommonMark, and so what its writer escapes, but for the portable callouts and task lists that `FlavourBlockReaders` and `FlavourBlockWriters` carry (docs/decisions.md §Each call names its flavour).
export type Claims = {
carryFence: boolean
directives: boolean
-22
View File
@@ -1,22 +0,0 @@
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
export const highlightDelimiter = '=='
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
const boundingScript = /^[゙゚ーー゙゚\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
const end = index + highlightDelimiter.length
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
}
function bounds(outside: string, inside: string): boolean {
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
}
function flanks(character: string): boolean {
return character !== '' && !/\s/.test(character)
}
+22 -2
View File
@@ -4,8 +4,7 @@ import type { CodeSpanCloser } from './commonmark/backtick-runs.ts'
import { backslashEscape, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from './commonmark/grammar.ts'
import { backtickCloser, backtickRun } from './commonmark/backtick-runs.ts'
import { claimDirectivePrefix } from './directive/syntax.ts'
import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from './highlight-syntax.ts'
import { delimiterFlags, isWordCharacter, runLength } from './commonmark/emphasis-matching.ts'
import { readEntityReference } from './commonmark/entity-references.ts'
// `directive` and `html` carry no `width`: a directive reads by its own grammar, and the reader refuses HTML.
@@ -27,6 +26,11 @@ export type InlineSource = {
readonly text: string
}
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
export const highlightDelimiter = '=='
export function inlineSource(text: string): InlineSource {
return { codeSpanCloser: backtickCloser(text), terminators: new Map(), text }
}
@@ -102,3 +106,19 @@ function lastStart(inline: InlineSource, terminator: string): number {
function text(width: number): InlineToken {
return { kind: 'text', width }
}
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
const end = index + highlightDelimiter.length
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
}
function bounds(outside: string, inside: string): boolean {
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
}
function flanks(character: string): boolean {
return character !== '' && !/\s/.test(character)
}
+1 -2
View File
@@ -10,10 +10,9 @@ import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts'
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter } from '../highlight-syntax.ts'
import { highlightDelimiter, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { inlineSource, readInlineToken } from '../inline-tokens.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
+3 -3
View File
@@ -19,9 +19,9 @@ import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.
import { spellsEmpty } from '../directive/empty-keys.ts'
import { withinEngineLimits } from '../length-bound.ts'
// A flavour's own reading of a quote and of a bullet list, which may read to several blocks.
// A flavour's own reading of a quote and of a bullet list, which may read to several blocks; `path` is the container's, `index` the list's among its blocks.
export type FlavourBlockReaders = {
bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, listIndex: number) => Result<AdfNode[]>
bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, index: number) => Result<AdfNode[]>
quote: (blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number) => Result<AdfNode>
}
@@ -77,7 +77,7 @@ export function readBlocks(blocks: readonly Block[], reading: Reading, path: Con
return positioned(faulted(fault, nodePath), block.position)
}
if (block.kind === 'bulletList' && reading.flavour === 'portable') {
const listed = positioned(reading.blocks.bulletList(block.items, reading, nodePath, depth, content.length), block.position)
const listed = positioned(reading.blocks.bulletList(block.items, reading, path, depth, content.length), block.position)
if (!listed.ok) return listed
for (const node of listed.value) content.push(node)
continue
+5 -5
View File
@@ -20,17 +20,17 @@ export const portableWriting: WrittenAs = { blocks: portableBlockWriters, flavou
// The portable flavour's nodes, in the shapes the portable reduction leaves them.
function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
if (node.type === 'panel') return quotedBlocks(node, path, depth, writing, headed(alertMarker(nodeAttrs(node)['panelType'])))
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
const title = nodeAttrs(node)['title']
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
if (typeof title !== 'string') return quotedBlocks(node, path, depth, writing, headed(foldedAlertMarker))
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, writing.flavour)
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
return line.ok ? quotedBlocks(node, path, depth, writing, headed(`${foldedAlertMarker} ${line.value}`)) : line
}
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
return quotedBlocks(node, path, depth, writing, (body) => (body === '' ? head : `${head}\n\n${body}`))
function headed(head: string): (body: string) => string {
return (body) => (body === '' ? head : `${head}\n\n${body}`)
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, FlavourReading, Reading } from '../parse/markdown-to-adf.ts'
import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
@@ -9,24 +9,25 @@ import { mintTaskIds } from './task-ids.ts'
import { parseInlineContent } from '../parse/inline-content.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { positioned, success } from '../../result.ts'
import { withinEngineLimits } from '../length-bound.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }>
// The paragraph a task marker opens: a task item holds no image, so a lone one there reads as its linked alt text.
type TaskParagraph = { kind: 'taskParagraph'; position: SourcePosition; text: string }
// inExpand: whether an expand holds the blocks, which makes a folded callout a nestedExpand.
function blockReaders(inExpand: boolean): FlavourBlockReaders {
return { bulletList: bulletBlocks, quote: (blocks, reading, path, depth) => quoteNode(blocks, reading, path, depth, inExpand) }
}
const expandBlockReaders = blockReaders(true)
const portableBlockReaders = blockReaders(false)
// Inside an expand, a folded callout reads as a nestedExpand.
const expandBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteReader('nestedExpand') }
const portableBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteReader('expand') }
export function portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const read = readDocument(markdown, { blocks: portableBlockReaders, flavour: 'portable' })
if (read.ok) mintTaskIds(read.value, markdown)
return read
if (!read.ok) return read
const minted = withinEngineLimits(() => {
mintTaskIds(read.value, markdown)
return read
})
return positioned(minted, { line: 1, offset: 0 })
}
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
@@ -47,18 +48,20 @@ function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[]
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number, inExpand: boolean): Result<AdfNode> {
const [first, ...body] = blocks
const led = markerLed(first, readAlertMarker)
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
if (!title.ok) return title
const text = titleText(title.value.nodes)
const type = inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1))
// The closure is the reader rather than a wrapper around one, so a quote level spends one stack frame (docs/decisions.md §Nothing recurses unbounded).
function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders['quote'] {
return (blocks, reading, path, depth) => {
const [first, ...body] = blocks
const led = markerLed(first, readAlertMarker)
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
if (!title.ok) return title
const text = titleText(title.value.nodes)
return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1))
}
}
// docs/decisions.md, A callout title keeps its link targets.
@@ -97,10 +100,11 @@ function taskItems(items: readonly Block[][]) {
return led
}
function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number): Result<AdfNode[]> {
const tasks = taskBlocks(items, reading, path, depth)
function bulletBlocks(items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, index: number): Result<AdfNode[]> {
const listPath = [...path, 'content', index]
const tasks = taskBlocks(items, reading, listPath, depth)
if (tasks === undefined) {
const list = listNode({ type: 'bulletList' }, items, reading, path, depth)
const list = listNode({ type: 'bulletList' }, items, reading, listPath, depth)
return list.ok ? success([list.value]) : list
}
if (!tasks.ok) return tasks
@@ -110,7 +114,7 @@ function bulletBlocks(items: readonly Block[][], reading: Reading, path: Convert
// task-list.test.ts pins that the split keeps the marker; were it lost, the error keeps its pre-split path, since nothing here throws.
if (place === undefined) return { error: refusal.error, ok: false }
const [blockIndex, ...steps] = place
return { error: { ...refusal.error, path: [...path.slice(0, -1), listIndex + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false }
return { error: { ...refusal.error, path: [...path, 'content', index + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false }
}
// A block a task list refused. Its placeholder holds `marker` as its one child and stands where the block's node would, so the one split that builds the list places it; `within` is the error's path below that node.
@@ -119,7 +123,7 @@ type Refusal = { error: ConvertError; marker: AdfNode; within: ConvertErrorPath
// Nodes read from task blocks and the first refusal among them; a list is read up to the task holding its refusal.
type TaskNodes = { nodes: AdfNode[]; refusal: Refusal | undefined }
function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<TaskNodes> | undefined {
function taskBlocks(items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number): Result<TaskNodes> | undefined {
const led = taskItems(items)
if (led === undefined) return undefined
if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0])
@@ -141,18 +145,19 @@ function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertEr
}
// A refused block reads to a placeholder. The split tells only paragraphs and task lists from other blocks, so a paragraph that failed to read stays a paragraph.
function readTaskBlock(block: Block | TaskParagraph, reading: Reading, path: ConvertErrorPath, depth: number): TaskNodes {
function readTaskBlock(block: Block | TaskParagraph, reading: FlavourReading, path: ConvertErrorPath, depth: number): TaskNodes {
const nodePath = [...path, 'content', 0]
const nested = block.kind === 'bulletList' ? taskBlocks(block.items, reading, nodePath, depth) : undefined
if (nested?.ok) return nested.value
const read = positioned(nested ?? (block.kind === 'taskParagraph' ? taskParagraph(block, reading, nodePath) : readBlocks([block], reading, path, depth)), block.position)
const read = positioned(nested ?? readTaskChild(block, reading, path, depth), block.position)
if (read.ok) return { nodes: read.value, refusal: undefined }
const marker: AdfNode = { text: '', type: 'text' }
const type = block.kind === 'taskParagraph' ? 'paragraph' : nested === undefined ? block.kind : 'taskList'
return { nodes: [{ content: [marker], type }], refusal: { error: read.error, marker, within: read.error.path.slice(nodePath.length) } }
}
function taskParagraph(paragraph: TaskParagraph, reading: Reading, path: ConvertErrorPath): Result<AdfNode[]> {
const node = contentNode({ type: 'paragraph' }, paragraph.text, reading, path, 'paragraph')
function readTaskChild(block: Block | TaskParagraph, reading: FlavourReading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (block.kind !== 'taskParagraph') return readBlocks([block], reading, path, depth)
const node = contentNode({ type: 'paragraph' }, block.text, reading, [...path, 'content', 0], 'paragraph')
return node.ok ? success([node.value]) : node
}
+10 -1
View File
@@ -6,7 +6,7 @@
`Bar = 0.1`
`Next ID = 128`
`Next ID = 129`
## Items
@@ -21,6 +21,7 @@
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
| 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
| 73 | 0.3.0 | 33, 91, 95, 109, 115, 122, 123, 125 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
| 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 |
@@ -139,6 +140,14 @@ its versions and its date in step with the output. We draft each post for the pl
maintainer posts them once `0.3.0` is published (`docs/decisions.md` §Posts announcing a release
wait for its publish). Found while planning discoverability, 2026-10-04.
### 128. Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.
Mutation testing of item 62's move left about 40 mutants alive in code it moved verbatim, so the
gaps predate it: `tryTaskList` and `taskBlocks` in `src/markdown/portable/block-spelling.ts`, and
`markerLine`, `quoteReader`, `titleText`, `taskBlocks` and `readTaskBlock` in
`src/markdown/portable/portable-markdown-to-adf.ts`. Each test takes its expected value from
`spec/flavour.md` or `docs/decisions.md`, never from the code.
### 75. Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.
`blockCarry` and `hostileBlockCarry` (`src/conformance/markdown-property.test.ts`) write a fence