10b - plainMarkdownToAdf's ADF→ADF lift #129
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
error: 'CAUTION',
|
||||
info: 'NOTE',
|
||||
note: 'IMPORTANT',
|
||||
success: 'TIP',
|
||||
tip: 'TIP',
|
||||
warning: 'WARNING',
|
||||
}
|
||||
|
||||
const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||
blockCard: paragraphOfNode,
|
||||
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)))),
|
||||
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<AdfNode[]>, 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<AdfNode[]> {
|
||||
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<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') {
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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`).**
|
||||
@@ -202,8 +205,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
|
||||
@@ -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
|
||||
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.
|
||||
@@ -252,12 +256,19 @@ 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
|
||||
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] **11a — The vendored schema.**
|
||||
- [x] **11b — The gate.**
|
||||
|
||||
Reference in New Issue
Block a user