From 5fc4272155e223e804920dc766e766fac2c4292b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 23:23:29 +0200 Subject: [PATCH] Read the leaf blocks, ahead of any inline parsing --- corpus/errors/colon-run-alone.error | 1 + corpus/errors/colon-run-alone.md | 1 + corpus/errors/html-comment.error | 1 + corpus/errors/html-comment.md | 1 + corpus/errors/pipe-row-alone.error | 1 + corpus/errors/pipe-row-alone.md | 1 + corpus/normalization/indented-code.json | 15 ++ corpus/normalization/indented-code.md | 2 + corpus/normalization/setext-headings.json | 30 +++ corpus/normalization/setext-headings.md | 5 + src/corpus.test.ts | 37 ++++ src/markdown/commonmark-grammar.ts | 47 ++++- src/markdown/parse/blocks.test.ts | 53 ++++++ src/markdown/parse/blocks.ts | 180 ++++++++++++++++++ src/markdown/parse/html-blocks.ts | 28 +++ .../parse/link-reference-definitions.ts | 100 ++++++++++ src/markdown/parse/markdown-to-adf.test.ts | 135 +++++++++++++ src/markdown/parse/markdown-to-adf.ts | 42 ++++ src/result.ts | 3 + 19 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 corpus/errors/colon-run-alone.error create mode 100644 corpus/errors/colon-run-alone.md create mode 100644 corpus/errors/html-comment.error create mode 100644 corpus/errors/html-comment.md create mode 100644 corpus/errors/pipe-row-alone.error create mode 100644 corpus/errors/pipe-row-alone.md create mode 100644 corpus/normalization/indented-code.json create mode 100644 corpus/normalization/indented-code.md create mode 100644 corpus/normalization/setext-headings.json create mode 100644 corpus/normalization/setext-headings.md create mode 100644 src/markdown/parse/blocks.test.ts create mode 100644 src/markdown/parse/blocks.ts create mode 100644 src/markdown/parse/html-blocks.ts create mode 100644 src/markdown/parse/link-reference-definitions.ts create mode 100644 src/markdown/parse/markdown-to-adf.test.ts create mode 100644 src/markdown/parse/markdown-to-adf.ts diff --git a/corpus/errors/colon-run-alone.error b/corpus/errors/colon-run-alone.error new file mode 100644 index 0000000..4f35411 --- /dev/null +++ b/corpus/errors/colon-run-alone.error @@ -0,0 +1 @@ +malformed-directive diff --git a/corpus/errors/colon-run-alone.md b/corpus/errors/colon-run-alone.md new file mode 100644 index 0000000..2d2bfd3 --- /dev/null +++ b/corpus/errors/colon-run-alone.md @@ -0,0 +1 @@ +::: diff --git a/corpus/errors/html-comment.error b/corpus/errors/html-comment.error new file mode 100644 index 0000000..847e0f9 --- /dev/null +++ b/corpus/errors/html-comment.error @@ -0,0 +1 @@ +unmappable-html diff --git a/corpus/errors/html-comment.md b/corpus/errors/html-comment.md new file mode 100644 index 0000000..2069895 --- /dev/null +++ b/corpus/errors/html-comment.md @@ -0,0 +1 @@ + diff --git a/corpus/errors/pipe-row-alone.error b/corpus/errors/pipe-row-alone.error new file mode 100644 index 0000000..5bf92ee --- /dev/null +++ b/corpus/errors/pipe-row-alone.error @@ -0,0 +1 @@ +malformed-pipe-table diff --git a/corpus/errors/pipe-row-alone.md b/corpus/errors/pipe-row-alone.md new file mode 100644 index 0000000..87f79c6 --- /dev/null +++ b/corpus/errors/pipe-row-alone.md @@ -0,0 +1 @@ +| Part | Qty | diff --git a/corpus/normalization/indented-code.json b/corpus/normalization/indented-code.json new file mode 100644 index 0000000..76b0c09 --- /dev/null +++ b/corpus/normalization/indented-code.json @@ -0,0 +1,15 @@ +{ + "content": [ + { + "content": [ + { + "text": "SELECT id\nFROM part", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/indented-code.md b/corpus/normalization/indented-code.md new file mode 100644 index 0000000..0fc6398 --- /dev/null +++ b/corpus/normalization/indented-code.md @@ -0,0 +1,2 @@ + SELECT id + FROM part diff --git a/corpus/normalization/setext-headings.json b/corpus/normalization/setext-headings.json new file mode 100644 index 0000000..784a149 --- /dev/null +++ b/corpus/normalization/setext-headings.json @@ -0,0 +1,30 @@ +{ + "content": [ + { + "attrs": { + "level": 1 + }, + "content": [ + { + "text": "Assembly", + "type": "text" + } + ], + "type": "heading" + }, + { + "attrs": { + "level": 2 + }, + "content": [ + { + "text": "Parts", + "type": "text" + } + ], + "type": "heading" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/setext-headings.md b/corpus/normalization/setext-headings.md new file mode 100644 index 0000000..18ddd28 --- /dev/null +++ b/corpus/normalization/setext-headings.md @@ -0,0 +1,5 @@ +Assembly +======== + +Parts +----- diff --git a/src/corpus.test.ts b/src/corpus.test.ts index a3cafbe..38d67cd 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -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) }) diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 0d9dec1..aeb1a08 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -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, '') +} diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts new file mode 100644 index 0000000..96966ee --- /dev/null +++ b/src/markdown/parse/blocks.test.ts @@ -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\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]: closed>\n'), []) + assert.deepEqual(definitions('[a]: { + assert.deepEqual(kinds('
\nx\n\nPart.\n'), ['html', 'paragraph']) + assert.deepEqual(kinds('\nPart.\n'), ['html', 'paragraph']) + assert.deepEqual(kinds('
x
\nPart.\n'), ['html', 'paragraph']) + assert.deepEqual(kinds('
\nx\n'), ['html']) + assert.deepEqual(kinds('Part.\n
\n'), ['paragraph', 'html']) +}) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts new file mode 100644 index 0000000..2af8cf5 --- /dev/null +++ b/src/markdown/parse/blocks.ts @@ -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 } + +type Walk = ParsedBlocks & { paragraph: string[] } + +const blankLine = /^[ \t]*$/ +const indentedCodeColumns = 4 +const largestOpenerIndentation = 3 +const tabStop = 4 + +export function parseBlocks(markdown: string): Result { + 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 | 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) +} diff --git a/src/markdown/parse/html-blocks.ts b/src/markdown/parse/html-blocks.ts new file mode 100644 index 0000000..76a1e91 --- /dev/null +++ b/src/markdown/parse/html-blocks.ts @@ -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]*/?>|)[ \\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: /^\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('
\nx\n
\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('
\nx\n
\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.deepEqual(path(markdownToAdf('Part.\n\n
\n')), ['content', 1]) +}) + +test('swallows an HTML block ahead of the claim a line inside it would make', () => { + assert.equal(code(markdownToAdf('\n')), 'unmappable-html') + assert.equal(code(markdownToAdf('
\n| x |\n
\n')), 'unmappable-html') +}) + +test('leaves a tag that opens no HTML block to the paragraph it sits in', () => { + assert.deepEqual(content(markdownToAdf('Part.\n\n')), [paragraph('Part. ')]) + 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')]) +}) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts new file mode 100644 index 0000000..06709f2 --- /dev/null +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 { + 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 { + 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' }] +} diff --git a/src/result.ts b/src/result.ts index 6c705a9..41f5528 100644 --- a/src/result.ts +++ b/src/result.ts @@ -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'