10b - plainMarkdownToAdf's ADF→ADF lift #129

Merged
lilleman merged 12 commits from 10b into main 2026-09-25 21:12:00 +02:00
8 changed files with 457 additions and 27 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
} }
function sameMarks(previous: AdfNode, node: AdfNode): boolean { export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node)) return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
} }
+1 -1
View File
@@ -2,6 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
@@ -9,7 +10,6 @@ import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor' const highlight = 'backgroundColor'
const highlightDelimiter = '=='
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
+4 -17
View File
@@ -1,5 +1,6 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, taskMarker } from '../plain-conventions.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts' import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -17,15 +18,6 @@ type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode } type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
const alertWords: Readonly<Record<string, string>> = {
error: 'CAUTION',
info: 'NOTE',
note: 'IMPORTANT',
success: 'TIP',
tip: 'TIP',
warning: 'WARNING',
}
const blockReducers: Readonly<Record<string, BlockReducer>> = { const blockReducers: Readonly<Record<string, BlockReducer>> = {
blockCard: paragraphOfNode, blockCard: paragraphOfNode,
blockquote: (node, reduction) => quoted(success([]), node, reduction), blockquote: (node, reduction) => quoted(success([]), node, reduction),
@@ -41,7 +33,7 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))), mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
nestedExpand: reduceExpand, nestedExpand: reduceExpand,
orderedList: reduceList, orderedList: reduceList,
panel: (node, reduction) => quoted(success([paragraph([text(`[!${alertWord(nodeAttrs(node)['panelType'])}]`)])]), node, reduction), panel: (node, reduction) => quoted(success([paragraph([text(alertMarker(nodeAttrs(node)['panelType']))])]), node, reduction),
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction), paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
rule: () => success([{ type: 'rule' }]), rule: () => success([{ type: 'rule' }]),
syncBlock: paragraphOfNode, syncBlock: paragraphOfNode,
@@ -190,14 +182,9 @@ function quoted(head: Result<AdfNode[]>, node: AdfNode, reduction: Reduction): R
return content.ok ? success([{ content: content.value, type: 'blockquote' }]) : content return content.ok ? success([{ content: content.value, type: 'blockquote' }]) : content
} }
function alertWord(panelType: unknown): string {
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
return word ?? 'NOTE'
}
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const title = nodeAttrs(node)['title'] const title = nodeAttrs(node)['title']
const marker = typeof title === 'string' ? `[!NOTE]- ${title.replace(/^[ \t\n\r]+/, '')}` : '[!NOTE]-' const marker = typeof title === 'string' ? `${foldedAlertMarker} ${title.replace(/^[ \t\n\r]+/, '')}` : foldedAlertMarker
return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction) return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction)
} }
@@ -257,7 +244,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
} }
function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> { function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
const marker = nodeAttrs(child)['state'] === 'DONE' ? '[x]' : '[ ]' const marker = taskMarker(nodeAttrs(child)['state'])
if (child.type === 'taskItem') { if (child.type === 'taskItem') {
const content = reduceInline(nodeContent(child), 'paragraph', at.path, at.depth) const content = reduceInline(nodeContent(child), 'paragraph', at.path, at.depth)
return content.ok ? success([paragraph(content.value.length === 0 ? [text(marker)] : [text(`${marker} `), ...content.value])]) : content return content.ok ? success([paragraph(content.value.length === 0 ? [text(marker)] : [text(`${marker} `), ...content.value])]) : content
+201
View File
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
import { largestNesting } from '../../nesting.ts'
import { liftFromPlain } from './plain-lift.ts'
import { markdownToAdf } from './markdown-to-adf.ts'
import { reduceToPlain } from '../emit/plain-reduction.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' }
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const strong: AdfMark = { type: 'strong' }
function lifted(markdown: string): readonly AdfNode[] | string {
const parsed = markdownToAdf(markdown)
return parsed.ok ? (toEditorNormal(liftFromPlain(parsed.value)).content ?? []) : parsed.error.code
}
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
return toEditorNormal(document(...blocks)).content ?? []
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
// ADF the reduction wrote, spelled and read back through the lift.
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
const reduced = reduceToPlain(document(...content))
const markdown = reduced.ok ? adfToMarkdown(reduced.value) : reduced
return markdown.ok ? lifted(markdown.value) : markdown.error.code
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
return { attrs, content, type }
}
function bare(type: string, ...content: AdfNode[]): AdfNode {
return { content, type }
}
function paragraph(...content: AdfNode[]): AdfNode {
return bare('paragraph', ...content)
}
function said(value: string): AdfNode {
return paragraph(text(value))
}
function panel(panelType: string, ...content: AdfNode[]): AdfNode {
return node('panel', { panelType }, ...content)
}
function task(state: string, ...content: AdfNode[]): AdfNode {
return node('taskItem', { state }, ...content)
}
test('lifts an alert to a panel by its GitHub word, in any case', () => {
const alert = (word: string): readonly AdfNode[] | string => lifted(`> [!${word}]\n>\n> Check it.\n`)
assert.deepEqual(alert('NOTE'), [panel('info', said('Check it.'))])
assert.deepEqual(alert('IMPORTANT'), [panel('note', said('Check it.'))])
assert.deepEqual(alert('TIP'), [panel('tip', said('Check it.'))])
assert.deepEqual(alert('WARNING'), [panel('warning', said('Check it.'))])
assert.deepEqual(alert('CAUTION'), [panel('error', said('Check it.'))])
assert.deepEqual(alert('Warning'), [panel('warning', said('Check it.'))])
assert.deepEqual(alert('caution'), [panel('error', said('Check it.'))])
})
test('lifts an Obsidian callout to a panel by what its word means, any other word info', () => {
const alert = (word: string): unknown => {
const blocks = lifted(`> [!${word}]\n> Body.\n`)
return typeof blocks === 'string' ? blocks : blocks[0]?.attrs
}
assert.deepEqual(alert('hint'), { panelType: 'tip' })
for (const word of ['success', 'check', 'Done']) assert.deepEqual(alert(word), { panelType: 'success' }, word)
assert.deepEqual(alert('attention'), { panelType: 'warning' })
for (const word of ['danger', 'error', 'failure', 'fail', 'missing', 'BUG']) assert.deepEqual(alert(word), { panelType: 'error' }, word)
for (const word of ['info', 'note', 'question', 'my-type']) assert.deepEqual(alert(word), { panelType: 'info' }, word)
})
test('reads text after an alert marker in its paragraph as the panel first body paragraph', () => {
assert.deepEqual(lifted('> [!NOTE]\n> Line **one**.\n>\n> Two.\n'), [panel('info', paragraph(text('Line '), text('one', strong), text('.')), said('Two.'))])
assert.deepEqual(lifted('> [!tip] Title\n'), [panel('tip', said('Title'))])
assert.deepEqual(lifted('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(lifted('> [!NOTE]\n'), normal(panel('info', paragraph())))
assert.deepEqual(lifted('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
})
test('leaves a quote plain where its first line is no alert marker', () => {
for (const markdown of ['> [!NOTE]x\n', '> **[!NOTE]**\n', '> See [!NOTE]\n', '> [!NOTE]**x**\n', '> [!]\n', '> ```\n> [!NOTE]\n> ```\n']) {
const blocks = lifted(markdown)
assert.equal(typeof blocks !== 'string' && blocks[0]?.type, 'blockquote', markdown)
}
})
test('lifts a folded callout to an expand titled by the rest of its marker paragraph, whatever the word', () => {
assert.deepEqual(lifted('> [!NOTE]- Build log\n>\n> Line.\n'), [node('expand', { title: 'Build log' }, said('Line.'))])
assert.deepEqual(lifted('> [!bug]+ Open **by** default\n> still title\n>\n> Line.\n'), [node('expand', { title: 'Open by default still title' }, said('Line.'))])
assert.deepEqual(lifted('> [!NOTE]- Two\\\n> lines\n'), normal(node('expand', { title: 'Two\nlines' }, paragraph())))
assert.deepEqual(lifted('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
test('lifts a folded callout inside an expand to a nested expand', () => {
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(lifted(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(lifted('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
})
test('lifts a bullet list whose every item leads with a task marker to a task list', () => {
assert.deepEqual(lifted('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
])
assert.deepEqual(lifted('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after')))))
})
test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => {
assert.deepEqual(lifted('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))])
assert.deepEqual(lifted('- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n'), [
bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), node('blockTaskItem', { state: 'TODO' }, bare('codeBlock', text('x')))),
])
assert.deepEqual(lifted('- [x] A\n - plain\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('A'), bare('bulletList', bare('listItem', said('plain')))))])
})
test('leaves mixed, ordered and unmarked lists plain', () => {
for (const markdown of ['- [x] a\n- b\n', '1. [x] a\n', '- [x]a\n', '- **[x]** a\n', '- [x]**a**\n', '- [-] a\n', '- > [x] a\n']) {
const blocks = lifted(markdown)
assert.notEqual(typeof blocks !== 'string' && blocks[0]?.type, 'taskList', markdown)
assert.equal(JSON.stringify(blocks).includes('taskItem'), false, markdown)
}
assert.deepEqual(lifted('- plain\n - [ ] nested\n'), [bare('bulletList', bare('listItem', said('plain'), bare('taskList', task('TODO', text('nested')))))])
})
test('lifts a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atlaskit/adf-schema 57.6.8', () => {
assert.deepEqual(lifted('a ==hi there== b\n'), [paragraph(text('a '), text('hi there', highlight), text(' b'))])
assert.deepEqual(lifted('**==hi==** b\n'), [paragraph(text('hi', highlight, strong), text(' b'))])
assert.deepEqual(lifted('==**a**_b_ `c`==\n'), [paragraph(text('a', highlight, strong), text('b', highlight, em), text(' ', highlight), text('c', code))])
assert.deepEqual(lifted('==`a`==\n'), [paragraph(text('a', code))])
assert.deepEqual(lifted('x==y==z ==a == b==, (==c==) _d_==e==\n'), [paragraph(text('x==y==z '), text('a == b', highlight), text(', ('), text('c', highlight), text(') '), text('d', em), text('e', highlight))])
assert.deepEqual(lifted('😀==b== ==c==😀 é==d==\n'), [paragraph(text('😀'), text('b', highlight), text(' '), text('c', highlight), text('😀 é==d=='))])
assert.deepEqual(lifted('# ==h==\n\n| ==c== |\n| --- |\n'), [
node('heading', { level: 1 }, text('h', highlight)),
bare('table', bare('tableRow', bare('tableHeader', paragraph(text('c', highlight))))),
])
assert.deepEqual(lifted('> [!NOTE]\n> ==x==\n'), [panel('info', paragraph(text('x', highlight)))])
assert.deepEqual(lifted('==a==\\\n==b==\n'), [paragraph(text('a', highlight), { type: 'hardBreak' }, text('b', highlight))])
})
test('leaves a == no pair flanks as text', () => {
for (const markdown of ['a == b == c\n', 'if a==b and c==d then\n', 'a==b== c\n', '==a==b\n', '====\n', '`==x==`\n', '==a\\\nb==\n', '**==a**==\n', '==a', '== a==\n', '==a ==\n']) {
assert.equal(JSON.stringify(lifted(markdown)).includes('backgroundColor'), false, markdown)
}
})
test('lifts what the reduction wrote back to the node it reduced, less the attributes it drops', () => {
const localId = '01a0d99b-1f59-7e2c-a3d4-62c1f0b8e7a1'
for (const panelType of ['info', 'note', 'tip', 'warning', 'error']) {
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
}
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare(
'taskList',
node('taskItem', { localId, state: 'DONE' }, text('Write')),
bare('taskList', task('TODO', text('Review'))),
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
)
const plainTasks = bare(
'taskList',
task('DONE', text('Write')),
bare('taskList', task('TODO', text('Review'))),
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
)
assert.deepEqual(roundTripped(tasks), [plainTasks])
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
})
test('keeps what markdownToAdf reads that no row lifts, and refuses only what it refuses', () => {
assert.deepEqual(lifted('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))])
const future = bare('futureBlock', text('==x=='))
const carried = adfToMarkdown(document(future))
assert.deepEqual(carried.ok ? lifted(carried.value) : carried.error.code, [future])
const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' }
const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
const spelled = adfToMarkdown(document(held))
assert.deepEqual(spelled.ok ? lifted(spelled.value) : spelled.error.code, [paragraph(text('a ==b== c', red), text(' '), text('d ', highlight), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g', highlight))])
assert.equal(lifted('!adf:panel\n'), 'malformed-directive')
let deep = 'x\n'
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof lifted(deep), 'object')
})
+175
View File
@@ -0,0 +1,175 @@
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
type Delimiter = { closes: boolean; holder: AdfNode; line: number; node: number; offset: number; opens: boolean; position: number }
type MarkerLed<T> = { marker: T; rest: AdfNode[] }
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
export function liftFromPlain(document: AdfDocument): AdfDocument {
return { ...document, content: liftBlocks(nodeContent(document), false) }
}
function liftBlocks(blocks: readonly AdfNode[], inExpand: boolean): AdfNode[] {
return blocks.map((block) => liftBlock(block, inExpand))
}
function liftBlock(block: AdfNode, inExpand: boolean): AdfNode {
const model = blockNodeModel(block.type)?.contentModel
if (model === 'inline') return { ...block, content: highlighted(nodeContent(block)) }
if (model !== 'block') return block
if (block.type === 'blockquote') return liftQuote(block, inExpand)
const tasks = block.type === 'bulletList' ? taskItems(nodeContent(block), inExpand) : undefined
if (tasks !== undefined) return { content: tasks, type: 'taskList' }
return { ...block, content: liftBlocks(nodeContent(block), inExpand || block.type === 'expand' || block.type === 'nestedExpand') }
}
function paragraphOf(content: readonly AdfNode[]): AdfNode[] {
return content.length === 0 ? [] : [{ content: [...content], type: 'paragraph' }]
}
// The marker opens the block's unmarked text and whitespace or the line's end follows it.
function markerLed<T extends { length: number }>(block: AdfNode | undefined, read: (text: string) => T | undefined): MarkerLed<T> | undefined {
if (block?.type !== 'paragraph') return undefined
const [first, ...others] = nodeContent(block)
if (first?.type !== 'text' || first.text === undefined || nodeMarks(first).length > 0) return undefined
const marker = read(first.text)
if (marker === undefined) return undefined
const tail = first.text.slice(marker.length)
const lineEnds = tail === '' && (others[0] === undefined || others[0].type === 'hardBreak')
if (!lineEnds && !/^[ \t]/.test(tail)) return undefined
const trimmed = tail.replace(/^[ \t]+/, '')
const rest = trimmed === '' ? others : [{ text: trimmed, type: 'text' }, ...others]
let from = 0
while (rest[from]?.type === 'hardBreak') from += 1
return { marker, rest: rest.slice(from) }
}
function liftQuote(quote: AdfNode, inExpand: boolean): AdfNode {
const [first, ...body] = nodeContent(quote)
const led = markerLed(first, readAlertMarker)
if (led === undefined) return { ...quote, content: liftBlocks(nodeContent(quote), inExpand) }
if (!led.marker.folded) return { attrs: { panelType: led.marker.panelType }, content: filled(liftBlocks([...paragraphOf(led.rest), ...body], inExpand)), type: 'panel' }
const title = led.rest.map((node) => node.text ?? (node.type === 'hardBreak' ? '\n' : '')).join('')
const content = filled(liftBlocks(body, true))
const type = inExpand ? 'nestedExpand' : 'expand'
return title === '' ? { content, type } : { attrs: { title }, content, type }
}
// Atlassian's schema requires a panel and an expand to hold a block.
function filled(blocks: AdfNode[]): AdfNode[] {
return blocks.length === 0 ? [{ type: 'paragraph' }] : blocks
}
// A task list trailing an item's blocks stands beside it, as ADF nests one.
function taskItems(items: readonly AdfNode[], inExpand: boolean): AdfNode[] | undefined {
if (items.length === 0) return undefined
const tasks: AdfNode[] = []
for (const item of items) {
const led = item.type === 'listItem' ? markerLed(nodeContent(item)[0], readTaskMarker) : undefined
if (led === undefined) return undefined
const blocks = liftBlocks([...paragraphOf(led.rest), ...nodeContent(item).slice(1)], inExpand)
let beside = blocks.length
while (blocks[beside - 1]?.type === 'taskList') beside -= 1
const kept = blocks.slice(0, beside)
const [only] = kept
const attrs = { state: led.marker.state }
const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph')
tasks.push(inline ? { attrs, content: [...nodeContent(only ?? {})], type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' })
for (const nested of blocks.slice(beside)) tasks.push(nested)
}
return tasks
}
function highlighted(inline: readonly AdfNode[]): AdfNode[] {
const pairs = pairedDelimiters(inline)
return pairs.length === 0 ? [...inline] : mergeAdjacentText(split(inline, pairs))
}
function textOf(node: AdfNode | undefined): string | undefined {
return node?.type === 'text' ? node.text : undefined
}
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function heldWhole(node: AdfNode): boolean {
return Object.keys(nodeAttrs(node)).length > 0 || nodeMarks(node).some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')
}
function delimiters(inline: readonly AdfNode[]): Delimiter[] {
const found: Delimiter[] = []
let line = 0
let position = 0
for (const [index, node] of inline.entries()) {
const text = textOf(node)
if (text === undefined) line += 1
if (text === undefined || heldWhole(node)) {
position += text?.length ?? 0
continue
}
for (let offset = text.indexOf(highlightDelimiter); offset !== -1; offset = text.indexOf(highlightDelimiter, offset + highlightDelimiter.length)) {
const end = offset + highlightDelimiter.length
const before = offset > 0 ? text[offset - 1] : textOf(inline[index - 1])?.at(-1)
const after = text[end] ?? textOf(inline[index + 1])?.[0]
// Outside a delimiter a node's edge bounds it, standing where the source spelled a mark's punctuation.
const outsideBefore = Array.from(text.slice(Math.max(0, offset - 2), offset)).at(-1) ?? ''
const outsideAfter = Array.from(text.slice(end, end + 2))[0] ?? ''
const closes = flanks(before) && !isWordCharacter(outsideAfter)
found.push({ closes, holder: node, line, node: index, offset, opens: flanks(after) && !isWordCharacter(outsideBefore), position: position + offset })
}
position += text.length
}
return found
}
function flanks(character: string | undefined): boolean {
return character !== undefined && !/\s/.test(character)
}
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedDelimiters(inline: readonly AdfNode[]): Delimiter[] {
const found = delimiters(inline)
const paired: Delimiter[] = []
let closer = 0
let resume = 0
for (const opener of found) {
if (!opener.opens || opener.position < resume) continue
const earliest = opener.position + highlightDelimiter.length + 1
let candidate = found[closer]
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
if (candidate === undefined) break
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
paired.push(opener, candidate)
resume = candidate.position + highlightDelimiter.length
}
return paired
}
function split(inline: readonly AdfNode[], paired: readonly Delimiter[]): AdfNode[] {
const lifted: AdfNode[] = []
let next = 0
for (const [index, node] of inline.entries()) {
const text = textOf(node)
if (text === undefined || heldWhole(node)) {
lifted.push(node)
continue
}
let from = 0
for (let delimiter = paired[next]; delimiter?.node === index; delimiter = paired[next]) {
pushPiece(lifted, node, text.slice(from, delimiter.offset), next % 2 === 1)
from = delimiter.offset + highlightDelimiter.length
next += 1
}
pushPiece(lifted, node, text.slice(from), next % 2 === 1)
}
return lifted
}
function pushPiece(lifted: AdfNode[], node: AdfNode, text: string, inPair: boolean): void {
const marks = inPair ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)]
if (text !== '') lifted.push(marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' })
}
+54
View File
@@ -0,0 +1,54 @@
type AlertMarker = { folded: boolean; length: number; panelType: string }
export const foldedAlertMarker = '[!NOTE]-'
export const highlightDelimiter = '=='
const alertWords: Readonly<Record<string, string>> = {
error: 'CAUTION',
info: 'NOTE',
note: 'IMPORTANT',
success: 'TIP',
tip: 'TIP',
warning: 'WARNING',
}
const panelTypesByWord: Readonly<Record<string, string>> = {
attention: 'warning',
bug: 'error',
caution: 'error',
check: 'success',
danger: 'error',
done: 'success',
error: 'error',
fail: 'error',
failure: 'error',
hint: 'tip',
important: 'note',
missing: 'error',
success: 'success',
tip: 'tip',
warning: 'warning',
}
export function alertMarker(panelType: unknown): string {
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
return `[!${word ?? 'NOTE'}]`
}
export function readAlertMarker(text: string): AlertMarker | undefined {
const marker = /^\[!([\w-]+)\]([+-]?)/.exec(text)
if (marker === null) return undefined
const word = (marker[1] ?? '').toLowerCase()
const panelType = Object.hasOwn(panelTypesByWord, word) ? panelTypesByWord[word] : undefined
return { folded: marker[2] !== '', length: marker[0].length, panelType: panelType ?? 'info' }
}
export function taskMarker(state: unknown): string {
return state === 'DONE' ? '[x]' : '[ ]'
}
export function readTaskMarker(text: string): { length: number; state: 'DONE' | 'TODO' } | undefined {
const marker = /^\[([ xX])\]/.exec(text)
if (marker === null) return undefined
return { length: marker[0].length, state: marker[1] === ' ' ? 'TODO' : 'DONE' }
}
+2
View File
@@ -1074,6 +1074,8 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
- [ ] **10 — Lossy conversion (`0.2.0`).** - [ ] **10 — Lossy conversion (`0.2.0`).**
- [x] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per - [x] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
row above. row above.
- [x] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
other tools' spellings included; the editor's default highlight colour looked up and cited.
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
+19 -8
View File
@@ -22,7 +22,7 @@ Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next
## Milestones ## Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b,
4c, 14, 15, 16, 18, 4d, 28, 17, 29, 19, 20, 21, 22, 32, 23, 24, 25, 30, 26, 27, 10, 6, 7, 31, 33, 5f, 5g → 4c, 14, 15, 16, 18, 4d, 28, 17, 29, 19, 20, 21, 22, 32, 23, 24, 25, 30, 26, 27, 10, 6, 7, 31, 33, 34, 5f, 5g →
`0.2.0`; `0.2.0`;
8, 9 → TBD; 5e last. 8, 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. Everything known and The numbering is the order the work was planned in, not the order it ships. Everything known and
@@ -43,8 +43,7 @@ HTML doubles the importers and the file count they touch, and 25 to 27 because t
panel says the next reader pays for. panel says the next reader pays for.
29 and 30 come from 17's prose pass (2026-09-20). 29 reads first because every goal is what a later 29 and 30 come from 17's prose pass (2026-09-20). 29 reads first because every goal is what a later
ask is settled against, 19's included; 30 sits beside 25, the other chunk rereading AGENTS.md. ask is settled against, 19's included; 30 sits beside 25, the other chunk rereading AGENTS.md.
33 comes from 10a (2026-09-25) and reads beside 31, the other chunk about what the pipeline 33 comes from 10a and 34 from 10b (2026-09-25); both read beside 31.
measures.
31 comes from 20's gate runs (2026-09-21) and reads beside 5f, the other chunk putting a measured 31 comes from 20's gate runs (2026-09-21) and reads beside 5f, the other chunk putting a measured
number under the pipeline. 32 comes from 21's review (2026-09-21) and reads beside 22, the other number under the pipeline. 32 comes from 21's review (2026-09-21) and reads beside 22, the other
chunk clearing a §11 seam. chunk clearing a §11 seam.
@@ -62,6 +61,10 @@ chunk clearing a §11 seam.
on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the
whole line before riding the carry, quadratic in the runs (§11 Bounds), and the plain whole line before riding the carry, quadratic in the runs (§11 Bounds), and the plain
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear. reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
- [ ] **34 — Emphasis flanking reads a whole character (`0.2.0`).** Check whether
`line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an astral
symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
punctuation — and, where they do, read the code point, with a fixture per direction.
- [x] **24 — The conformance gates have a directory (`0.2.0`).** - [x] **24 — The conformance gates have a directory (`0.2.0`).**
- [x] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).** - [x] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).**
- [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).** - [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).**
@@ -202,8 +205,8 @@ chunk clearing a §11 seam.
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger, Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
failure, fail, missing and bug error; any other word info). Text after a marker in its failure, fail, missing, bug and error error — `error` by a panel, 3 of 3, 2026-09-25; any
paragraph is the panel's first body paragraph. other word info). Text after a marker in its paragraph is the panel's first body paragraph.
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`, - An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
rest of the marker's paragraph as its title, and an expand inside an expand as a rest of the marker's paragraph as its title, and an expand inside an expand as a
@@ -213,7 +216,8 @@ chunk clearing a §11 seam.
where an item holds more than one block, a nested task list moved beside its item — and where an item holds more than one block, a nested task list moved beside its item — and
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list. leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default - `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
highlight colour. highlight colour where whitespace, punctuation or a line edge bounds each `==` outside, so
`a==b and c==d` stays text (a panel, 3 of 3, 2026-09-25).
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension` - `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
spelling, attributes dropped. spelling, attributes dropped.
@@ -252,12 +256,19 @@ chunk clearing a §11 seam.
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`, lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes. and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [x] **10a — The reduction.** - [x] **10a — The reduction.**
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads, - [x] **10b — The lift.**
other tools' spellings included; the editor's default highlight colour looked up and cited.
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README - [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
sections, and two properties over 4.2's generators: writing refuses only the guard's codes sections, and two properties over 4.2's generators: writing refuses only the guard's codes
and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one. again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
- [ ] **10d — A literal marker survives the lossy round trip.** Text reading `==x==`, a quote
opening `[!NOTE]` or a list whose items all open `[x] ` comes back as a highlight, panel or
task list after `plainMarkdownToAdf(adfToPlainMarkdown(doc))`, and a human's `\==x==` too:
the lift reads ADF, where `markdownToAdf` has already spent the backslash. Give plain
markdown an escape that keeps such text literal through both directions — which seam carries
it is a gap to ask. The same class runs the other way: a highlighted `=` writes `=====`, which
reads back as text, and a highlighted `a==b` writes `==a==b==`, highlighting `a` alone; 10c's
byte-for-byte property misses both, since the wrong document re-spells to the same bytes.
- [x] **11 — Atlassian's ADF schema as the tables' truth.** - [x] **11 — Atlassian's ADF schema as the tables' truth.**
- [x] **11a — The vendored schema.** - [x] **11a — The vendored schema.**
- [x] **11b — The gate.** - [x] **11b — The gate.**