Read the leaf blocks, ahead of any inline parsing
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 23:23:29 +02:00
parent 90632c6711
commit 5fc4272155
19 changed files with 681 additions and 2 deletions
+37
View File
@@ -7,9 +7,12 @@ import { fileURLToPath } from 'node:url'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors')
const normalizationRoot = join(corpusRoot, 'normalization')
const roundTripRoot = join(corpusRoot, 'round-trip')
const unspellableRoot = join(corpusRoot, 'unspellable')
@@ -162,6 +165,40 @@ for (const name of names(unspellableRoot, '.json')) {
})
}
test('normalization pairs every .md with a .json', () => {
assert.deepEqual(names(normalizationRoot, '.md'), names(normalizationRoot, '.json'))
})
test('normalization holds fixtures', () => {
assert.ok(names(normalizationRoot, '.md').length > 0)
})
for (const name of names(normalizationRoot, '.md')) {
test(`normalization/${name} parses to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
})
}
test('errors pairs every .md with an .error', () => {
assert.deepEqual(names(errorsRoot, '.md'), names(errorsRoot, '.error'))
})
test('errors holds fixtures', () => {
assert.ok(names(errorsRoot, '.md').length > 0)
})
for (const name of names(errorsRoot, '.md')) {
test(`errors/${name} is refused with the error it names`, () => {
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8'))
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
})
}
test('the corpus holds JSON to gate', () => {
assert.ok(corpusJsonPaths().length > 0)
})
+45 -2
View File
@@ -12,17 +12,43 @@ const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const entityReference = new RegExp(entityReferenceSource)
const nullCharacter = new RegExp(nullCharacterSource)
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
const codeFenceOpener = /^(`{3,}|~{3,})/
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
const pipeClaim = /^\|/
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip.
const firstCharacterOpeners = [atxHeadingOpener, /^>/, /^[*+-](?:[ \t]|$)/, codeFenceOpener, /^:{2,}/, pipeClaim]
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
const setextUnderline = /^(?:=+|-+)$/
const setextUnderline = /^(=+|-+)[ \t]*$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
export function atxHeading(line: string): { level: number; text: string } | undefined {
const hashes = atxHeadingOpener.exec(line)?.[1]
if (hashes === undefined) return undefined
const text = trimSpace(line.slice(hashes.length))
return { level: hashes.length, text: trimSpace(text.replace(/(?:^|(?<=[ \t]))#+$/, '')) }
}
export function claimsDirectiveLine(line: string): boolean {
return directiveClaim.test(line)
}
export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
}
export function claimsPipeLine(line: string): boolean {
return pipeClaim.test(line)
}
export function closingCodeFence(line: string, marker: string): boolean {
const closing = codeFenceOpener.exec(line)?.[1]
if (closing === undefined || closing.charAt(0) !== marker.charAt(0) || closing.length < marker.length) return false
return /^[ \t]*$/.test(line.slice(closing.length))
}
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
if (offset === 0) {
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
@@ -60,6 +86,13 @@ export function isUnicodeWhitespace(character: string): boolean {
return unicodeWhitespace.test(character)
}
export function openingCodeFence(line: string): { info: string; marker: string } | undefined {
const marker = codeFenceOpener.exec(line)?.[1]
if (marker === undefined) return undefined
const info = trimSpace(line.slice(marker.length))
return marker.startsWith('`') && info.includes('`') ? undefined : { info, marker }
}
export function opensBracketedAutolink(text: string): boolean {
return bracketedAutolink.test(text)
}
@@ -68,6 +101,16 @@ export function opensHtmlConstruct(text: string): boolean {
return htmlConstructs.some((construct) => construct.test(text))
}
export function setextHeadingLevel(line: string): number | undefined {
const underline = setextUnderline.exec(line)?.[1]
if (underline === undefined) return undefined
return underline.startsWith('=') ? 1 : 2
}
export function startsEntityReference(text: string): boolean {
return anchoredEntityReference.test(text)
}
function trimSpace(text: string): string {
return text.replace(/^[ \t]+|[ \t]+$/g, '')
}
+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { LinkDefinition } from './link-reference-definitions.ts'
import type { ParsedBlocks } from './blocks.ts'
import { parseBlocks } from './blocks.ts'
function walk(markdown: string): ParsedBlocks {
const result = parseBlocks(markdown)
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
return result.value
}
function definitions(markdown: string): [string, LinkDefinition][] {
return [...walk(markdown).definitions]
}
function kinds(markdown: string): string[] {
return walk(markdown).blocks.map((block) => block.kind)
}
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
assert.deepEqual(definitions('[a]: /url\n'), [['a', { destination: '/url' }]])
assert.deepEqual(definitions('[Foo Bar]:\n<the url>\n"Title"\n'), [['foo bar', { destination: 'the url', title: 'Title' }]])
assert.deepEqual(definitions("[a]: /url 'One'\n[a]: /other (Two)\n[b]: /b\n"), [
['a', { destination: '/url', title: 'One' }],
['b', { destination: '/b' }],
])
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]: <>\n'), [['a', { destination: '' }]])
assert.deepEqual(definitions('[a]: /url "He said \\"hi\\""\n'), [['a', { destination: '/url', title: 'He said \\"hi\\"' }]])
})
test('leaves the paragraph a line no definition spells', () => {
assert.deepEqual(definitions('[]: /url\n'), [])
assert.deepEqual(definitions('[a]: <un>closed>\n'), [])
assert.deepEqual(definitions('[a]: <unclosed\n'), [])
assert.deepEqual(definitions('[a]: /url)x\n'), [])
assert.deepEqual(definitions('[a]: /url "One" and more\n'), [])
assert.deepEqual(definitions('[a]:\n'), [])
assert.deepEqual(definitions('[a]: /url "unclosed\n'), [])
assert.deepEqual(kinds('[a]: /url\nPart.\n'), ['paragraph'])
})
test('swallows an HTML block to the end condition its start sets', () => {
assert.deepEqual(kinds('<div>\nx\n\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<!--\n:::\n-->\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<pre>x</pre>\nPart.\n'), ['html', 'paragraph'])
assert.deepEqual(kinds('<div>\nx\n'), ['html'])
assert.deepEqual(kinds('Part.\n<div>\n'), ['paragraph', 'html'])
})
+180
View File
@@ -0,0 +1,180 @@
import type { LinkDefinition } from './link-reference-definitions.ts'
import type { OpenHtmlBlock } from './html-blocks.ts'
import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts'
import { failure, success, type Result } from '../../result.ts'
import { openingHtmlBlock } from './html-blocks.ts'
import { readLinkDefinitions } from './link-reference-definitions.ts'
export type LeafBlock =
| { kind: 'code'; language: string; text: string }
| { kind: 'heading'; level: number; text: string }
| { kind: 'html'; name: string }
| { kind: 'paragraph'; text: string }
| { kind: 'rule' }
export type ParsedBlocks = { blocks: LeafBlock[]; definitions: Map<string, LinkDefinition> }
type Walk = ParsedBlocks & { paragraph: string[] }
const blankLine = /^[ \t]*$/
const indentedCodeColumns = 4
const largestOpenerIndentation = 3
const tabStop = 4
export function parseBlocks(markdown: string): Result<ParsedBlocks> {
const lines = normalizeInput(markdown).split('\n')
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] }
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (blankLine.test(line)) {
closeParagraph(walk)
continue
}
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
index = readIndentedCode(walk, lines, index - 1)
continue
}
const opener = removeColumns(line, largestOpenerIndentation)
const claim = claimedLine(walk, opener)
if (claim !== undefined) return claim
if (readLineBlock(walk, opener)) continue
const fence = openingCodeFence(opener)
if (fence !== undefined) {
closeParagraph(walk)
index = readFencedCode(walk, lines, index, fence, leadingColumns(line))
continue
}
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
if (html !== undefined) {
closeParagraph(walk)
index = readHtmlBlock(walk, lines, index - 1, html)
continue
}
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
}
closeParagraph(walk)
return success({ blocks: walk.blocks, definitions: walk.definitions })
}
// The document's last line ending closes its line rather than opening an empty one.
function normalizeInput(markdown: string): string {
return markdown
.replace(/\r\n?/g, '\n')
.replaceAll('\u0000', '\ufffd')
.replace(/\n$/, '')
}
function claimedLine(walk: Walk, opener: string): Result<ParsedBlocks> | undefined {
const directive = claimsDirectiveLine(opener)
if (!directive && !claimsPipeLine(opener)) return undefined
closeParagraph(walk)
const path = ['content', walk.blocks.length]
if (directive) return failure('malformed-directive', 'the line claims a directive and parses as none', path)
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
}
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
function readLineBlock(walk: Walk, opener: string): boolean {
const level = walk.paragraph.length === 0 ? undefined : setextHeadingLevel(opener)
if (level !== undefined) {
const text = takeParagraph(walk)
if (text !== undefined) {
walk.blocks.push({ kind: 'heading', level, text })
return true
}
}
if (isThematicBreak(opener)) {
closeParagraph(walk)
walk.blocks.push({ kind: 'rule' })
return true
}
const heading = atxHeading(opener)
if (heading === undefined) return false
closeParagraph(walk)
walk.blocks.push({ kind: 'heading', level: heading.level, text: heading.text })
return true
}
function readFencedCode(walk: Walk, lines: readonly string[], start: number, fence: { info: string; marker: string }, indentation: number): number {
const collected: string[] = []
let index = start
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (closingCodeFence(removeColumns(line, largestOpenerIndentation), fence.marker)) break
collected.push(removeColumns(line, indentation))
}
walk.blocks.push({ kind: 'code', language: fence.info, text: collected.join('\n') })
return index
}
function readIndentedCode(walk: Walk, lines: readonly string[], start: number): number {
const collected: string[] = []
const held: string[] = []
let index = start
let end = start
while (index < lines.length) {
const line = lines[index] ?? ''
index += 1
if (blankLine.test(line)) {
held.push(removeColumns(line, indentedCodeColumns))
continue
}
if (leadingColumns(line) < indentedCodeColumns) break
collected.push(...held, removeColumns(line, indentedCodeColumns))
held.length = 0
end = index
}
walk.blocks.push({ kind: 'code', language: '', text: collected.join('\n') })
return end
}
function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html: OpenHtmlBlock): number {
let index = start
while (index < lines.length) {
const line = lines[index] ?? ''
if (html.closer === undefined && blankLine.test(line)) break
index += 1
if (html.closer !== undefined && html.closer.test(line)) break
}
walk.blocks.push({ kind: 'html', name: html.name })
return index
}
function closeParagraph(walk: Walk): void {
const text = takeParagraph(walk)
if (text !== undefined) walk.blocks.push({ kind: 'paragraph', text })
}
function takeParagraph(walk: Walk): string | undefined {
const text = readLinkDefinitions(walk.definitions, walk.paragraph.join('\n'))
walk.paragraph = []
return text === '' ? undefined : text
}
function leadingColumns(line: string): number {
let columns = 0
for (const character of line) {
if (character === ' ') columns += 1
else if (character === '\t') columns += tabStop - (columns % tabStop)
else break
}
return columns
}
// CommonMark's tab stops: a tab the cut splits gives the columns it holds past the cut back as spaces.
function removeColumns(line: string, columns: number): string {
let removed = 0
let index = 0
while (removed < columns && index < line.length) {
const character = line.charAt(index)
if (character !== ' ' && character !== '\t') break
const width = character === ' ' ? 1 : tabStop - (removed % tabStop)
index += 1
if (removed + width > columns) return ' '.repeat(removed + width - columns) + line.slice(index)
removed += width
}
return line.slice(index)
}
+28
View File
@@ -0,0 +1,28 @@
export type OpenHtmlBlock = { closer: RegExp | undefined; name: string }
type HtmlBlockCondition = { closer: RegExp | undefined; interrupts: boolean; name: string | undefined; start: RegExp }
// CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists.
const blockTagNames =
'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul'
const attributeSource = '(?:[ \\t]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \\t]*=[ \\t]*(?:[^ \\t"\'=<>`]+|\'[^\']*\'|"[^"]*"))?)'
const completeTag = new RegExp(`^(?:<[A-Za-z][A-Za-z0-9-]*${attributeSource}*[ \\t]*/?>|</[A-Za-z][A-Za-z0-9-]*[ \\t]*>)[ \\t]*$`)
const tagName = /^<\/?([A-Za-z][A-Za-z0-9-]*).*$/
const conditions: HtmlBlockCondition[] = [
{ closer: /<\/(?:pre|script|style|textarea)>/i, interrupts: true, name: undefined, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
{ closer: /-->/, interrupts: true, name: 'an HTML comment', start: /^<!--/ },
{ closer: /\?>/, interrupts: true, name: 'an HTML processing instruction', start: /^<\?/ },
{ closer: />/, interrupts: true, name: 'an HTML declaration', start: /^<![A-Za-z]/ },
{ closer: /\]\]>/, interrupts: true, name: 'a CDATA section', start: /^<!\[CDATA\[/ },
{ closer: undefined, interrupts: true, name: undefined, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
{ closer: undefined, interrupts: false, name: undefined, start: completeTag },
]
export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlBlock | undefined {
for (const condition of conditions) {
if ((interrupting && !condition.interrupts) || !condition.start.test(line)) continue
return { closer: condition.closer, name: condition.name ?? line.replace(tagName, '<$1>') }
}
return undefined
}
@@ -0,0 +1,100 @@
import { holdsControlCharacter } from '../commonmark-grammar.ts'
export type LinkDefinition = { destination: string; title?: string }
type ReadDefinition = { definition: LinkDefinition; label: string; length: number }
type ReadValue = { length: number; value: string }
const bracketedDestination = /^<((?:[^\n<>\\]|\\[\s\S])*)>/
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
let read = readDefinition(rest)
while (read !== undefined) {
if (!definitions.has(read.label)) definitions.set(read.label, read.definition)
rest = rest.slice(read.length)
read = readDefinition(rest)
}
return rest
}
function readDefinition(text: string): ReadDefinition | undefined {
const matched = label.exec(text)
if (matched === null) return undefined
const raw = matched[1] ?? ''
if (raw.trim() === '') return undefined
const afterLabel = skipSpace(text, matched[0].length)
const destination = readDestination(text, afterLabel)
if (destination === undefined) return undefined
const afterDestination = afterLabel + destination.length
const name = raw.replace(/[ \t\n]+/g, ' ').trim().toLowerCase()
const titled = readTitledEnd(text, afterDestination)
if (titled !== undefined) return { definition: { destination: destination.value, title: titled.value }, label: name, length: titled.length }
const plain = endOfLine(text, afterDestination)
if (plain === undefined) return undefined
return { definition: { destination: destination.value }, label: name, length: plain }
}
function readTitledEnd(text: string, offset: number): ReadValue | undefined {
const afterSpace = skipSpace(text, offset)
if (afterSpace === offset) return undefined
const title = readTitle(text, afterSpace)
if (title === undefined) return undefined
const end = endOfLine(text, afterSpace + title.length)
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 (character === '\\') {
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 (character === '\\') {
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
}
// 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
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Result } from '../../result.ts'
import { markdownToAdf } from './markdown-to-adf.ts'
function code(result: Result<AdfDocument>): string {
return result.ok ? `built ${JSON.stringify(result.value)}` : result.error.code
}
function content(result: Result<AdfDocument>): AdfNode[] | string {
return result.ok ? (result.value.content ?? []) : `${result.error.code}: ${result.error.message}`
}
function path(result: Result<AdfDocument>): readonly (number | string)[] {
return result.ok ? ['built'] : result.error.path
}
function text(value: string): AdfNode {
return { text: value, type: 'text' }
}
function paragraph(value: string): AdfNode {
return { content: [text(value)], type: 'paragraph' }
}
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')), [])
})
test('builds one paragraph from the lines a blank line does not part', () => {
assert.deepEqual(content(markdownToAdf('One\ntwo.\n\nThree.\n')), [paragraph('One two.'), paragraph('Three.')])
})
test('reads an ATX heading and its level', () => {
assert.deepEqual(content(markdownToAdf('# Assembly\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('###### M8\n')), [{ attrs: { level: 6 }, content: [text('M8')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf(' ## Parts ##\n')), [{ attrs: { level: 2 }, content: [text('Parts')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('#\n')), [{ attrs: { level: 1 }, type: 'heading' }])
assert.deepEqual(content(markdownToAdf('####### Seven\n')), [paragraph('####### Seven')])
assert.deepEqual(content(markdownToAdf('#hashtag\n')), [paragraph('#hashtag')])
})
test('reads a setext underline as the heading level it spells', () => {
assert.deepEqual(content(markdownToAdf('Assembly\n===\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('One\ntwo\n-\n')), [{ attrs: { level: 2 }, content: [text('One two')], type: 'heading' }])
assert.deepEqual(content(markdownToAdf('===\n')), [paragraph('===')])
})
test('reads a thematic break, the dashed one only where no paragraph is open', () => {
assert.deepEqual(content(markdownToAdf('---\n')), [{ type: 'rule' }])
assert.deepEqual(content(markdownToAdf('Part.\n\n * * *\n')), [paragraph('Part.'), { type: 'rule' }])
assert.deepEqual(content(markdownToAdf('___\n')), [{ type: 'rule' }])
assert.deepEqual(content(markdownToAdf('Part.\n***\n')), [paragraph('Part.'), { type: 'rule' }])
assert.deepEqual(content(markdownToAdf('Part.\n---\n')), [{ attrs: { level: 2 }, content: [text('Part.')], type: 'heading' }])
})
test('reads a fenced code block, its info string the language', () => {
assert.deepEqual(content(markdownToAdf('```sql\nSELECT id\nFROM part\n```\n')), [
{ attrs: { language: 'sql' }, content: [text('SELECT id\nFROM part')], type: 'codeBlock' },
])
assert.deepEqual(content(markdownToAdf('```\nx\n```\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('``` rust \nx\n```\n')), [{ attrs: { language: 'rust' }, content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('```\n```\n')), [{ type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('```\nx\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('````\n```\n````\n')), [{ content: [text('```')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('~~~ a`b\n```\n~~~\n')), [{ attrs: { language: 'a`b' }, content: [text('```')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('``` a`b\n')), [paragraph('``` a`b')])
assert.deepEqual(content(markdownToAdf('```\n``` x\n```\n')), [{ content: [text('``` x')], type: 'codeBlock' }])
})
test('strips the opening fence indentation from the content lines it holds', () => {
assert.deepEqual(content(markdownToAdf(' ```\n x\n y\n ```\n')), [{ content: [text(' x\ny')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' ```\n\tx\n ```\n')), [{ content: [text(' x')], type: 'codeBlock' }])
})
test('reads an indented code block where no paragraph is open', () => {
assert.deepEqual(content(markdownToAdf(' SELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('\tSELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' x\n')), [{ content: [text(' x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf(' a\n\n b\n\nPart.\n')), [{ content: [text('a\n\nb')], type: 'codeBlock' }, paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')])
})
test('claims a block-level colon run with no directive to parse it', () => {
assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive')
assert.equal(code(markdownToAdf('::panel\n')), 'malformed-directive')
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'malformed-directive')
assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')])
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
})
test('claims a block-level pipe with no table to parse it', () => {
assert.equal(code(markdownToAdf('| Part | Qty |\n')), 'malformed-pipe-table')
assert.deepEqual(content(markdownToAdf('\\| Part\n')), [paragraph('\\| Part')])
})
test('refuses the raw HTML no element mapping carries', () => {
assert.equal(code(markdownToAdf('<!-- note -->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\nx\n</div>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<?php ?>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<!DOCTYPE html>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<![CDATA[x]]>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<pre>\nx\n</pre>\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<span foo="bar">\n')), 'unmappable-html')
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1])
})
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
assert.equal(code(markdownToAdf('<!--\n:::\n-->\n')), 'unmappable-html')
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
})
test('leaves a tag that opens no HTML block to the paragraph it sits in', () => {
assert.deepEqual(content(markdownToAdf('Part.\n<span>\n')), [paragraph('Part. <span>')])
assert.deepEqual(content(markdownToAdf('3 < 4\n')), [paragraph('3 < 4')])
})
test('gives up the link reference definitions a paragraph opens with', () => {
assert.deepEqual(content(markdownToAdf('[a]: /url\n')), [])
assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('[a]: /url\n"Title"\n\nPart.\n')), [paragraph('Part.')])
assert.deepEqual(content(markdownToAdf('[a]: /url and more\n')), [paragraph('[a]: /url and more')])
assert.deepEqual(content(markdownToAdf('Part.\n[a]: /url\n')), [paragraph('Part. [a]: /url')])
assert.deepEqual(content(markdownToAdf('[a]: /url\n===\n')), [paragraph('===')])
})
test('normalizes the line endings and the null character CommonMark replaces', () => {
assert.deepEqual(content(markdownToAdf('One\r\ntwo.\r\n')), [paragraph('One two.')])
assert.deepEqual(content(markdownToAdf('```\r\nx\r\n```\r\n')), [{ content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('a\u0000b\n')), [paragraph('a\ufffdb')])
})
+42
View File
@@ -0,0 +1,42 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { LeafBlock } from './blocks.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { parseBlocks } from './blocks.ts'
export function markdownToAdf(markdown: string): Result<AdfDocument> {
const parsed = parseBlocks(markdown)
if (!parsed.ok) return parsed
const content: AdfNode[] = []
for (const [index, block] of parsed.value.blocks.entries()) {
const node = blockNode(block, ['content', index])
if (!node.ok) return node
content.push(node.value)
}
return success(content.length === 0 ? { type: 'doc', version: 1 } : { content, type: 'doc', version: 1 })
}
function blockNode(block: LeafBlock, path: ConvertErrorPath): Result<AdfNode> {
if (block.kind === 'code') return success(codeBlockNode(block.language, block.text))
if (block.kind === 'heading') return success(withContent({ attrs: { level: block.level }, type: 'heading' }, block.text))
if (block.kind === 'html') return failure('unmappable-html', `no element mapping carries ${block.name}`, path)
if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text))
return success({ type: 'rule' })
}
function codeBlockNode(language: string, text: string): AdfNode {
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }
}
function withContent(node: AdfNode, text: string): AdfNode {
const content = inlineContent(text)
return content.length === 0 ? node : { ...node, content }
}
function inlineContent(text: string): AdfNode[] {
const line = text
.split('\n')
.map((part) => part.trim())
.join(' ')
return line === '' ? [] : [{ text: line, type: 'text' }]
}
+3
View File
@@ -1,5 +1,8 @@
export type ConvertErrorCode =
| 'malformed-directive'
| 'malformed-pipe-table'
| 'not-an-adf-document'
| 'unmappable-html'
| 'unspellable-adjacent-lists'
| 'unspellable-character'
| 'unspellable-line-start'