Compare commits

...

5 Commits

7 changed files with 430 additions and 22 deletions
+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
+195
View File
@@ -0,0 +1,195 @@
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', 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)),
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')
})
+165
View File
@@ -0,0 +1,165 @@
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<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: 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 {
// 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' })
}
+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`
+9 -4
View File
@@ -202,8 +202,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
@@ -252,12 +252,17 @@ 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.
- [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.**