35a - the parser reads the plain flavour and the lift goes #144

Merged
lilleman merged 6 commits from 35a into main 2026-09-28 22:22:40 +02:00
13 changed files with 494 additions and 470 deletions
+17 -12
View File
@@ -118,18 +118,22 @@ 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: tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest:
attributes, colours, layout, identity. It refuses only attributes, colours, layout, identity. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes `not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive. `plainMarkdownToAdf` reads through `markdownToAdf`, refusing what it refuses, and no directive.
turns the conventions below back into nodes, taking other tools' spellings too. Markdown
`adfToPlainMarkdown` wrote reads back and writes again byte for byte; the document it came from `plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the
does not come back. To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text:
saving what this pair read replaces mentions, attachments and macros with 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 | | 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 | | `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, 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; an expand inside an expand is a `nestedExpand` | | `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 | | `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 | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, in the editor's default highlight `#f8e6a0` |
| `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 | — | | `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 | — |
| `decisionList` | a bullet list | — | | `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — | | `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
@@ -142,10 +146,11 @@ saving what this pair read replaces mentions, attachments and macros with text.
- Content the document only references leaves an italic note naming it where it stood: - Content the document only references leaves an italic note naming it where it stood:
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`, `_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`,
`_(link card not included)_`, `_(extension not included)_`. `_(link card not included)_`, `_(extension not included)_`.
- `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
so does a mark CommonMark cannot spell where it stands. 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, - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
carriage returns and null characters are removed, and an empty paragraph drops. 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 - An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text. continue, is one bullet list keeping its numbers as text.
- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`, - A node read back carries no `localId`, which Atlassian's schema requires on `taskList`,
+2 -2
View File
@@ -304,8 +304,8 @@ input reads `message`.
inline alike, `\|` for every pipe row. inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
branches read the document's own shape, and threading a path to the eighth — a malformed node branches read the document's own shape, and threading a path to the eighth — a malformed node
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces, whose empty anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
half no input reaches. The message names the violation instead. names the violation instead.
## Publish on a version bump ## Publish on a version bump
+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 { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts' import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { plainMarkdownToAdf } from '../markdown/parse/plain-lift.ts'
import { toEditorNormal } from '../adf/editor-normal.ts' import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600 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 { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts' export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { plainMarkdownToAdf } from './markdown/parse/plain-lift.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', '> [!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', {}, 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', () => { 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[]> { function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const title = nodeAttrs(node)['title'] 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) return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction)
} }
+119 -27
View File
@@ -1,15 +1,17 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts' import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../commonmark/emphasis-matching.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 { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { commonMarkLink, linkHref } from '../mark-spellings.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 { highlightDelimiter } from '../plain-conventions.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.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 { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.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 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 Pairing = EmphasisPairing<Run>
type Piece = type Piece =
@@ -33,6 +37,7 @@ type Piece =
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] } | { kind: 'nodes'; nodes: AdfNode[] }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } | { 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 } 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. // 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 deactivatedBefore: number
definitions: LinkDefinitions definitions: LinkDefinitions
highlights: boolean
openingSpellableLink: boolean openingSpellableLink: boolean
path: ConvertErrorPath path: ConvertErrorPath
pending: string pending: string
@@ -53,29 +59,21 @@ type Scan = {
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none' const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' 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>' 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> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans) return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
} }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> { function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
const scan: Scan = { container, deactivatedBefore: 0, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans } const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
case '\\':
index = readBackslash(scan, index)
break
case '\n':
index = readLineEnding(scan, index)
break
case '`':
index = readBackticks(scan, index)
break
case '<': { case '<': {
const angle = readAngle(scan, index) const angle = readAngle(scan, index)
if (!angle.ok) return angle if (!angle.ok) return angle
@@ -101,20 +99,34 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index = closed.value index = closed.value
break break
} }
case '*':
case '_':
case '~':
index = readDelimiterRun(scan, index)
break
default: default:
scan.pending += source.charAt(index) index = readCharacter(scan, index)
index += 1
} }
} }
flush(scan, container !== undefined) flush(scan, container !== undefined)
return assemble(scan) 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 { function readBackslash(scan: Scan, index: number): number {
if (scan.source.charAt(index + 1) === '\n') { if (scan.source.charAt(index + 1) === '\n') {
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash. // CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
@@ -230,7 +242,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> { function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(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.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value) return success(parsed.value)
@@ -250,7 +262,7 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0] const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) 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 (!nodes.ok) return nodes
if (scan.openingSpellableLink) { if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path) const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
@@ -289,6 +301,24 @@ function readDelimiterRun(scan: Scan, index: number): number {
return index + length 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 { function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
const bracketed = readBracketedAutolink(source, index) const bracketed = readBracketedAutolink(source, index)
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') } if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
@@ -361,7 +391,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
} }
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, 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 if (!resolved.ok) return resolved
const nodes = resolved.value const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text. // An empty link text gives the mark no node to ride, so the brackets stay text.
@@ -399,7 +429,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
} }
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> { 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 if (!nodes.ok) return nodes
return success(nodes.value.map(altText).join('')) return success(nodes.value.map(altText).join(''))
} }
@@ -412,20 +442,25 @@ function altText(node: AdfNode): string {
return typeof spelled === 'string' ? spelled : (node.text ?? '') 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 nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces) const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs) const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings) writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeAdjacentText(nodes.flat())) return success(mergeAdjacentText(nodes.flat()))
} }
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. // 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[] { function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) { switch (piece.kind) {
case 'carry': case 'carry':
return [piece.node] return [piece.node]
case 'highlight':
return [{ text: '', type: 'text' }]
case 'image': case 'image':
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }] return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
case 'nodes': case 'nodes':
@@ -472,6 +507,63 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true 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 { function markType(character: string, used: number): string {
if (character === '~') return 'strike' if (character === '~') return 'strike'
return used === 2 ? 'strong' : 'em' return used === 2 ? 'strong' : 'em'
+122 -30
View File
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts' import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts' import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts'
@@ -10,22 +11,39 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../block-directive.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 { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { unsupportedNodeShape } from '../directive-syntax.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 } const documentStart: SourcePosition = { line: 1, offset: 0 }
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> { 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 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 if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }) 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) if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = [] const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
@@ -35,7 +53,7 @@ function readBlocks(blocks: readonly Block[], definitions: LinkDefinitions, path
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position) if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
continue 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 if (!node.ok) return node
content.push(node.value) content.push(node.value)
} }
@@ -55,50 +73,124 @@ function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`) 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) { switch (block.kind) {
case 'blockquote': 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': 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': case 'code':
return codeBlockNode(block.language, block.text, path, depth) return codeBlockNode(block.language, block.text, path, depth)
case 'directive': case 'directive':
return directiveNode(block, definitions, path, depth, memo) return directiveNode(block, reading, path, depth)
case 'fault': case 'fault':
return faulted(block.fault, path) return faulted(block.fault, path)
case 'heading': 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': case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'orderedList': 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': case 'paragraph':
return paragraphNode(block.text, definitions, path) return paragraphNode(block.text, reading, path)
case 'rule': case 'rule':
return success({ type: 'rule' }) return success({ type: 'rule' })
case 'table': 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> { 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
}
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' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless')
if (!title.ok) return title
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
const text = title.value.nodes.map((node) => node.text ?? '').join('')
const type = reading.inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
}
// Atlassian's schema requires a panel and an expand to hold a block.
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
if (!content.ok) return content
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
}
// A paragraph split off a marker still refuses the image it held beside it; `onMarkerLine` is whether the first one opens on the marker's line.
function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const read = readBlocks([...marked, ...others], reading, path, depth + 1)
if (!read.ok) return read
const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle')
if (image === -1) return read
return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
}
// 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 = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
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) const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
if (!read.ok) return read 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 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 === undefined) return built
if (!readable.ok) return readable if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) 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 const { contentModel, node } = read
if (blocks === undefined) return success(node) if (blocks === undefined) return success(node)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, definitions, path, depth, memo) return containerNode(node, blocks, reading, path, depth)
} }
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> { function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
@@ -114,13 +206,13 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }])) 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[] = [] const content: AdfNode[] = []
for (const [rowIndex, cells] of rows.entries()) { for (const [rowIndex, cells] of rows.entries()) {
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell' const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
const row: AdfNode[] = [] const row: AdfNode[] = []
for (const [cellIndex, cell] of cells.entries()) { 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 if (!paragraph.ok) return paragraph
row.push({ content: [paragraph.value], type }) row.push({ content: [paragraph.value], type })
} }
@@ -129,16 +221,16 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
return success({ content, type: 'table' }) 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) if (blocks.length === 0) return success(node)
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) 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) 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> { function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content = readBlocks(blocks, definitions, path, depth + 1, memo) const content = readBlocks(blocks, reading, path, depth + 1)
if (!content.ok) return content if (!content.ok) return content
return success(withContent(node, content.value)) return success(withContent(node, content.value))
} }
@@ -147,10 +239,10 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
return content.length === 0 ? node : { ...node, content: [...content] } 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[] = [] const content: AdfNode[] = []
for (const [index, blocks] of items.entries()) { 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 if (!item.ok) return item
content.push(item.value) content.push(item.value)
} }
@@ -168,15 +260,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. // 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> { function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, 'paragraph') const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
if (!content.ok) return content if (!content.ok) return content
const image = content.value.image const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : 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> { function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, container) const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
if (!content.ok) return content 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) 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)) return success(withContent(node, content.value.nodes))
-201
View File
@@ -1,201 +0,0 @@
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')
})
-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' })
}
@@ -0,0 +1,220 @@
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 { markdownToAdf, plainMarkdownToAdf } 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 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[] {
return toEditorNormal(document(...blocks)).content ?? []
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
// 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 ? read(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('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.'))])
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('reads an Obsidian callout to a panel by what its word means, any other word info', () => {
const alert = (word: string): unknown => {
const blocks = read(`> [!${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 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]\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('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]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
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(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())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
})
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(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(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(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', '- [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(read('- plain\n - [ ] nested\n'), [bare('bulletList', bare('listItem', said('plain'), bare('taskList', task('TODO', text('nested')))))])
})
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(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 ['\\==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('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)
}
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 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 ? 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 ? 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')
const refusal = (markdown: string): unknown => {
const parsed = plainMarkdownToAdf(markdown)
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
}
for (const markdown of ['> [!tip] ![a](u)\n', '> [!NOTE]- ![a](u)\n', '- [x] ![a](u)\n']) {
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
}
for (const markdown of ['> [!tip]\n> ![a](u)\n', '> [!tip] t\n> ![a](u)\n', '> [!NOTE]- t\n> ![a](u)\n', '- [x]\n ![a](u)\n']) {
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
}
let deep = 'x\n'
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
})
+2
View File
@@ -1,3 +1,5 @@
export type Flavour = 'lossless' | 'plain'
type AlertMarker = { folded: boolean; length: number; panelType: string } type AlertMarker = { folded: boolean; length: number; panelType: string }
export const foldedAlertMarker = '[!NOTE]-' export const foldedAlertMarker = '[!NOTE]-'
+8 -10
View File
@@ -4,26 +4,24 @@
- **35 — Read and write plain markdown as a flavour of the markdown grammar.** Per Goal 2 and - **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 `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. 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 - **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 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]` `plainMarkdownToAdf(adfToPlainMarkdown(doc))` keeps a literal `==x==`, a quote opening `[!NOTE]`
and a list whose items all open `[x] ` as text. A highlighted `=` (today `=====`) and `a==b` and a list whose items all open `[x] ` as text. A highlighted `=` (today `=====`) and `a==b`
(today `==a==b==`, highlighting `a` alone) come back highlighted whole, or lose the highlight (today `==a==b==`, highlighting `a` alone) come back highlighted whole, or lose the highlight
where no spelling holds them; the plain pair's byte-for-byte property misses both, since the where no spelling holds them; the plain pair's byte-for-byte property misses both, since the
wrong document re-spells to the same bytes. The reduction keeps only degrading what the flavour wrong document re-spells to the same bytes. An expand titled `**x** [y](z)` comes back an
cannot spell. expand, where today its escaped marker reads back as a quote. The reduction keeps only
degrading what the flavour cannot spell.
- **10f — Give task nodes read from plain markdown position ids.** Per `docs/decisions.md` §Plain - **10f — Give task nodes read from plain markdown position ids.** Per `docs/decisions.md` §Plain
task ids come from position, README §Plain markdown's `localId` bullet saying so. The id spelling task ids come from position, README §Plain markdown's `localId` bullet saying so. The id spelling
is part of the chunk. is part of the chunk.
- **41 — Keep a link's target when `plainMarkdownToAdf` reads a callout title.** Per Goal 5 a link
target is content, but `> [!faq]- See [x](http://y)` reads to an expand titled `See x`, the
target gone. Where the target goes is a gap in `docs/decisions.md`; candidate: the title keeps
it as text, `See x (http://y)`.
- **40 — Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document it takes.** - **40 — Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document it takes.**
Today it holds for editor-normal documents only: two adjacent text nodes with the same marks Today it holds for editor-normal documents only: two adjacent text nodes with the same marks
merge, an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines merge, an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines