35a - the parser reads the plain flavour and the lift goes #144
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
@@ -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] \n', '> [!NOTE]- \n', '- [x] \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> \n', '> [!tip] t\n> \n', '> [!NOTE]- t\n> \n', '- [x]\n \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')
|
||||||
|
})
|
||||||
@@ -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]-'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user