35a - the parser reads the plain flavour and the lift goes
CI / gate (push) Successful in 43s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-28 22:02:55 +02:00
parent 23804a5b3a
commit 474e6a2991
11 changed files with 311 additions and 314 deletions
+8 -6
View File
@@ -118,16 +118,17 @@ Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` wri
tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest:
attributes, colours, layout, identity. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive. `plainMarkdownToAdf` reads through `markdownToAdf`, refusing what it refuses, and
turns the conventions below back into nodes, taking other tools' spellings too. Markdown
no directive. `plainMarkdownToAdf` reads what `markdownToAdf` reads, refusing what it refuses, and
the conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker text:
`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown
`adfToPlainMarkdown` wrote reads back and writes again byte for byte; the document it came from
does not come back. To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`:
saving what this pair read replaces mentions, attachments and macros with text.
| ADF | Written | Read back |
| --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | GitHub's five words, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word; an expand inside an expand is a `nestedExpand` |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | GitHub's five words, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; an expand inside an expand is a `nestedExpand` |
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
| `backgroundColor` | `==text==` | `==text==` bounded outside by whitespace, punctuation or a line edge, in the editor's default highlight |
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
@@ -144,8 +145,9 @@ saving what this pair read replaces mentions, attachments and macros with text.
`_(link card not included)_`, `_(extension not included)_`.
- `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and
so does a mark CommonMark cannot spell where it stands.
- A newline in text is a hard break, edge whitespace outside a link or code span is trimmed,
carriage returns and null characters are removed, and an empty paragraph drops.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text.
- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`,
+1 -2
View File
@@ -6,8 +6,7 @@ import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.t
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { plainMarkdownToAdf } from '../markdown/parse/plain-lift.ts'
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600
+1 -2
View File
@@ -4,5 +4,4 @@ export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { plainMarkdownToAdf } from './markdown/parse/plain-lift.ts'
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
+1 -1
View File
@@ -118,7 +118,7 @@ test('spells an expand and a nested expand as a folded callout titled by the mar
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
)
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\*\\\n> lines\n')
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
})
test('spells a task list as a bullet list whose items lead with their state', () => {
+1 -1
View File
@@ -190,7 +190,7 @@ function quoted(head: Result<AdfNode[]>, node: AdfNode, reduction: Reduction): R
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const title = nodeAttrs(node)['title']
const marker = typeof title === 'string' ? `${foldedAlertMarker} ${title.replace(/^[ \t\n\r]+/, '')}` : foldedAlertMarker
const marker = typeof title === 'string' ? `${foldedAlertMarker} ${title.replace(/^[ \t\n\r]+/, '').replaceAll('\n', ' ')}` : foldedAlertMarker
return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction)
}
+118 -27
View File
@@ -1,15 +1,17 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { editorHighlight, highlightDelimiter } from '../plain-conventions.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
@@ -25,6 +27,8 @@ export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; line: number; opens: boolean; position: number }
type Pairing = EmphasisPairing<Run>
type Piece =
@@ -33,6 +37,7 @@ type Piece =
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
@@ -42,6 +47,7 @@ type Scan = {
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number
definitions: LinkDefinitions
highlights: boolean
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
@@ -58,24 +64,15 @@ const imageAlone = 'an image fits only as a paragraph of its own: this one sits
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans)
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
}
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
const scan: Scan = { container, deactivatedBefore: 0, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
let index = 0
while (index < source.length) {
switch (source.charAt(index)) {
case '\\':
index = readBackslash(scan, index)
break
case '\n':
index = readLineEnding(scan, index)
break
case '`':
index = readBackticks(scan, index)
break
case '<': {
const angle = readAngle(scan, index)
if (!angle.ok) return angle
@@ -101,20 +98,34 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index = closed.value
break
}
case '*':
case '_':
case '~':
index = readDelimiterRun(scan, index)
break
default:
scan.pending += source.charAt(index)
index += 1
index = readCharacter(scan, index)
}
}
flush(scan, container !== undefined)
return assemble(scan)
}
function readCharacter(scan: Scan, index: number): number {
switch (scan.source.charAt(index)) {
case '\\':
return readBackslash(scan, index)
case '\n':
return readLineEnding(scan, index)
case '`':
return readBackticks(scan, index)
case '*':
case '_':
case '~':
return readDelimiterRun(scan, index)
case '=':
return readEquals(scan, index)
default:
scan.pending += scan.source.charAt(index)
return index + 1
}
}
function readBackslash(scan: Scan, index: number): number {
if (scan.source.charAt(index + 1) === '\n') {
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
@@ -230,7 +241,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined)
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value)
@@ -250,7 +261,7 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(scan.pieces, scan.path)
const nodes = resolveNodes(scan.pieces, scan.path, true)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
@@ -289,6 +300,24 @@ function readDelimiterRun(scan: Scan, index: number): number {
return index + length
}
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
function readEquals(scan: Scan, index: number): number {
if (!scan.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
scan.pending += '='
return index + 1
}
const end = index + highlightDelimiter.length
const before = Array.from(scan.source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
const after = Array.from(scan.source.slice(end, end + 2))[0] ?? ''
flush(scan, false)
scan.pieces.push({ closes: flanks(before) && !isWordCharacter(after), kind: 'highlight', opens: flanks(after) && !isWordCharacter(before) })
return end
}
function flanks(character: string): boolean {
return character !== '' && !/\s/.test(character)
}
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
const bracketed = readBracketedAutolink(source, index)
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
@@ -361,7 +390,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
}
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path)
const resolved = resolveNodes(inner, scan.path, true)
if (!resolved.ok) return resolved
const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text.
@@ -399,7 +428,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
}
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
const nodes = resolveNodes(inner, path)
const nodes = resolveNodes(inner, path, false)
if (!nodes.ok) return nodes
return success(nodes.value.map(altText).join(''))
}
@@ -412,20 +441,25 @@ function altText(node: AdfNode): string {
return typeof spelled === 'string' ? spelled : (node.text ?? '')
}
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> {
// An image's alt text is plain, so `highlights` is off there and every `==` stays text.
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeAdjacentText(nodes.flat()))
}
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) {
case 'carry':
return [piece.node]
case 'highlight':
return [{ text: '', type: 'text' }]
case 'image':
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
case 'nodes':
@@ -472,6 +506,63 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true
}
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
const found: HighlightDelimiter[] = []
let line = 0
let position = 0
for (const [index, piece] of pieces.entries()) {
const held = nodes[index] ?? []
const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
position += highlightDelimiter.length
continue
}
for (const node of held) {
if (node.type === 'text') position += node.text?.length ?? 0
else line += 1
}
}
return found
}
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] {
const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = []
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({ closer: candidate.index, opener: opener.index })
resume = candidate.position + highlightDelimiter.length
}
return paired
}
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void {
for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
}
for (const { closer, opener } of paired) {
nodes[opener] = []
nodes[closer] = []
for (let index = opener + 1; index < closer; index += 1) nodes[index] = (nodes[index] ?? []).map(highlighted)
}
}
function highlighted(node: AdfNode): AdfNode {
const marks = nodeMarks(node)
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
return { ...node, marks: [editorHighlight, ...marks] }
}
function markType(character: string, used: number): string {
if (character === '~') return 'strike'
return used === 2 ? 'strong' : 'em'
+112 -30
View File
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
@@ -10,22 +11,37 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../block-directive.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts'
import { readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }>
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
type Reading = { definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
const documentStart: SourcePosition = { line: 1, offset: 0 }
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
return readDocument(markdown, 'lossless')
}
export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
return readDocument(markdown, 'plain')
}
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown)
const content = positioned(readBlocks(parsed.blocks, parsed.definitions, [], 0, new Map()), documentStart)
const reading: Reading = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
}
function readBlocks(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode[]> {
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) {
@@ -35,7 +51,7 @@ function readBlocks(blocks: readonly Block[], definitions: LinkDefinitions, path
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
continue
}
const node = positioned(readBlock(block, definitions, nodePath, depth, memo), block.position)
const node = positioned(readBlock(block, reading, nodePath, depth), block.position)
if (!node.ok) return node
content.push(node.value)
}
@@ -55,50 +71,116 @@ function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
}
function readBlock(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
switch (block.kind) {
case 'blockquote':
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth, memo)
return reading.flavour === 'plain' ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
case 'bulletList':
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth, memo)
return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
case 'code':
return codeBlockNode(block.language, block.text, path, depth)
case 'directive':
return directiveNode(block, definitions, path, depth, memo)
return directiveNode(block, reading, path, depth)
case 'fault':
return faulted(block.fault, path)
case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading')
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'orderedList':
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth, memo)
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
case 'paragraph':
return paragraphNode(block.text, definitions, path)
return paragraphNode(block.text, reading, path)
case 'rule':
return success({ type: 'rule' })
case 'table':
return tableNode(block.rows, definitions, path)
return tableNode(block.rows, reading, path)
}
}
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
// The marker opens the paragraph's source, and whitespace or the line's end follows it.
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
if (block?.kind !== 'paragraph') return undefined
const marker = read(block.text)
if (marker === undefined) return undefined
const text = block.text.slice(marker.length)
return text === '' || /^(?:[ \t\n]|\\\n)/.test(text) ? { marker, position: block.position, text } : undefined
}
// The marker's line, less a hard break ending it, and the lines after.
function markerLine(text: string): { line: string; rest: string } {
const lineEnd = text.indexOf('\n')
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
}
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const [first, ...body] = blocks
const led = markerLed(first, readAlertMarker)
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, [...paragraphsOf(led.position, line, rest), ...body], reading, path, depth)
const title = contentNode({ type: 'paragraph' }, line, reading, path, 'paragraph')
if (!title.ok) return title
const text = nodeContent(title.value).map((node) => node.text ?? '').join('')
const type = reading.inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, [...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth)
}
// Atlassian's schema requires a panel and an expand to hold a block.
function filledNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const built = containerNode(node, blocks, reading, path, depth)
return built.ok && built.value.content === undefined ? success({ ...built.value, content: [{ type: 'paragraph' }] }) : built
}
// A task list trailing an item's blocks stands beside it, as ADF nests one.
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const led = []
for (const [first, ...others] of items) {
const marked = markerLed(first, readTaskMarker)
if (marked === undefined) return listNode({ type: 'bulletList' }, items, reading, path, depth)
led.push({ ...marked, others })
}
const tasks: AdfNode[] = []
for (const [index, { marker, others, position, text }] of led.entries()) {
const read = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1)
if (!read.ok) return read
let beside = read.value.length
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
const kept = read.value.slice(0, beside)
const [only] = kept
const attrs = { state: marker.state }
const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph')
tasks.push(inline ? { attrs, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' })
for (const nested of read.value.slice(beside)) tasks.push(nested)
}
return success({ content: tasks, type: 'taskList' })
}
function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, definitions, path, depth, memo)
const inExpand = reading.inExpand || read.value.node.type === 'expand' || read.value.node.type === 'nestedExpand'
const built = directiveBody(read.value, block.blocks, { ...reading, inExpand }, path, depth)
if (!built.ok) return built
const readable = commonMarkSpelling(built.value, path, depth, memo)
const readable = commonMarkSpelling(built.value, path, depth, reading.memo)
if (readable === undefined) return built
if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
}
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read
if (blocks === undefined) return success(node)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path)
return containerNode(node, blocks, definitions, path, depth, memo)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth)
}
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
@@ -114,13 +196,13 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
}
function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content: AdfNode[] = []
for (const [rowIndex, cells] of rows.entries()) {
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
const row: AdfNode[] = []
for (const [cellIndex, cell] of cells.entries()) {
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
const paragraph = contentNode({ type: 'paragraph' }, cell, reading, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
if (!paragraph.ok) return paragraph
row.push({ content: [paragraph.value], type })
}
@@ -129,16 +211,16 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
return success({ content, type: 'table' })
}
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
if (blocks.length === 0) return success(node)
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position)
return positioned(contentNode(node, only.text, reading, path, 'paragraph'), only.position)
}
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
const content = readBlocks(blocks, definitions, path, depth + 1, memo)
function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content = readBlocks(blocks, reading, path, depth + 1)
if (!content.ok) return content
return success(withContent(node, content.value))
}
@@ -147,10 +229,10 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
return content.length === 0 ? node : { ...node, content: [...content] }
}
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content: AdfNode[] = []
for (const [index, blocks] of items.entries()) {
const item = containerNode({ type: 'listItem' }, blocks, definitions, [...path, 'content', index], depth, memo)
const item = containerNode({ type: 'listItem' }, blocks, reading, [...path, 'content', index], depth)
if (!item.ok) return item
content.push(item.value)
}
@@ -168,15 +250,15 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
}
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, 'paragraph')
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
if (!content.ok) return content
const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
}
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, container)
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
if (!content.ok) return content
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes))
-182
View File
@@ -1,182 +0,0 @@
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 { markdownToAdf } from './markdown-to-adf.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { success, type ParseError, type Result } from '../../result.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 plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const parsed = markdownToAdf(markdown)
return parsed.ok ? success(liftFromPlain(parsed.value)) : parsed
}
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' })
}
@@ -4,8 +4,7 @@ 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 { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
import { reduceToPlain } from '../emit/plain-reduction.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
@@ -14,9 +13,9 @@ 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 read(markdown: string): readonly AdfNode[] | string {
const parsed = plainMarkdownToAdf(markdown)
return parsed.ok ? (toEditorNormal(parsed.value).content ?? []) : parsed.error.code
}
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
@@ -27,11 +26,11 @@ function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
// ADF the reduction wrote, spelled and read back through the lift.
// ADF the reduction wrote, spelled and read back.
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
return markdown.ok ? read(markdown.value) : markdown.error.code
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
@@ -62,8 +61,8 @@ 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`)
test('reads an alert to a panel by its GitHub word, in any case', () => {
const alert = (word: string): readonly AdfNode[] | string => read(`> [!${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.'))])
@@ -73,9 +72,9 @@ test('lifts an alert to a panel by its GitHub word, in any case', () => {
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', () => {
test('reads 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`)
const blocks = read(`> [!${word}]\n> Body.\n`)
return typeof blocks === 'string' ? blocks : blocks[0]?.attrs
}
assert.deepEqual(alert('hint'), { panelType: 'tip' })
@@ -85,80 +84,86 @@ test('lifts an Obsidian callout to a panel by what its word means, any other wor
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('reads the rest of an alert marker line as the panel first body paragraph, the lines after as the next', () => {
assert.deepEqual(read('> [!NOTE]\n> Line **one**.\n>\n> Two.\n'), [panel('info', paragraph(text('Line '), text('one', strong), text('.')), said('Two.'))])
assert.deepEqual(read('> [!tip] Title\n'), [panel('tip', said('Title'))])
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph())))
assert.deepEqual(read('> > [!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)
for (const markdown of ['> \\[!NOTE]\n> x\n', '> [!NOTE]x\n', '> **[!NOTE]**\n', '> See [!NOTE]\n', '> [!NOTE]**x**\n', '> [!]\n', '> ```\n> [!NOTE]\n> ```\n']) {
const blocks = read(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('reads a folded callout to an expand titled by the rest of its marker line, whatever the word', () => {
assert.deepEqual(read('> [!NOTE]- Build log\n>\n> Line.\n'), [node('expand', { title: 'Build log' }, said('Line.'))])
assert.deepEqual(read('> [!bug]+ Open **by** default\n> body\n>\n> Line.\n'), [node('expand', { title: 'Open by default' }, said('body'), said('Line.'))])
const link: AdfMark = { attrs: { href: 'https://example.com' }, type: 'link' }
assert.deepEqual(read('> [!faq]- Why?\n> See [the docs](https://example.com), **now**.\n'), [
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
])
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
test('lifts a folded callout inside an expand to a nested expand', () => {
test('reads 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())))))
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!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'), [
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
assert.deepEqual(read('- [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')))))
assert.deepEqual(read('- [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'), [
assert.deepEqual(read('- [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(read('- [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')))))])
assert.deepEqual(read('- [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)
for (const markdown of ['- \\[x] a\n', '- [x] a\n- \\[ ] b\n', '- [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 = read(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')))))])
assert.deepEqual(read('- 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'), [
test('reads a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atlaskit/adf-schema 57.6.8', () => {
assert.deepEqual(read('a ==hi there== b\n'), [paragraph(text('a '), text('hi there', highlight), text(' b'))])
assert.deepEqual(read('**==hi==** b\n'), [paragraph(text('hi', highlight, strong), text(' b'))])
assert.deepEqual(read('==**a**_b_ `c`==\n'), [paragraph(text('a', highlight, strong), text('b', highlight, em), text(' ', highlight), text('c', code))])
assert.deepEqual(read('==`a`==\n'), [paragraph(text('a', code))])
assert.deepEqual(read('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(read('😀==b== ==c==😀 é==d==\n'), [paragraph(text('😀'), text('b', highlight), text(' '), text('c', highlight), text('😀 é==d=='))])
assert.deepEqual(read('# ==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))])
assert.deepEqual(read('> [!NOTE]\n> ==x==\n'), [panel('info', paragraph(text('x', highlight)))])
assert.deepEqual(read('==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)
for (const markdown of ['\\==x==\n', '==x\\==\n', '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(read(markdown)).includes('backgroundColor'), false, markdown)
}
})
test('lifts what the reduction wrote back to the node it reduced, less the attributes it drops', () => {
test('reads 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)
@@ -185,17 +190,19 @@ test('lifts what the reduction wrote back to the node it reduced, less the attri
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'))))])
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
assert.deepEqual(read('!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.deepEqual(carried.ok ? read(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')
assert.deepEqual(spelled.ok ? read(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))])
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
assert.equal(read('!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')
assert.equal(typeof read(deep), 'object')
})
+6
View File
@@ -1,5 +1,11 @@
import type { AdfMark } from '../adf/document.ts'
export type Flavour = 'lossless' | 'plain'
type AlertMarker = { folded: boolean; length: number; panelType: string }
export const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
export const foldedAlertMarker = '[!NOTE]-'
export const highlightDelimiter = '=='
+1 -8
View File
@@ -4,15 +4,8 @@
- **35 — Read and write plain markdown as a flavour of the markdown grammar.** Per Goal 2 and
`docs/decisions.md` §Plain markdown is a flavour of the grammar, README §Plain markdown's rows are
read by `markdownToAdf`'s parser and written by `adfToMarkdown`'s writer, and the lift goes. The
read by `markdownToAdf`'s parser and written by `adfToMarkdown`'s writer. The
exports, their refusals and those rows stay as they are.
- **35a — Read the plain flavour in the parser and delete the lift.** The rows are read while
parsing, and `plain-lift.ts` is deleted, its tests reading through `plainMarkdownToAdf`. `>
[!faq]- Why?` with the body on the next `>` line reads to an expand titled `Why?` whose body
keeps the next lines' link targets and marks, and `> [!tip] Title` then `> body` to a panel
whose paragraphs are `Title` and `body`: the rest of the marker's line is the title (an expand)
or the first body paragraph (a panel). A CommonMark backslash keeps a marker literal — `\==x==`,
`> \[!NOTE]`, `- \[x]`.
- **35b — Spell the plain flavour in the writer.** Panels, expands, task lists and highlights are
written by the writer, which escapes text that would read back as one, so
`plainMarkdownToAdf(adfToPlainMarkdown(doc))` keeps a literal `==x==`, a quote opening `[!NOTE]`