diff --git a/README.md b/README.md index 0220f92..e2e65bb 100644 --- a/README.md +++ b/README.md @@ -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: 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 -`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. +no directive. + +`plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the +conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as 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` | `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, 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 | +| `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 | — | | `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 | — | @@ -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: `_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`, `_(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. +- `backgroundColor`, `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 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`, diff --git a/docs/decisions.md b/docs/decisions.md index 4f5c0b6..79b9ddd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -304,8 +304,8 @@ input reads `message`. inline alike, `\|` for every pipe row. - `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 - anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces, whose empty - half no input reaches. The message names the violation instead. + anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message + names the violation instead. ## Publish on a version bump diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 58712f6..25c4a3a 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index c3f770a..3587e8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 0b94ead..618c980 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -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', () => { diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index ea7d710..107ac94 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -190,7 +190,7 @@ function quoted(head: Result, node: AdfNode, reduction: Reduction): R function reduceExpand(node: AdfNode, reduction: Reduction): Result { 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) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 3017c03..a30cf01 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 { 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 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 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 @@ -53,29 +59,21 @@ type Scan = { type SlotContent = { carry: boolean; nodes: AdfNode[] } 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 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 spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' -export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result { - return parseInline(source, definitions, path, container, noSpans) +export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { + return parseInline(source, definitions, path, container, noSpans, flavour === 'plain') } -function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result { - 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 { + 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 +99,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 +242,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[ function slotContent(scan: Scan, span: DirectiveSpan): Result { 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 +262,7 @@ function assemble(scan: Scan): Result { 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 +301,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 +391,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 +429,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: } function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result { - 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 +442,25 @@ function altText(node: AdfNode): string { return typeof spelled === 'string' ? spelled : (node.text ?? '') } -function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result { +// 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 { 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 +507,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' diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index c78a60d..a13ccf5 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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,39 @@ 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 + +// `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 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 { + return readDocument(markdown, 'lossless') +} + +export function plainMarkdownToAdf(markdown: string): Result { + return readDocument(markdown, 'plain') +} + +function readDocument(markdown: string, flavour: Flavour): Result { 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 { +function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 +53,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 +73,124 @@ 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 { +function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { +function markerLed(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 { + 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): Result { + 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 { + 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 { + 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 { 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 { +function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { @@ -114,13 +206,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 { +function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result { 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 +221,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 { +function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath): Result { 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 { - const content = readBlocks(blocks, definitions, path, depth + 1, memo) +function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { + const content = readBlocks(blocks, reading, path, depth + 1) if (!content.ok) return content 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] } } -function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result { +function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 +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. -function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { - const content = parseInlineContent(text, definitions, path, 'paragraph') +function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result { + 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 { - const content = parseInlineContent(text, definitions, path, container) +function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result { + 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)) diff --git a/src/markdown/parse/plain-lift.test.ts b/src/markdown/parse/plain-lift.test.ts deleted file mode 100644 index a5a5651..0000000 --- a/src/markdown/parse/plain-lift.test.ts +++ /dev/null @@ -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') -}) diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts deleted file mode 100644 index fe5d74e..0000000 --- a/src/markdown/parse/plain-lift.ts +++ /dev/null @@ -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 = { marker: T; rest: AdfNode[] } - -const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } - -export function plainMarkdownToAdf(markdown: string): Result { - 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(block: AdfNode | undefined, read: (text: string) => T | undefined): MarkerLed | 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' }) -} diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts new file mode 100644 index 0000000..f378da4 --- /dev/null +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -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') +}) diff --git a/src/markdown/plain-conventions.ts b/src/markdown/plain-conventions.ts index d972f0d..04de876 100644 --- a/src/markdown/plain-conventions.ts +++ b/src/markdown/plain-conventions.ts @@ -1,3 +1,5 @@ +export type Flavour = 'lossless' | 'plain' + type AlertMarker = { folded: boolean; length: number; panelType: string } export const foldedAlertMarker = '[!NOTE]-' diff --git a/todo.md b/todo.md index 815748a..cf8f7db 100644 --- a/todo.md +++ b/todo.md @@ -4,26 +4,24 @@ - **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]` 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 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 - cannot spell. + wrong document re-spells to the same bytes. An expand titled `**x** [y](z)` comes back an + 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 task ids come from position, README §Plain markdown's `localId` bullet saying so. The id spelling 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.** 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