Compare commits

...

4 Commits

4 changed files with 45 additions and 26 deletions
+1 -1
View File
@@ -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))
}
+11 -6
View File
@@ -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}`
+28 -17
View File
@@ -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<T> = { 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' })
}
+5 -2
View File
@@ -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.
@@ -262,7 +263,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.**