Read the emphasis, the links and the lone image CommonMark spells (#34)
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit was merged in pull request #34.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { LinkDefinition } from './link-reference-definitions.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
function definitions(markdown: string): [string, LinkDefinition][] {
|
||||
@@ -21,9 +21,10 @@ test('keeps the link reference definitions a paragraph gives up, the first of a
|
||||
])
|
||||
assert.deepEqual(definitions('[a\\]b]: /url\n'), [['a\\]b', { destination: '/url' }]])
|
||||
assert.deepEqual(definitions('[a]: /url(x)y\n'), [['a', { destination: '/url(x)y' }]])
|
||||
assert.deepEqual(definitions('[a]: /url\\(x\n'), [['a', { destination: '/url\\(x' }]])
|
||||
assert.deepEqual(definitions('[a]: /url\\(x\n'), [['a', { destination: '/url(x' }]])
|
||||
assert.deepEqual(definitions('[a]: /url&x\n'), [['a', { destination: '/url&x' }]])
|
||||
assert.deepEqual(definitions('[a]: <>\n'), [['a', { destination: '' }]])
|
||||
assert.deepEqual(definitions('[a]: /url "He said \\"hi\\""\n'), [['a', { destination: '/url', title: 'He said \\"hi\\"' }]])
|
||||
assert.deepEqual(definitions('[a]: /url "He said \\"hi\\""\n'), [['a', { destination: '/url', title: 'He said "hi"' }]])
|
||||
assert.deepEqual(definitions('[a]: /url\\\n[b]: /b\n'), [
|
||||
['a', { destination: '/url\\' }],
|
||||
['b', { destination: '/b' }],
|
||||
@@ -47,6 +48,7 @@ test('leaves the paragraph a line no definition spells', () => {
|
||||
assert.deepEqual(definitions('[a]: /url "One" and more\n'), [])
|
||||
assert.deepEqual(definitions('[a]:\n'), [])
|
||||
assert.deepEqual(definitions('[a]: /url "unclosed\n'), [])
|
||||
assert.deepEqual(definitions('[a]: /url (a(b)\n'), [])
|
||||
assert.deepEqual(kinds('[a]: /url\nPart.\n'), ['paragraph'])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LinkDefinition } from './link-reference-definitions.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import {
|
||||
atxHeading,
|
||||
claimsDirectiveLine,
|
||||
|
||||
@@ -1,69 +1,326 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct } from '../commonmark-grammar.ts'
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
||||
|
||||
type Run = { nodes: AdfNode[]; text: string; undecodedFrom: number }
|
||||
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||
|
||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||
|
||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||
|
||||
type Pairing = EmphasisPairing<Run>
|
||||
|
||||
type Piece =
|
||||
| Bracket
|
||||
| { alt: string; kind: 'image'; node: AdfNode }
|
||||
| { kind: 'nodes'; nodes: AdfNode[] }
|
||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
|
||||
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
|
||||
|
||||
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
|
||||
|
||||
const hardBreakSpaces = / {2,}$/
|
||||
const imageAlone = 'an image fits only as a paragraph of its own'
|
||||
const trailingSpace = /[ \t]+$/
|
||||
|
||||
export function parseInlineContent(source: string, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
const run: Run = { nodes: [], text: '', undecodedFrom: 0 }
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
|
||||
const scan: Scan = { definitions, path, pending: '', pieces: [], source }
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const character = source.charAt(index)
|
||||
if (character === '\\' && source.charAt(index + 1) === '\n') {
|
||||
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
||||
takeRun(run, source, index, index + 2, false)
|
||||
pushNode(run, { type: 'hardBreak' })
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === '\n') {
|
||||
const hard = hardBreakSpaces.test(source.slice(run.undecodedFrom, index))
|
||||
takeRun(run, source, index, index + 1, true)
|
||||
if (hard) pushNode(run, { type: 'hardBreak' })
|
||||
else run.text += ' '
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (character === '`') {
|
||||
const span = readCodeSpan(source, index)
|
||||
if (span === undefined) {
|
||||
index += backtickRun(source, index)
|
||||
continue
|
||||
switch (source.charAt(index)) {
|
||||
case '\\':
|
||||
index = readBackslash(scan, index)
|
||||
break
|
||||
case '\n':
|
||||
index = readLineEnding(scan, index)
|
||||
break
|
||||
case '`':
|
||||
index = readBackticks(scan, index)
|
||||
break
|
||||
case '<': {
|
||||
const angle = readAngle(scan, index)
|
||||
if (!angle.ok) return angle
|
||||
index = angle.value
|
||||
break
|
||||
}
|
||||
takeRun(run, source, index, span.end, false)
|
||||
pushNode(run, { marks: [{ type: 'code' }], text: span.text, type: 'text' })
|
||||
index = span.end
|
||||
continue
|
||||
case '!':
|
||||
case '[':
|
||||
index = openBracket(scan, index)
|
||||
break
|
||||
case ']': {
|
||||
const closed = closeBracket(scan, index)
|
||||
if (!closed.ok) return closed
|
||||
index = closed.value
|
||||
break
|
||||
}
|
||||
case '*':
|
||||
case '_':
|
||||
case '~':
|
||||
index = readDelimiterRun(scan, index)
|
||||
break
|
||||
default:
|
||||
scan.pending += source.charAt(index)
|
||||
index += 1
|
||||
}
|
||||
if (character === '<') {
|
||||
const construct = inlineHtmlConstruct(source, index)
|
||||
if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, path)
|
||||
}
|
||||
index += backslashEscape(source, index) === undefined ? 1 : 2
|
||||
}
|
||||
takeRun(run, source, source.length, source.length, true)
|
||||
pushText(run)
|
||||
return success(run.nodes)
|
||||
flush(scan, true)
|
||||
return assemble(scan)
|
||||
}
|
||||
|
||||
function takeRun(run: Run, source: string, end: number, resume: number, strip: boolean): void {
|
||||
const raw = source.slice(run.undecodedFrom, end)
|
||||
run.text += decodeTextEscapes(strip ? raw.replace(trailingSpace, '') : raw)
|
||||
run.undecodedFrom = resume
|
||||
function readBackslash(scan: Scan, index: number): number {
|
||||
if (scan.source.charAt(index + 1) === '\n') {
|
||||
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
||||
flush(scan, false)
|
||||
pushNode(scan, { type: 'hardBreak' })
|
||||
return index + 2
|
||||
}
|
||||
const width = backslashEscape(scan.source, index) === undefined ? 1 : 2
|
||||
scan.pending += scan.source.slice(index, index + width)
|
||||
return index + width
|
||||
}
|
||||
|
||||
function pushText(run: Run): void {
|
||||
if (run.text !== '') run.nodes.push({ text: run.text, type: 'text' })
|
||||
run.text = ''
|
||||
function readLineEnding(scan: Scan, index: number): number {
|
||||
const hard = hardBreakSpaces.test(scan.pending)
|
||||
flush(scan, true)
|
||||
if (hard) pushNode(scan, { type: 'hardBreak' })
|
||||
else scan.pending = ' '
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function pushNode(run: Run, node: AdfNode): void {
|
||||
pushText(run)
|
||||
run.nodes.push(node)
|
||||
function readBackticks(scan: Scan, index: number): number {
|
||||
const span = readCodeSpan(scan.source, index)
|
||||
if (span === undefined) {
|
||||
const run = backtickRun(scan.source, index)
|
||||
scan.pending += scan.source.slice(index, index + run)
|
||||
return index + run
|
||||
}
|
||||
flush(scan, false)
|
||||
pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' })
|
||||
return span.end
|
||||
}
|
||||
|
||||
function readAngle(scan: Scan, index: number): Result<number> {
|
||||
const autolink = readAutolink(scan.source, index)
|
||||
if (autolink !== undefined) {
|
||||
flush(scan, false)
|
||||
pushNode(scan, autolink.node)
|
||||
return success(index + autolink.length)
|
||||
}
|
||||
const construct = inlineHtmlConstruct(scan.source, index)
|
||||
if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, scan.path)
|
||||
scan.pending += '<'
|
||||
return success(index + 1)
|
||||
}
|
||||
|
||||
function openBracket(scan: Scan, index: number): number {
|
||||
const image = scan.source.charAt(index) === '!'
|
||||
if (image && scan.source.charAt(index + 1) !== '[') {
|
||||
scan.pending += '!'
|
||||
return index + 1
|
||||
}
|
||||
const width = image ? 2 : 1
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ active: true, image, kind: 'open', start: index + width })
|
||||
return index + width
|
||||
}
|
||||
|
||||
function flush(scan: Scan, strip: boolean): void {
|
||||
const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending
|
||||
scan.pending = ''
|
||||
if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] })
|
||||
}
|
||||
|
||||
function pushNode(scan: Scan, node: AdfNode): void {
|
||||
scan.pieces.push({ kind: 'nodes', nodes: [node] })
|
||||
}
|
||||
|
||||
function assemble(scan: Scan): Result<InlineContent> {
|
||||
const only = scan.pieces[0]
|
||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success({ nodes: resolveNodes(scan.pieces) })
|
||||
}
|
||||
|
||||
function holdsImage(pieces: readonly Piece[]): boolean {
|
||||
return pieces.some((piece) => piece.kind === 'image')
|
||||
}
|
||||
|
||||
function readDelimiterRun(scan: Scan, index: number): number {
|
||||
const character = scan.source.charAt(index)
|
||||
const length = runLength(scan.source, index)
|
||||
const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length))
|
||||
if ((character === '~' && length !== 2) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length)
|
||||
else {
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ canClose: flags.canClose, canOpen: flags.canOpen, character, kind: 'run', length })
|
||||
}
|
||||
return index + length
|
||||
}
|
||||
|
||||
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
|
||||
const bracketed = readBracketedAutolink(source, index)
|
||||
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
|
||||
const email = readEmailAutolink(source, index)
|
||||
if (email === undefined) return undefined
|
||||
return { length: email, node: linkedText(source.slice(index + 1, index + email - 1), 'mailto:') }
|
||||
}
|
||||
|
||||
function linkedText(text: string, scheme: string): AdfNode {
|
||||
return { marks: [{ attrs: { href: `${scheme}${text}` }, type: 'link' }], text, type: 'text' }
|
||||
}
|
||||
|
||||
function closeBracket(scan: Scan, index: number): Result<number> {
|
||||
flush(scan, false)
|
||||
const open = lastBracket(scan.pieces)
|
||||
if (open === undefined) return success(literalClose(scan, index))
|
||||
const target = open.bracket.active ? resolveTarget(scan, open.bracket, index) : undefined
|
||||
if (target === undefined) return success(unopened(scan, open, index))
|
||||
const inner = scan.pieces.slice(open.index + 1)
|
||||
if (open.bracket.image) {
|
||||
const closed = closeImage(scan, open.index, inner, target.definition)
|
||||
if (!closed.ok) return closed
|
||||
return success(index + 1 + target.length)
|
||||
}
|
||||
const closed = closeLink(scan, open.index, inner, target.definition)
|
||||
if (!closed.ok) return closed
|
||||
return success(closed.value ? index + 1 + target.length : unopened(scan, open, index))
|
||||
}
|
||||
|
||||
function literalClose(scan: Scan, index: number): number {
|
||||
scan.pending += ']'
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function unopened(scan: Scan, open: { bracket: Bracket; index: number }, index: number): number {
|
||||
scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) }
|
||||
return literalClose(scan, index)
|
||||
}
|
||||
|
||||
function bracketNodes(bracket: Bracket): AdfNode[] {
|
||||
return [{ text: bracket.image ? '![' : '[', type: 'text' }]
|
||||
}
|
||||
|
||||
function lastBracket(pieces: readonly Piece[]): { bracket: Bracket; index: number } | undefined {
|
||||
for (let index = pieces.length - 1; index >= 0; index -= 1) {
|
||||
const piece = pieces[index]
|
||||
if (piece?.kind === 'open') return { bracket: piece, index }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definition: LinkDefinition; length: number } | undefined {
|
||||
const after = index + 1
|
||||
if (scan.source.charAt(after) === '(') {
|
||||
const inline = readInlineTarget(scan.source, after)
|
||||
if (inline !== undefined) return { definition: inline.definition, length: inline.length }
|
||||
}
|
||||
const label = scan.source.charAt(after) === '[' ? readLabel(scan.source, after) : undefined
|
||||
const name = label === undefined || label.value === '' ? scan.source.slice(bracket.start, index) : label.value
|
||||
const definition = scan.definitions.get(normalizeLabel(name))
|
||||
if (definition === undefined) return undefined
|
||||
return { definition, length: label?.length ?? 0 }
|
||||
}
|
||||
|
||||
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text.
|
||||
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
|
||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const nodes = resolveNodes(inner)
|
||||
if (nodes.length === 0) return success(false)
|
||||
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
||||
scan.pieces.length = at
|
||||
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||
for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false
|
||||
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
|
||||
return success(true)
|
||||
}
|
||||
|
||||
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
||||
const alt = imageAlt(inner)
|
||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||
scan.pieces.length = at
|
||||
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
|
||||
return success(null)
|
||||
}
|
||||
|
||||
function imageAlt(inner: readonly Piece[]): string {
|
||||
return resolveNodes(inner)
|
||||
.map((node) => (node.type === 'hardBreak' ? ' ' : (node.text ?? '')))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
|
||||
const nodes = pieces.map(pieceNodes)
|
||||
const runs = delimiterRuns(pieces)
|
||||
const pairings = matchEmphasis(runs)
|
||||
writeUnpaired(nodes, runs, pairings)
|
||||
markPairings(nodes, pairings)
|
||||
return mergeAdjacentText(nodes.flat())
|
||||
}
|
||||
|
||||
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
||||
function pieceNodes(piece: Piece): AdfNode[] {
|
||||
switch (piece.kind) {
|
||||
case 'image':
|
||||
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
|
||||
case 'nodes':
|
||||
return piece.nodes
|
||||
case 'open':
|
||||
return bracketNodes(piece)
|
||||
case 'run':
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function delimiterRuns(pieces: readonly Piece[]): Run[] {
|
||||
const runs: Run[] = []
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
// A run gives its delimiters up from the head closing and the tail opening; what is left between them is text.
|
||||
function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
|
||||
const heads = new Map<Run, number>()
|
||||
const tails = new Map<Run, number>()
|
||||
for (const pairing of pairings) {
|
||||
heads.set(pairing.closer, pairing.closerOffset + pairing.used)
|
||||
tails.set(pairing.opener, pairing.openerOffset)
|
||||
}
|
||||
for (const run of runs) {
|
||||
const head = heads.get(run) ?? 0
|
||||
const tail = tails.get(run) ?? run.length
|
||||
if (tail > head) nodes[run.index] = [{ text: run.character.repeat(tail - head), type: 'text' }]
|
||||
}
|
||||
}
|
||||
|
||||
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
|
||||
function markPairings(nodes: AdfNode[][], pairings: readonly Pairing[]): void {
|
||||
for (const pairing of pairings) {
|
||||
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
||||
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) nodes[index] = applyMark(nodes[index] ?? [], mark)
|
||||
}
|
||||
}
|
||||
|
||||
function markType(character: string, used: number): string {
|
||||
if (character === '~') return 'strike'
|
||||
return used === 2 ? 'strong' : 'em'
|
||||
}
|
||||
|
||||
// A node cannot carry one mark type twice (AGENTS.md §14).
|
||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||
return nodes.map((node) => {
|
||||
const marks = node.marks ?? []
|
||||
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
||||
})
|
||||
}
|
||||
|
||||
function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { holdsControlCharacter, isAsciiPunctuation } from '../commonmark-grammar.ts'
|
||||
|
||||
export type LinkDefinition = { destination: string; title?: string }
|
||||
import type { LinkDefinition, LinkPart } from '../link-syntax.ts'
|
||||
import { normalizeLabel, readDestination, readLabel, readTitle, skipLinkWhitespace } from '../link-syntax.ts'
|
||||
|
||||
type ReadDefinition = { definition: LinkDefinition; label: string; length: number }
|
||||
type ReadValue = { length: number; value: string }
|
||||
|
||||
const bracketedDestination = /^<((?:[^\n<>\\]|\\[^\n])*)>/
|
||||
const label = /^\[((?:[^[\]\\]|\\[\s\S]){1,999})\]:/
|
||||
const restOfLine = /^[ \t]*(?:\n|$)/
|
||||
const titleClosers: Readonly<Record<string, string>> = { '"': '"', "'": "'", '(': ')' }
|
||||
|
||||
export function readLinkDefinitions(definitions: Map<string, LinkDefinition>, text: string): string {
|
||||
let rest = text
|
||||
@@ -22,11 +17,11 @@ export function readLinkDefinitions(definitions: Map<string, LinkDefinition>, te
|
||||
}
|
||||
|
||||
function readDefinition(text: string): ReadDefinition | undefined {
|
||||
const matched = label.exec(text)
|
||||
if (matched === null) return undefined
|
||||
const name = normalizeLabel(matched[1] ?? '')
|
||||
const label = readLabel(text, 0)
|
||||
if (label === undefined || text.charAt(label.length) !== ':') return undefined
|
||||
const name = normalizeLabel(label.value)
|
||||
if (name === '') return undefined
|
||||
const afterLabel = skipSpace(text, matched[0].length)
|
||||
const afterLabel = skipLinkWhitespace(text, label.length + 1)
|
||||
const destination = readDestination(text, afterLabel)
|
||||
if (destination === undefined) return undefined
|
||||
const afterDestination = afterLabel + destination.length
|
||||
@@ -37,15 +32,8 @@ function readDefinition(text: string): ReadDefinition | undefined {
|
||||
return { definition: { destination: destination.value }, label: name, length: plain }
|
||||
}
|
||||
|
||||
function normalizeLabel(raw: string): string {
|
||||
return raw
|
||||
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
|
||||
.replace(/[ \t\n]+/g, ' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function readTitledEnd(text: string, offset: number): ReadValue | undefined {
|
||||
const afterSpace = skipSpace(text, offset)
|
||||
function readTitledEnd(text: string, offset: number): LinkPart | undefined {
|
||||
const afterSpace = skipLinkWhitespace(text, offset)
|
||||
if (afterSpace === offset) return undefined
|
||||
const title = readTitle(text, afterSpace)
|
||||
if (title === undefined) return undefined
|
||||
@@ -53,58 +41,6 @@ function readTitledEnd(text: string, offset: number): ReadValue | undefined {
|
||||
return end === undefined ? undefined : { length: end, value: title.value }
|
||||
}
|
||||
|
||||
function readDestination(text: string, offset: number): ReadValue | undefined {
|
||||
const bracketed = bracketedDestination.exec(text.slice(offset))
|
||||
if (bracketed !== null) return { length: bracketed[0].length, value: bracketed[0].slice(1, -1) }
|
||||
if (text.charAt(offset) === '<') return undefined
|
||||
let depth = 0
|
||||
let index = offset
|
||||
while (index < text.length) {
|
||||
const character = text.charAt(index)
|
||||
if (character === ' ' || holdsControlCharacter(character)) break
|
||||
if (escapesNext(text, index)) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === '(') depth += 1
|
||||
if (character === ')') {
|
||||
depth -= 1
|
||||
if (depth < 0) break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index <= offset ? undefined : { length: index - offset, value: text.slice(offset, index) }
|
||||
}
|
||||
|
||||
function readTitle(text: string, offset: number): ReadValue | undefined {
|
||||
const opener = text.charAt(offset)
|
||||
const closer = titleClosers[opener]
|
||||
if (closer === undefined) return undefined
|
||||
let index = offset + 1
|
||||
while (index < text.length) {
|
||||
const character = text.charAt(index)
|
||||
if (escapesNext(text, index)) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === closer) return { length: index + 1 - offset, value: text.slice(offset + 1, index) }
|
||||
if (character === opener) return undefined
|
||||
index += 1
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A backslash escapes ASCII punctuation only, so a line ending always ends the destination it follows.
|
||||
function escapesNext(text: string, index: number): boolean {
|
||||
return text.charAt(index) === '\\' && isAsciiPunctuation(text.charAt(index + 1))
|
||||
}
|
||||
|
||||
// The label, the destination and the title each take at most one line ending with them.
|
||||
function skipSpace(text: string, offset: number): number {
|
||||
const rest = text.slice(offset)
|
||||
return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length
|
||||
}
|
||||
|
||||
function endOfLine(text: string, offset: number): number | undefined {
|
||||
const rest = restOfLine.exec(text.slice(offset))?.[0]
|
||||
return rest === undefined ? undefined : offset + rest.length
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { markdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strike: AdfMark = { type: 'strike' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
|
||||
function code(result: Result<AdfDocument>): string {
|
||||
return result.ok ? `built ${JSON.stringify(result.value)}` : result.error.code
|
||||
}
|
||||
@@ -49,6 +53,19 @@ function quote(...content: AdfNode[]): AdfNode {
|
||||
return content.length === 0 ? { type: 'blockquote' } : { content, type: 'blockquote' }
|
||||
}
|
||||
|
||||
function marked(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function link(href: string, title?: string): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
function image(url: string, alt?: string): AdfNode {
|
||||
const media: AdfNode = { attrs: alt === undefined ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
return { attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }
|
||||
}
|
||||
|
||||
test('builds an empty document from input holding no block', () => {
|
||||
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
|
||||
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
|
||||
@@ -323,3 +340,142 @@ test('leaves the angle bracket that opens no HTML construct to the text it sits
|
||||
assert.deepEqual(content(markdownToAdf('`<span>`\n')), [{ content: [codeSpan('<span>')], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('\\<span>\n')), [paragraph('<span>')])
|
||||
})
|
||||
|
||||
test('reads the emphasis CommonMark matches, the marks nesting outermost first', () => {
|
||||
assert.deepEqual(content(markdownToAdf('*a*\n')), [{ content: [marked('a', em)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('_a_\n')), [{ content: [marked('a', em)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('**a**\n')), [{ content: [marked('a', strong)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('__a__\n')), [{ content: [marked('a', strong)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('***a***\n')), [{ content: [marked('a', em, strong)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('*a **b** c*\n')), [
|
||||
{ content: [marked('a ', em), marked('b', em, strong), marked(' c', em)], type: 'paragraph' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('a*b*c\n')), [{ content: [text('a'), marked('b', em), text('c')], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('# *a*\n')), [{ attrs: { level: 1 }, content: [marked('a', em)], type: 'heading' }])
|
||||
})
|
||||
|
||||
test('leaves a delimiter run CommonMark pairs with nothing in the text it sits in', () => {
|
||||
assert.deepEqual(content(markdownToAdf('a_b_c\n')), [paragraph('a_b_c')])
|
||||
assert.deepEqual(content(markdownToAdf('*a\n')), [paragraph('*a')])
|
||||
assert.deepEqual(content(markdownToAdf('a * b\n')), [paragraph('a * b')])
|
||||
assert.deepEqual(content(markdownToAdf('**a*\n')), [{ content: [text('*'), marked('a', em)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('*a**\n')), [{ content: [marked('a', em), text('*')], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('\\*a\\*\n')), [paragraph('*a*')])
|
||||
assert.deepEqual(content(markdownToAdf('`*a*`\n')), [{ content: [codeSpan('*a*')], type: 'paragraph' }])
|
||||
})
|
||||
|
||||
test('reads two tildes as strike, a single tilde and a longer run literal', () => {
|
||||
assert.deepEqual(content(markdownToAdf('~~a~~\n')), [{ content: [marked('a', strike)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('a ~b~ c\n')), [paragraph('a ~b~ c')])
|
||||
assert.deepEqual(content(markdownToAdf('a ~~~b~~~ c\n')), [paragraph('a ~~~b~~~ c')])
|
||||
assert.deepEqual(content(markdownToAdf('~~a **b**~~\n')), [{ content: [marked('a ', strike), marked('b', strike, strong)], type: 'paragraph' }])
|
||||
})
|
||||
|
||||
test('reads an inline link, its destination and title', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a](/url)\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/url "t")\n')), [{ content: [marked('a', link('/url', 't'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](\n/url\n"t" )\n')), [{ content: [marked('a', link('/url', 't'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](<u v>)\n')), [{ content: [marked('a', link('u v'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a]()\n')), [{ content: [marked('a', link(''))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/x(y))\n')), [{ content: [marked('a', link('/x(y)'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[**a**](/u)\n')), [{ content: [marked('a', link('/u'), strong)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a `b`](/u)\n')), [
|
||||
{ content: [marked('a ', link('/u')), { marks: [link('/u'), { type: 'code' }], text: 'b', type: 'text' }], type: 'paragraph' },
|
||||
])
|
||||
})
|
||||
|
||||
test('decodes the escapes and the references a destination and a title hold', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a](/x\\)y)\n')), [{ content: [marked('a', link('/x)y'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/u "He said \\"hi\\"")\n')), [{ content: [marked('a', link('/u', 'He said "hi"'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/x&y)\n')), [{ content: [marked('a', link('/x&y'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /x\\)y "He said \\"hi\\""\n')), [
|
||||
{ content: [marked('a', link('/x)y', 'He said "hi"'))], type: 'paragraph' },
|
||||
])
|
||||
})
|
||||
|
||||
test('leaves the bracket pair no link parses as the text it holds', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a\n')), [paragraph('[a')])
|
||||
assert.deepEqual(content(markdownToAdf('[a] (/u)\n')), [paragraph('[a] (/u)')])
|
||||
assert.deepEqual(content(markdownToAdf('a ] b\n')), [paragraph('a ] b')])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/u\n')), [paragraph('[a](/u')])
|
||||
assert.deepEqual(content(markdownToAdf('[a](/u x)\n')), [paragraph('[a](/u x)')])
|
||||
assert.deepEqual(content(markdownToAdf('[a](<u\n')), [paragraph('[a](<u')])
|
||||
assert.deepEqual(content(markdownToAdf('![a\n')), [paragraph('![a')])
|
||||
assert.deepEqual(content(markdownToAdf('[a [b](/u) c](/v)\n')), [
|
||||
{ content: [text('[a '), marked('b', link('/u')), text(' c](/v)')], type: 'paragraph' },
|
||||
])
|
||||
})
|
||||
|
||||
test('reads the reference links a definition resolves, and leaves the rest literal', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[Foo\nBar][]\n\n[foo bar]: /url\n')), [{ content: [marked('Foo Bar', link('/url'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('[a][z]\n\n[a]: /url\n')), [paragraph('[a][z]')])
|
||||
assert.deepEqual(content(markdownToAdf('[a]\n')), [paragraph('[a]')])
|
||||
assert.deepEqual(content(markdownToAdf('[][]\n')), [paragraph('[][]')])
|
||||
})
|
||||
|
||||
test('reads an autolink, the email form as the mailto link it means', () => {
|
||||
assert.deepEqual(content(markdownToAdf('<https://example.com/>\n')), [
|
||||
{ content: [marked('https://example.com/', link('https://example.com/'))], type: 'paragraph' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('a <a@b.example.com> c\n')), [
|
||||
{ content: [text('a '), marked('a@b.example.com', link('mailto:a@b.example.com')), text(' c')], type: 'paragraph' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('a <a@b-c.example.com> d\n')), [
|
||||
{ content: [text('a '), marked('a@b-c.example.com', link('mailto:a@b-c.example.com')), text(' d')], type: 'paragraph' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('a <b@-c.example.com> d\n')), [paragraph('a <b@-c.example.com> d')])
|
||||
assert.deepEqual(content(markdownToAdf('a <b[c@example.com> d\n')), [paragraph('a <b[c@example.com> d')])
|
||||
assert.deepEqual(content(markdownToAdf('<https://example.com/?a=\\*>\n')), [
|
||||
{ content: [marked('https://example.com/?a=\\*', link('https://example.com/?a=\\*'))], type: 'paragraph' },
|
||||
])
|
||||
assert.equal(code(markdownToAdf('<https://example.com/> <span>\n')), 'unmappable-html')
|
||||
})
|
||||
|
||||
test('reads a lone image as the media the flavour spells for it', () => {
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [
|
||||
image('https://example.com/moon.png', 'The moon'),
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u')])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a')])
|
||||
assert.deepEqual(content(markdownToAdf('- \n')), [bulletList(item(image('/u', 'a')))])
|
||||
})
|
||||
|
||||
test('flattens the description of a lone image to the plain text alt holds', () => {
|
||||
assert.deepEqual(content(markdownToAdf(' c](/v)\n')), [image('/v', 'a b c')])
|
||||
assert.deepEqual(content(markdownToAdf(' d](/e)\n')), [image('/e', 'a b d')])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a b')])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a b')])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a b')])
|
||||
})
|
||||
|
||||
test('leaves the brackets of an empty link text the text they are', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[](/u)\n')), [paragraph('[](/u)')])
|
||||
assert.deepEqual(content(markdownToAdf('a [](/u) b\n')), [paragraph('a [](/u) b')])
|
||||
// The pair gives the label back the way an unresolved one does, so the shortcut behind it still reads.
|
||||
assert.deepEqual(content(markdownToAdf('[][r]\n\n[r]: /u\n')), [{ content: [text('[]'), marked('r', link('/u'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('\n')), [image('/u')])
|
||||
})
|
||||
|
||||
test('refuses the image no ADF node carries where it sits', () => {
|
||||
assert.equal(content(markdownToAdf('\n')), 'unmappable-image: no media node carries a link title')
|
||||
assert.equal(content(markdownToAdf('See .\n')), 'unmappable-image: an image fits only as a paragraph of its own')
|
||||
assert.equal(code(markdownToAdf('# \n')), 'unmappable-image')
|
||||
assert.equal(code(markdownToAdf('**\n')), 'unmappable-image')
|
||||
assert.equal(code(markdownToAdf('[](/v)\n')), 'unmappable-image')
|
||||
assert.equal(code(markdownToAdf('\n')), 'unmappable-image')
|
||||
assert.equal(code(markdownToAdf(' d\n')), 'unmappable-image')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n\nSee .\n')), ['content', 1])
|
||||
assert.deepEqual(content(markdownToAdf('![a]\n')), [paragraph('![a]')])
|
||||
assert.deepEqual(content(markdownToAdf('a ! b\n')), [paragraph('a ! b')])
|
||||
})
|
||||
|
||||
test('carries the mark a spelling nested inside its own kind names once', () => {
|
||||
assert.deepEqual(content(markdownToAdf('*(*a*)*\n')), [{ content: [marked('(a)', em)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf(`${'*'.repeat(600)}a${'*'.repeat(600)}\n`)), [{ content: [marked('a', strong)], type: 'paragraph' }])
|
||||
assert.deepEqual(content(markdownToAdf('*a **b** c*\n')), [
|
||||
{ content: [marked('a ', em), marked('b', em, strong), marked(' c', em)], type: 'paragraph' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,60 +1,62 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block, ClaimedConstruct } from './blocks.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
const content = blockNodes(parseBlocks(markdown).blocks, [], 0)
|
||||
const parsed = parseBlocks(markdown)
|
||||
const content = blockNodes(parsed.blocks, parsed.definitions, [], 0)
|
||||
if (!content.ok) return content
|
||||
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
|
||||
}
|
||||
|
||||
function blockNodes(blocks: readonly Block[], path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const node = blockNode(block, [...path, 'content', index], depth)
|
||||
const node = blockNode(block, definitions, [...path, 'content', index], depth)
|
||||
if (!node.ok) return node
|
||||
content.push(node.value)
|
||||
}
|
||||
return success(content)
|
||||
}
|
||||
|
||||
function blockNode(block: Block, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
switch (block.kind) {
|
||||
case 'blockquote':
|
||||
return containerNode({ type: 'blockquote' }, block.blocks, path, depth)
|
||||
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth)
|
||||
case 'bulletList':
|
||||
return listNode({ type: 'bulletList' }, block.items, path, depth)
|
||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
|
||||
case 'claim':
|
||||
return claimFailure(block.construct, path)
|
||||
case 'code':
|
||||
return success(codeBlockNode(block.language, block.text))
|
||||
case 'heading':
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, path)
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path)
|
||||
case 'html':
|
||||
return failure('unmappable-html', `no ADF node carries ${block.construct}`, path)
|
||||
case 'orderedList':
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, path, depth)
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth)
|
||||
case 'paragraph':
|
||||
return contentNode({ type: 'paragraph' }, block.text, path)
|
||||
return contentNode({ type: 'paragraph' }, block.text, definitions, path)
|
||||
case 'rule':
|
||||
return success({ type: 'rule' })
|
||||
}
|
||||
}
|
||||
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content = blockNodes(blocks, path, depth + 1)
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content = blockNodes(blocks, definitions, path, depth + 1)
|
||||
if (!content.ok) return content
|
||||
return success(content.value.length === 0 ? node : { ...node, content: content.value })
|
||||
}
|
||||
|
||||
function listNode(node: AdfNode, items: readonly Block[][], path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, blocks] of items.entries()) {
|
||||
const item = containerNode({ type: 'listItem' }, blocks, [...path, 'content', index], depth)
|
||||
const item = containerNode({ type: 'listItem' }, blocks, definitions, [...path, 'content', index], depth)
|
||||
if (!item.ok) return item
|
||||
content.push(item.value)
|
||||
}
|
||||
@@ -75,8 +77,13 @@ function codeBlockNode(language: string, text: string): AdfNode {
|
||||
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }
|
||||
}
|
||||
|
||||
function contentNode(node: AdfNode, text: string, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, path)
|
||||
// spec/flavour.md, The CommonMark image: only a paragraph gives an image the block it needs.
|
||||
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, definitions, path)
|
||||
if (!content.ok) return content
|
||||
return success(content.value.length === 0 ? node : { ...node, content: content.value })
|
||||
const image = content.value.image
|
||||
if (image !== undefined) {
|
||||
return node.type === 'paragraph' ? success(image) : failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
||||
}
|
||||
return success(content.value.nodes.length === 0 ? node : { ...node, content: content.value.nodes })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user