From d1a208146fe30c72784fdca40b9c65217a828840 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:52:44 +0200 Subject: [PATCH 01/12] 10b - tests: the lift reads alerts, folded callouts, task markers and == pairs back into their nodes --- src/markdown/parse/plain-lift.test.ts | 194 ++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/markdown/parse/plain-lift.test.ts diff --git a/src/markdown/parse/plain-lift.test.ts b/src/markdown/parse/plain-lift.test.ts new file mode 100644 index 0000000..15de8e3 --- /dev/null +++ b/src/markdown/parse/plain-lift.test.ts @@ -0,0 +1,194 @@ +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'))) + 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' }))) + 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'))))) + assert.deepEqual(lifted('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand'))))) +}) + +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', highlight, code))]) + assert.deepEqual(lifted('x==y==z ==a == b==\n'), [paragraph(text('x'), text('y', highlight), text('z '), text('a == b', highlight))]) + 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', '====\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(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]) + 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') +}) -- 2.52.0 From 53ff7e0204b19d0b48e26854341dd46584e20e9c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:52:44 +0200 Subject: [PATCH 02/12] 10b - the lift, with alert words, task markers and == shared with the reduction --- src/markdown/emit/plain-inline.ts | 2 +- src/markdown/emit/plain-reduction.ts | 21 +--- src/markdown/parse/plain-lift.ts | 163 +++++++++++++++++++++++++++ src/markdown/plain-conventions.ts | 54 +++++++++ 4 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 src/markdown/parse/plain-lift.ts create mode 100644 src/markdown/plain-conventions.ts diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index f4f3b09..007ee63 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -2,6 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { highlightDelimiter } from '../plain-conventions.ts' import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.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' const highlight = 'backgroundColor' -const highlightDelimiter = '==' const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index a66be46..33e935b 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -1,5 +1,6 @@ import type { AdfDocument, AdfNode } 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 { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.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 } -const alertWords: Readonly> = { - error: 'CAUTION', - info: 'NOTE', - note: 'IMPORTANT', - success: 'TIP', - tip: 'TIP', - warning: 'WARNING', -} - const blockReducers: Readonly> = { blockCard: paragraphOfNode, blockquote: (node, reduction) => quoted(success([]), node, reduction), @@ -41,7 +33,7 @@ const blockReducers: Readonly> = { mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))), nestedExpand: reduceExpand, 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), rule: () => success([{ type: 'rule' }]), syncBlock: paragraphOfNode, @@ -190,14 +182,9 @@ function quoted(head: Result, node: AdfNode, reduction: Reduction): R 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 { 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) } @@ -257,7 +244,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result } function taskBlocks(child: AdfNode, at: Reduction): Result { - const marker = nodeAttrs(child)['state'] === 'DONE' ? '[x]' : '[ ]' + const marker = taskMarker(nodeAttrs(child)['state']) if (child.type === 'taskItem') { 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 diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts new file mode 100644 index 0000000..4559956 --- /dev/null +++ b/src/markdown/parse/plain-lift.ts @@ -0,0 +1,163 @@ +import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' +import { blockNodeModel } from '../../adf/block-nodes.ts' +import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' +import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' +import { nodeContent, nodeMarks } from '../../adf/document.ts' + +type Delimiter = { closes: boolean; line: number; marks: readonly AdfMark[]; node: number; offset: number; opens: boolean; position: number } + +type MarkerLed = { 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(block: AdfNode | undefined, read: (text: string) => T | undefined): MarkerLed | 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: liftBlocks([...paragraphOf(led.rest), ...body], inExpand), type: 'panel' } + const title = led.rest.map((node) => node.text ?? (node.type === 'hardBreak' ? '\n' : '')).join('') + const content = liftBlocks(body, true) + const type = inExpand ? 'nestedExpand' : 'expand' + return title === '' ? { content, type } : { attrs: { title }, content, type } +} + +// 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 +} + +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 || nodeMarks(node).some((mark) => mark.type === 'code')) { + position += text?.length ?? 0 + continue + } + for (let offset = text.indexOf(highlightDelimiter); offset !== -1; offset = text.indexOf(highlightDelimiter, offset + highlightDelimiter.length)) { + const before = offset > 0 ? text[offset - 1] : textOf(inline[index - 1])?.at(-1) + const after = text[offset + highlightDelimiter.length] ?? textOf(inline[index + 1])?.[0] + found.push({ closes: flanks(before), line, marks: nodeMarks(node), node: index, offset, opens: flanks(after), 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.marks, candidate.marks)) continue + paired.push(opener, candidate) + resume = candidate.position + highlightDelimiter.length + } + return paired +} + +function sameMarks(first: readonly AdfMark[], second: readonly AdfMark[]): boolean { + return first.length === second.length && first.every((mark, index) => second[index] !== undefined && sameMark(mark, second[index])) +} + +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) { + 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' }) +} diff --git a/src/markdown/plain-conventions.ts b/src/markdown/plain-conventions.ts new file mode 100644 index 0000000..d972f0d --- /dev/null +++ b/src/markdown/plain-conventions.ts @@ -0,0 +1,54 @@ +type AlertMarker = { folded: boolean; length: number; panelType: string } + +export const foldedAlertMarker = '[!NOTE]-' +export const highlightDelimiter = '==' + +const alertWords: Readonly> = { + error: 'CAUTION', + info: 'NOTE', + note: 'IMPORTANT', + success: 'TIP', + tip: 'TIP', + warning: 'WARNING', +} + +const panelTypesByWord: Readonly> = { + 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.52.0 From bf87e6ea18241b26dd024922ac71050ebba89e71 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:54:04 +0200 Subject: [PATCH 03/12] 10b - tests: a code span inside a == pair takes no highlight --- src/markdown/parse/plain-lift.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/markdown/parse/plain-lift.test.ts b/src/markdown/parse/plain-lift.test.ts index 15de8e3..0dc98e7 100644 --- a/src/markdown/parse/plain-lift.test.ts +++ b/src/markdown/parse/plain-lift.test.ts @@ -140,7 +140,8 @@ test('leaves mixed, ordered and unmarked lists plain', () => { 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', highlight, code))]) + 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==\n'), [paragraph(text('x'), text('y', highlight), text('z '), text('a == b', highlight))]) assert.deepEqual(lifted('# ==h==\n\n| ==c== |\n| --- |\n'), [ node('heading', { level: 1 }, text('h', highlight)), -- 2.52.0 From c1fed0885b88daf1d4424ed0e7a90b23688a0cc5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:54:46 +0200 Subject: [PATCH 04/12] 10b - a code span inside a == pair keeps code and takes no highlight --- src/markdown/parse/plain-lift.ts | 4 +++- todo.md | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts index 4559956..0f91d38 100644 --- a/src/markdown/parse/plain-lift.ts +++ b/src/markdown/parse/plain-lift.ts @@ -158,6 +158,8 @@ function split(inline: readonly AdfNode[], paired: readonly Delimiter[]): AdfNod } function pushPiece(lifted: AdfNode[], node: AdfNode, text: string, inPair: boolean): void { - const marks = inPair ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)] + // Atlassian's schema refuses a highlight on code. + const highlights = inPair && !nodeMarks(node).some((mark) => mark.type === 'code') + const marks = highlights ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)] if (text !== '') lifted.push(marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' }) } diff --git a/todo.md b/todo.md index d18cdf4..fce234a 100644 --- a/todo.md +++ b/todo.md @@ -202,8 +202,8 @@ chunk clearing a §11 seam. 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 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 - paragraph is the panel's first body paragraph. + failure, fail, missing, bug and error error — `error` by a panel, 3 of 3, 2026-09-25; any + 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 `>`, 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 @@ -258,6 +258,12 @@ chunk clearing a §11 seam. 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 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. - [x] **11 — Atlassian's ADF schema as the tables' truth.** - [x] **11a — The vendored schema.** - [x] **11b — The gate.** -- 2.52.0 From b42218e655b14a3d7230272e032edf26024952fb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:55:00 +0200 Subject: [PATCH 05/12] 10b - error reads as an error alert, 10d files the literal marker, and 10b is done --- todo-history.md | 2 ++ todo.md | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/todo-history.md b/todo-history.md index 26e6af4..1327c67 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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`).** - [x] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per 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` diff --git a/todo.md b/todo.md index fce234a..154e2ca 100644 --- a/todo.md +++ b/todo.md @@ -252,8 +252,7 @@ chunk clearing a §11 seam. lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes. - [x] **10a — The reduction.** - - [ ] **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. + - [x] **10b — The lift.** - [ ] **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 and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes -- 2.52.0 From 969326b776758dfb864aceccef96057f52f5665d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:03:09 +0200 Subject: [PATCH 06/12] 10b - 10d names the highlight that reads back wrong --- todo.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 154e2ca..8f331d1 100644 --- a/todo.md +++ b/todo.md @@ -262,7 +262,9 @@ chunk clearing a §11 seam. 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. + 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] **11a — The vendored schema.** - [x] **11b — The gate.** -- 2.52.0 From 8cb85f3d275dcbd4a5c5dafdd3833f954261fcae Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:04:50 +0200 Subject: [PATCH 07/12] 10b - tests: an empty alert holds an empty paragraph, a == pair is bounded outside and passes over held nodes --- src/markdown/parse/plain-lift.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/markdown/parse/plain-lift.test.ts b/src/markdown/parse/plain-lift.test.ts index 0dc98e7..26f5516 100644 --- a/src/markdown/parse/plain-lift.test.ts +++ b/src/markdown/parse/plain-lift.test.ts @@ -89,7 +89,7 @@ test('reads text after an alert marker in its paragraph as the panel first body 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'))) + assert.deepEqual(lifted('> [!NOTE]\n'), normal(panel('info', paragraph()))) assert.deepEqual(lifted('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))]) }) @@ -103,14 +103,14 @@ test('leaves a quote plain where its first line is no alert marker', () => { 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' }))) + 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'))))) - assert.deepEqual(lifted('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand'))))) + 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', () => { @@ -142,7 +142,7 @@ test('lifts a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atl 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==\n'), [paragraph(text('x'), text('y', highlight), text('z '), text('a == b', highlight))]) + 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('# ==h==\n\n| ==c== |\n| --- |\n'), [ node('heading', { level: 1 }, text('h', highlight)), bare('table', bare('tableRow', bare('tableHeader', paragraph(text('c', highlight))))), @@ -152,7 +152,7 @@ test('lifts a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atl }) test('leaves a == no pair flanks as text', () => { - for (const markdown of ['a == b == c\n', '====\n', '`==x==`\n', '==a\\\nb==\n', '**==a**==\n', '==a', '== a==\n', '==a ==\n']) { + 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) } }) @@ -163,6 +163,7 @@ test('lifts what the reduction wrote back to the node it reduced, less the attri 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', @@ -188,6 +189,10 @@ test('keeps what markdownToAdf reads that no row lifts, and refuses only what it 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}` -- 2.52.0 From 4df17055efb88b7467723871fb58c6ba1a5fb21a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:05:58 +0200 Subject: [PATCH 08/12] 10b - an empty alert holds an empty paragraph, a == pair is bounded outside and passes over held nodes --- src/adf/editor-normal.ts | 2 +- src/markdown/parse/plain-lift.ts | 45 ++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/adf/editor-normal.ts b/src/adf/editor-normal.ts index fe74067..b8a299b 100644 --- a/src/adf/editor-normal.ts +++ b/src/adf/editor-normal.ts @@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean { 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)) } diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts index 0f91d38..b6279c5 100644 --- a/src/markdown/parse/plain-lift.ts +++ b/src/markdown/parse/plain-lift.ts @@ -1,10 +1,10 @@ import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' -import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' -import { nodeContent, nodeMarks } from '../../adf/document.ts' +import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts' +import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -type Delimiter = { closes: boolean; line: number; marks: readonly AdfMark[]; node: number; offset: number; opens: boolean; position: number } +type Delimiter = { closes: boolean; holder: AdfNode; line: number; node: number; offset: number; opens: boolean; position: number } type MarkerLed = { marker: T; rest: AdfNode[] } @@ -53,13 +53,18 @@ 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: liftBlocks([...paragraphOf(led.rest), ...body], inExpand), type: 'panel' } + 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 = liftBlocks(body, true) + 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 @@ -89,6 +94,11 @@ 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 @@ -96,14 +106,16 @@ function delimiters(inline: readonly AdfNode[]): Delimiter[] { for (const [index, node] of inline.entries()) { const text = textOf(node) if (text === undefined) line += 1 - if (text === undefined || nodeMarks(node).some((mark) => mark.type === 'code')) { + 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[offset + highlightDelimiter.length] ?? textOf(inline[index + 1])?.[0] - found.push({ closes: flanks(before), line, marks: nodeMarks(node), node: index, offset, opens: flanks(after), position: position + offset }) + const after = text[end] ?? textOf(inline[index + 1])?.[0] + const closes = flanks(before) && bounds(text[end]) + found.push({ closes, holder: node, line, node: index, offset, opens: flanks(after) && bounds(text[offset - 1]), position: position + offset }) } position += text.length } @@ -114,6 +126,11 @@ function flanks(character: string | undefined): boolean { return character !== undefined && !/\s/.test(character) } +// Outside a delimiter, a node's edge stands where the source spelled a mark's punctuation. +function bounds(character: string | undefined): boolean { + return character === undefined || /[\s\p{P}\p{S}]/u.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) @@ -126,23 +143,19 @@ function pairedDelimiters(inline: readonly AdfNode[]): Delimiter[] { 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.marks, candidate.marks)) continue + if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue paired.push(opener, candidate) resume = candidate.position + highlightDelimiter.length } return paired } -function sameMarks(first: readonly AdfMark[], second: readonly AdfMark[]): boolean { - return first.length === second.length && first.every((mark, index) => second[index] !== undefined && sameMark(mark, second[index])) -} - 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) { + if (text === undefined || heldWhole(node)) { lifted.push(node) continue } @@ -158,8 +171,6 @@ function split(inline: readonly AdfNode[], paired: readonly Delimiter[]): AdfNod } function pushPiece(lifted: AdfNode[], node: AdfNode, text: string, inPair: boolean): void { - // Atlassian's schema refuses a highlight on code. - const highlights = inPair && !nodeMarks(node).some((mark) => mark.type === 'code') - const marks = highlights ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)] + const marks = inPair ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)] if (text !== '') lifted.push(marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' }) } -- 2.52.0 From 1eb7b54f19ef797c32db6a29835a95a474d63111 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:06:23 +0200 Subject: [PATCH 09/12] 10b - item 10's highlight row needs the pair bounded outside --- todo.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 8f331d1..ee78850 100644 --- a/todo.md +++ b/todo.md @@ -213,7 +213,8 @@ chunk clearing a §11 seam. 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. - `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` and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their spelling, attributes dropped. -- 2.52.0 From 7a53b6f7a02112c5d6e93622b2abe165c265ea7c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:09:12 +0200 Subject: [PATCH 10/12] 10b - tests: a symbol outside the BMP bounds a == pair --- src/markdown/parse/plain-lift.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/markdown/parse/plain-lift.test.ts b/src/markdown/parse/plain-lift.test.ts index 26f5516..a5a5651 100644 --- a/src/markdown/parse/plain-lift.test.ts +++ b/src/markdown/parse/plain-lift.test.ts @@ -143,6 +143,7 @@ test('lifts a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atl 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))))), -- 2.52.0 From ae2dc6054b12ca2344c5906986663fc973e20d04 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:10:05 +0200 Subject: [PATCH 11/12] 10b - a == pair is bounded by the code point outside it, CommonMark's word character test --- src/markdown/parse/plain-lift.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts index b6279c5..2a3ed02 100644 --- a/src/markdown/parse/plain-lift.ts +++ b/src/markdown/parse/plain-lift.ts @@ -1,5 +1,6 @@ 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' @@ -114,8 +115,11 @@ function delimiters(inline: readonly AdfNode[]): Delimiter[] { 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] - const closes = flanks(before) && bounds(text[end]) - found.push({ closes, holder: node, line, node: index, offset, opens: flanks(after) && bounds(text[offset - 1]), position: position + offset }) + // 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 } @@ -126,11 +130,6 @@ function flanks(character: string | undefined): boolean { return character !== undefined && !/\s/.test(character) } -// Outside a delimiter, a node's edge stands where the source spelled a mark's punctuation. -function bounds(character: string | undefined): boolean { - return character === undefined || /[\s\p{P}\p{S}]/u.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) -- 2.52.0 From 2f03e20549be5aa760e6870ecacef184a19888da Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 21:10:23 +0200 Subject: [PATCH 12/12] 10b - 34 files the flanking read beside an astral symbol --- todo.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/todo.md b/todo.md index ee78850..9a4df2f 100644 --- a/todo.md +++ b/todo.md @@ -22,7 +22,7 @@ Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next ## Milestones 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`; 8, 9 → TBD; 5e last. 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. 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. -33 comes from 10a (2026-09-25) and reads beside 31, the other chunk about what the pipeline -measures. +33 comes from 10a and 34 from 10b (2026-09-25); both read beside 31. 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 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 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. +- [ ] **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] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).** - [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).** -- 2.52.0