diff --git a/corpus/errors/image-in-text.error b/corpus/errors/image-in-text.error new file mode 100644 index 0000000..7fe8b78 --- /dev/null +++ b/corpus/errors/image-in-text.error @@ -0,0 +1 @@ +unmappable-image diff --git a/corpus/errors/image-in-text.md b/corpus/errors/image-in-text.md new file mode 100644 index 0000000..ede305f --- /dev/null +++ b/corpus/errors/image-in-text.md @@ -0,0 +1 @@ +See ![the moon](https://example.com/moon.png) tonight. diff --git a/corpus/normalization/emphasis-spellings.json b/corpus/normalization/emphasis-spellings.json new file mode 100644 index 0000000..1d72db8 --- /dev/null +++ b/corpus/normalization/emphasis-spellings.json @@ -0,0 +1,88 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "em" + } + ], + "text": "Torque", + "type": "text" + }, + { + "text": " the ", + "type": "text" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "text": "M8 bolt", + "type": "text" + }, + { + "text": " to 25 Nm, then ", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + }, + { + "type": "strong" + } + ], + "text": "log it", + "type": "text" + }, + { + "text": ".", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "Un", + "type": "text" + }, + { + "marks": [ + { + "type": "em" + } + ], + "text": "bolt", + "type": "text" + }, + { + "text": "ing needs a ", + "type": "text" + }, + { + "marks": [ + { + "type": "strike" + } + ], + "text": "10 mm", + "type": "text" + }, + { + "text": " 13 mm spanner.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/emphasis-spellings.md b/corpus/normalization/emphasis-spellings.md new file mode 100644 index 0000000..acc1f01 --- /dev/null +++ b/corpus/normalization/emphasis-spellings.md @@ -0,0 +1,3 @@ +*Torque* the __M8 bolt__ to 25 Nm, then ***log it***. + +Un*bolt*ing needs a ~~10 mm~~ 13 mm spanner. diff --git a/corpus/normalization/link-references.json b/corpus/normalization/link-references.json new file mode 100644 index 0000000..6bc3045 --- /dev/null +++ b/corpus/normalization/link-references.json @@ -0,0 +1,64 @@ +{ + "content": [ + { + "content": [ + { + "text": "See ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "https://example.com/changelog" + }, + "type": "link" + } + ], + "text": "the changelog", + "type": "text" + }, + { + "text": " and ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "https://example.com/guide", + "title": "Setup guide" + }, + "type": "link" + } + ], + "text": "the guide", + "type": "text" + }, + { + "text": ", or mail ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "mailto:ops@example.com" + }, + "type": "link" + } + ], + "text": "ops@example.com", + "type": "text" + }, + { + "text": ".", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/normalization/link-references.md b/corpus/normalization/link-references.md new file mode 100644 index 0000000..eb13e09 --- /dev/null +++ b/corpus/normalization/link-references.md @@ -0,0 +1,4 @@ +See [the changelog][changes] and [the guide], or mail . + +[changes]: https://example.com/changelog +[the guide]: https://example.com/guide "Setup guide" diff --git a/corpus/unspellable/link-destination-parenthesis.json b/corpus/round-trip/commonmark-subset/link-destination-parenthesis.json similarity index 100% rename from corpus/unspellable/link-destination-parenthesis.json rename to corpus/round-trip/commonmark-subset/link-destination-parenthesis.json diff --git a/corpus/round-trip/commonmark-subset/link-destination-parenthesis.md b/corpus/round-trip/commonmark-subset/link-destination-parenthesis.md new file mode 100644 index 0000000..6457e90 --- /dev/null +++ b/corpus/round-trip/commonmark-subset/link-destination-parenthesis.md @@ -0,0 +1 @@ +[The torque table](https://example.com/a\)b) diff --git a/corpus/unspellable/link-title-quote.json b/corpus/round-trip/commonmark-subset/link-title-quote.json similarity index 100% rename from corpus/unspellable/link-title-quote.json rename to corpus/round-trip/commonmark-subset/link-title-quote.json diff --git a/corpus/round-trip/commonmark-subset/link-title-quote.md b/corpus/round-trip/commonmark-subset/link-title-quote.md new file mode 100644 index 0000000..e61387b --- /dev/null +++ b/corpus/round-trip/commonmark-subset/link-title-quote.md @@ -0,0 +1 @@ +[The torque table](https://example.com/torque "He said \"hi\"") diff --git a/corpus/unspellable/link-destination-parenthesis.error b/corpus/unspellable/link-destination-parenthesis.error deleted file mode 100644 index 8da7bcc..0000000 --- a/corpus/unspellable/link-destination-parenthesis.error +++ /dev/null @@ -1 +0,0 @@ -unspellable-link-destination diff --git a/corpus/unspellable/link-title-quote.error b/corpus/unspellable/link-title-quote.error deleted file mode 100644 index a1f2950..0000000 --- a/corpus/unspellable/link-title-quote.error +++ /dev/null @@ -1 +0,0 @@ -unspellable-link-title diff --git a/spec/flavour.md b/spec/flavour.md index 839c3aa..5bed709 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -37,9 +37,10 @@ normalizes to it through the round-trip. CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node carries an attribute, it is the inline directive. - An empty paragraph — real payloads carry them — is `::paragraph`. -- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. - `` autolink form only when the text equals the destination and the destination is a valid - CommonMark autolink (absolute URI). +- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. A + backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title; + a balanced pair stays bare. `` autolink form only when the text equals the destination and + the destination is a valid CommonMark autolink (absolute URI). - Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index edeb4e7..cb1452b 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -197,13 +197,21 @@ export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlB } export function opensBracketedAutolink(text: string, index: number): boolean { - bracketedAutolink.lastIndex = index - return bracketedAutolink.test(text) + return readBracketedAutolink(text, index) !== undefined } export function opensEmailAutolink(text: string, index: number): boolean { + return readEmailAutolink(text, index) !== undefined +} + +export function readBracketedAutolink(text: string, index: number): number | undefined { + bracketedAutolink.lastIndex = index + return bracketedAutolink.exec(text)?.[0].length +} + +export function readEmailAutolink(text: string, index: number): number | undefined { emailAutolink.lastIndex = index - return emailAutolink.test(text) + return emailAutolink.exec(text)?.[0].length } export function setextHeadingLevel(line: string): number | undefined { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 7ab0676..aebacc5 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -76,7 +76,6 @@ test('spells a code block language no info string holds as an attribute', () => test('refuses a link destination CommonMark cannot spell', () => { const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' })) - assert.equal(code(adfToMarkdown(link('https://example.com/a)b'))), 'unspellable-link-destination') assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link-destination') assert.equal(code(adfToMarkdown(link(' { assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link-destination') const entity = 'https://example.com/?a=1&b=2' assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link-destination') - assert.equal(markdown(adfToMarkdown(link('https://en.example.com/a_(b)'))), '[t](https://en.example.com/a_(b))\n') }) -test('refuses a link title CommonMark cannot spell', () => { +test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => { + const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' })))) + assert.equal(link('https://en.example.com/a_(b)'), '[t](https://en.example.com/a_(b))\n') + assert.equal(link('https://example.com/a)b'), '[t](https://example.com/a\\)b)\n') + assert.equal(link('https://example.com/a(b'), '[t](https://example.com/a\\(b)\n') + assert.equal(link('https://example.com/)(') , '[t](https://example.com/\\)\\()\n') + assert.equal(link('https://example.com/a (b'), '[t]()\n') +}) + +test('escapes the quote a link title holds, and refuses the rest', () => { const titled = (title: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' })) - assert.equal(code(adfToMarkdown(titled('He said "hi"'))), 'unspellable-link-title') + assert.equal(markdown(adfToMarkdown(titled('He said "hi"'))), '[t](https://example.com/ "He said \\"hi\\"")\n') assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title') + assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link-title') }) test('carries a link mark the link spelling cannot write', () => { diff --git a/src/markdown/emit/destination-spelling.ts b/src/markdown/emit/destination-spelling.ts deleted file mode 100644 index 028ba80..0000000 --- a/src/markdown/emit/destination-spelling.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { holdsControlCharacter } from '../commonmark-grammar.ts' -import { holdsEntityReference } from '../entity-references.ts' - -export function spellDestination(href: string, path: ConvertErrorPath): Result { - if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path) - if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path) - if (holdsEntityReference(href)) { - return failure('unspellable-link-destination', 'a link destination holds an entity reference that decodes on the way back', path) - } - if (href.includes(' ')) { - if (/[<>]/.test(href)) { - return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path) - } - return success(`<${href}>`) - } - if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path) - if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path) - return success(href) -} - -export function spellTitle(title: string, path: ConvertErrorPath): Result { - if (/["\n\r\\]/.test(title)) { - return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path) - } - if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title holds an entity reference that decodes on the way back', path) - return success(` "${title}"`) -} - -function balanced(href: string): boolean { - let depth = 0 - for (const character of href) { - if (character === '(') depth += 1 - if (character === ')') depth -= 1 - if (depth < 0) return false - } - return depth === 0 -} diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index ba61347..c112bb8 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -11,7 +11,7 @@ import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts' -import { spellDestination, spellTitle } from './destination-spelling.ts' +import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' type EmittedLine = { line: string; segments: InlineSegment[] } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 34ddb70..f139af7 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,5 +1,5 @@ import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' -import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts' +import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../emphasis-matching.ts' import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark-grammar.ts' import { opensInlineDirective } from '../directive-attributes.ts' import { readEntityReference } from '../entity-references.ts' @@ -260,13 +260,6 @@ function startsRun(scan: string, index: number, escaped: ReadonlySet): b return scan.charAt(index - 1) !== scan.charAt(index) } -function runLength(scan: string, index: number): number { - const character = scan.charAt(index) - let length = 0 - while (scan.charAt(index + length) === character) length += 1 - return length -} - function charAt(text: string, index: number): string { return index < 0 ? '' : text.charAt(index) } diff --git a/src/markdown/emphasis-matching.ts b/src/markdown/emphasis-matching.ts index a5897d0..d69104b 100644 --- a/src/markdown/emphasis-matching.ts +++ b/src/markdown/emphasis-matching.ts @@ -64,6 +64,13 @@ export function matchEmphasis(runs: readonly Run[]): E return pairings } +export function runLength(text: string, index: number): number { + const character = text.charAt(index) + let length = 0 + while (text.charAt(index + length) === character) length += 1 + return length +} + function candidates(runs: readonly Run[]): Candidate | undefined { let first: Candidate | undefined let previous: Candidate | undefined diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts new file mode 100644 index 0000000..022adbb --- /dev/null +++ b/src/markdown/link-syntax.ts @@ -0,0 +1,129 @@ +import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' +import { holdsEntityReference } from './entity-references.ts' + +export type LinkDefinition = { destination: string; title?: string } + +export type LinkPart = { length: number; value: string } + +const bracketedDestination = /<((?:[^\n<>\\]|\\[^\n])*)>/y +const linkLabel = /\[((?:[^[\]\\]|\\[\s\S]){0,999})\]/y +const titleClosers: Readonly> = { '"': '"', "'": "'", '(': ')' } + +// The label is matched by its normalized raw text, escapes and all. +export function readLabel(text: string, offset: number): LinkPart | undefined { + linkLabel.lastIndex = offset + const value = linkLabel.exec(text)?.[1] + return value === undefined ? undefined : { length: value.length + 2, value } +} + +export function normalizeLabel(raw: string): string { + return raw + .replace(/^[ \t\n]+|[ \t\n]+$/g, '') + .replace(/[ \t\n]+/g, ' ') + .toLowerCase() +} + +export function readDestination(text: string, offset: number): LinkPart | undefined { + bracketedDestination.lastIndex = offset + const bracketed = bracketedDestination.exec(text)?.[0] + if (bracketed !== undefined) return { length: bracketed.length, value: decodeTextEscapes(bracketed.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 (backslashEscape(text, index) !== undefined) { + 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: decodeTextEscapes(text.slice(offset, index)) } +} + +// `offset` sits on the `(` a link text closes into. +export function readInlineTarget(text: string, offset: number): { definition: LinkDefinition; length: number } | undefined { + let index = skipLinkWhitespace(text, offset + 1) + let destination = '' + if (text.charAt(index) !== ')') { + const read = readDestination(text, index) + if (read === undefined) return undefined + destination = read.value + index += read.length + } + const afterDestination = index + index = skipLinkWhitespace(text, index) + const title = index > afterDestination ? readTitle(text, index) : undefined + if (title !== undefined) index = skipLinkWhitespace(text, index + title.length) + if (text.charAt(index) !== ')') return undefined + return { definition: title === undefined ? { destination } : { destination, title: title.value }, length: index + 1 - offset } +} + +export function readTitle(text: string, offset: number): LinkPart | 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 (backslashEscape(text, index) !== undefined) { + index += 2 + continue + } + if (character === closer) return { length: index + 1 - offset, value: decodeTextEscapes(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. +export function skipLinkWhitespace(text: string, offset: number): number { + const rest = text.slice(offset) + return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length +} + +export function spellDestination(href: string, path: ConvertErrorPath): Result { + if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path) + if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path) + if (holdsEntityReference(href)) { + return failure('unspellable-link-destination', 'a link destination holds an entity reference that decodes on the way back', path) + } + if (href.includes(' ')) { + if (/[<>]/.test(href)) { + return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path) + } + return success(`<${href}>`) + } + if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path) + return success(escapeUnbalanced(href)) +} + +export function spellTitle(title: string, path: ConvertErrorPath): Result { + if (/[\n\r\\]/.test(title)) { + return failure('unspellable-link-title', 'no canonical escape spells a backslash or newline in a link title', path) + } + if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title holds an entity reference that decodes on the way back', path) + return success(` "${title.replaceAll('"', '\\"')}"`) +} + +function escapeUnbalanced(href: string): string { + const open: number[] = [] + const unbalanced = new Set() + for (let index = 0; index < href.length; index += 1) { + const character = href.charAt(index) + if (character === '(') open.push(index) + if (character === ')' && open.pop() === undefined) unbalanced.add(index) + } + for (const index of open) unbalanced.add(index) + let spelled = '' + for (let index = 0; index < href.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + href.charAt(index) + return spelled +} diff --git a/src/markdown/parse/blocks.test.ts b/src/markdown/parse/blocks.test.ts index f625693..cab466b 100644 --- a/src/markdown/parse/blocks.test.ts +++ b/src/markdown/parse/blocks.test.ts @@ -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']) }) diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index b9b2126..7b6dd5d 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -1,4 +1,4 @@ -import type { LinkDefinition } from './link-reference-definitions.ts' +import type { LinkDefinition } from '../link-syntax.ts' import { atxHeading, claimsDirectiveLine, diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 85e9e70..ebd2438 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -1,69 +1,269 @@ -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 { 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 { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' +import { serializeCanonicalJson } from '../../canonical-json.ts' -type Run = { nodes: AdfNode[]; text: string; undecodedFrom: number } +export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] } +export type LinkDefinitions = ReadonlyMap + +type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } + +type Piece = Bracket | { 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; image: AdfNode | undefined; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } + +const emphasisCharacters = '*_~' 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 { - const run: Run = { nodes: [], text: '', undecodedFrom: 0 } +export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { + const scan: Scan = { definitions, image: undefined, 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' }) + flush(scan, false) + pushNode(scan, { type: 'hardBreak' }) + index += 2 + continue + } + if (backslashEscape(source, index) !== undefined) { + scan.pending += source.slice(index, index + 2) 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 += ' ' + const hard = hardBreakSpaces.test(scan.pending) + flush(scan, true) + if (hard) pushNode(scan, { type: 'hardBreak' }) + else scan.pending = ' ' index += 1 continue } if (character === '`') { const span = readCodeSpan(source, index) if (span === undefined) { - index += backtickRun(source, index) + const run = backtickRun(source, index) + scan.pending += source.slice(index, index + run) + index += run continue } - takeRun(run, source, index, span.end, false) - pushNode(run, { marks: [{ type: 'code' }], text: span.text, type: 'text' }) + flush(scan, false) + pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' }) index = span.end continue } if (character === '<') { + const autolink = readAutolink(source, index) + if (autolink !== undefined) { + flush(scan, false) + pushNode(scan, autolink.node) + index += autolink.length + continue + } 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 + if (character === '[' || (character === '!' && source.charAt(index + 1) === '[')) { + const image = character === '!' + const width = image ? 2 : 1 + flush(scan, false) + scan.pieces.push({ active: true, image, kind: 'open', start: index + width }) + index += width + continue + } + if (character === ']') { + const closed = closeBracket(scan, index) + if (!closed.ok) return closed + index = closed.value + continue + } + if (emphasisCharacters.includes(character)) { + index = readDelimiterRun(scan, index) + continue + } + scan.pending += character + index += 1 } - 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 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 pushText(run: Run): void { - if (run.text !== '') run.nodes.push({ text: run.text, type: 'text' }) - run.text = '' +function pushNode(scan: Scan, node: AdfNode): void { + scan.pieces.push({ kind: 'nodes', nodes: [node] }) } -function pushNode(run: Run, node: AdfNode): void { - pushText(run) - run.nodes.push(node) +function assemble(scan: Scan): Result { + if (scan.image !== undefined) { + if (scan.pieces.length > 0) return failure('unmappable-image', imageAlone, scan.path) + return success({ image: scan.image }) + } + return success({ nodes: resolveNodes(scan.pieces) }) +} + +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)) + // spec/flavour.md: strike is exactly two tildes. + 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 { + 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) { + scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) } + return success(literalClose(scan, index)) + } + const inner = scan.pieces.splice(open.index).slice(1) + if (open.bracket.image) { + const built = buildImage(scan, inner, target.definition) + if (!built.ok) return built + } else buildLink(scan, inner, target.definition) + return success(index + 1 + target.length) +} + +function literalClose(scan: Scan, index: number): number { + scan.pending += ']' + return index + 1 +} + +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 } +} + +function buildLink(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): void { + const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } + const nodes = applyMark(resolveNodes(inner), { attrs, type: 'link' }) + // CommonMark: no link nests inside another. + for (const piece of scan.pieces) if (piece.kind === 'open') piece.active = false + scan.pieces.push({ kind: 'nodes', nodes }) +} + +function buildImage(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result { + if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) + if (scan.pieces.length > 0 || scan.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) + const alt = resolveNodes(inner) + .map((node) => node.text ?? '') + .join('') + const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } + scan.image = { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } + return success(null) +} + +function resolveNodes(pieces: readonly Piece[]): AdfNode[] { + const nodes = pieces.map((piece) => (piece.kind === 'nodes' ? piece.nodes : piece.kind === 'open' ? bracketNodes(piece) : [])) + 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 }) + } + const pairings = matchEmphasis(runs) + const heads = new Map() + const tails = new Map() + 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' }] + } + 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) + } + } + return mergeText(nodes.flat()) +} + +function markType(character: string, used: number): string { + if (character === '~') return 'strike' + return used === 2 ? 'strong' : 'em' +} + +// CommonMark nests a spelling inside its own kind (`*(*a*)*`); the mark it names is idempotent, and +// a node carrying it twice is the shape AGENTS.md §14 has the emitter refuse. +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] } + }) +} + +// Editor-normal (AGENTS.md §2): adjacent text nodes carrying identical marks are one node. +function mergeText(nodes: readonly AdfNode[]): AdfNode[] { + const merged: AdfNode[] = [] + for (const node of nodes) { + const previous = merged[merged.length - 1] + if (previous !== undefined && previous.type === 'text' && node.type === 'text' && markKey(previous) === markKey(node)) { + merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } + continue + } + merged.push(node) + } + return merged +} + +function markKey(node: AdfNode): string { + return (node.marks ?? []).map((mark) => `${mark.type}${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`).join(' ') } function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined { diff --git a/src/markdown/parse/link-reference-definitions.ts b/src/markdown/parse/link-reference-definitions.ts index b235adf..8edbefd 100644 --- a/src/markdown/parse/link-reference-definitions.ts +++ b/src/markdown/parse/link-reference-definitions.ts @@ -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> = { '"': '"', "'": "'", '(': ')' } export function readLinkDefinitions(definitions: Map, text: string): string { let rest = text @@ -22,11 +17,11 @@ export function readLinkDefinitions(definitions: Map, 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 diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 3861b27..2a76461 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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): 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,120 @@ test('leaves the angle bracket that opens no HTML construct to the text it sits assert.deepEqual(content(markdownToAdf('``\n')), [{ content: [codeSpan('')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('\\\n')), [paragraph('')]) }) + +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]()\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]( { + 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('\n')), [ + { content: [marked('https://example.com/', link('https://example.com/'))], type: 'paragraph' }, + ]) + assert.deepEqual(content(markdownToAdf('a c\n')), [ + { content: [text('a '), marked('a@b.example.com', link('mailto:a@b.example.com')), text(' c')], type: 'paragraph' }, + ]) + assert.deepEqual(content(markdownToAdf('\n')), [ + { content: [marked('https://example.com/?a=\\*', link('https://example.com/?a=\\*'))], type: 'paragraph' }, + ]) + assert.equal(code(markdownToAdf(' \n')), 'unmappable-html') +}) + +test('reads a lone image as the media the flavour spells for it', () => { + assert.deepEqual(content(markdownToAdf('![The moon](https://example.com/moon.png)\n')), [ + image('https://example.com/moon.png', 'The moon'), + ]) + assert.deepEqual(content(markdownToAdf('![](/u)\n')), [image('/u')]) + assert.deepEqual(content(markdownToAdf('![*a*](/u)\n')), [image('/u', 'a')]) + assert.deepEqual(content(markdownToAdf('- ![a](/u)\n')), [bulletList(item(image('/u', 'a')))]) +}) + +test('refuses the image no ADF node carries where it sits', () => { + assert.equal(content(markdownToAdf('![a](/u "t")\n')), 'unmappable-image: no media node carries a link title') + assert.equal(content(markdownToAdf('See ![a](/u).\n')), 'unmappable-image: an image fits only as a paragraph of its own') + assert.equal(code(markdownToAdf('# ![a](/u)\n')), 'unmappable-image') + assert.equal(code(markdownToAdf('*![a](/u)*\n')), 'unmappable-image') + assert.equal(code(markdownToAdf('[![a](/u)](/v)\n')), 'unmappable-image') + assert.equal(code(markdownToAdf('![a](/u)![b](/v)\n')), 'unmappable-image') + assert.deepEqual(path(markdownToAdf('Part.\n\nSee ![a](/u).\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' }, + ]) +}) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index fc2c1ed..8a5c985 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 { - 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 { +function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) const content: AdfNode[] = [] for (const [index, block] of blocks.entries()) { - 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 { +function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { 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 { - const content = blockNodes(blocks, path, depth + 1) +function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { + 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 { +function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result { 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 { - 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 { + 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 }) } diff --git a/src/result.ts b/src/result.ts index 94e8c9d..2bdcdb4 100644 --- a/src/result.ts +++ b/src/result.ts @@ -3,6 +3,7 @@ export type ConvertErrorCode = | 'malformed-pipe-table' | 'not-an-adf-document' | 'unmappable-html' + | 'unmappable-image' | 'unspellable-adjacent-lists' | 'unspellable-character' | 'unspellable-line-start' diff --git a/todo.md b/todo.md index 35924d7..5216a37 100644 --- a/todo.md +++ b/todo.md @@ -240,7 +240,7 @@ detail is settled at its own milestone. autolink parting off as 3e's own predicate: refusing on the superset would refuse `1 ` is, autolinks decoding neither escapes nor references. + The image gap mints `unmappable-image`, mirroring `unmappable-html` — a construct in input + no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once + `[…](…)` reads, a lone `![alt](url)` would otherwise misparse as text plus a link, so 3h + keeps the rest of the media family and loses only that line. - [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`, container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with its quoting and escapes, the fence-length and nesting rules, and the malformed list @@ -269,9 +277,9 @@ detail is settled at its own milestone. guard is built here if the answer there was yes. - [ ] **3h — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f settles, read from the other side; the `codeBlock` directive's fenced body and the - `language` attribute a bare fence leaves it; the media family's composition and the - CommonMark image shape; both table forms, the pipe table's cell split and its named - errors; and the one paragraph an inline body takes. + `language` attribute a bare fence leaves it; the media family's composition; both table + forms, the pipe table's cell split and its named errors; and the one paragraph an inline + body takes. **Settled** (the maintainer, 2026-08-27): 1d's last pick, the one `container-block-separation` holds — a CommonMark block and a directive block sit adjacent in a container body with no blank line between them. That reduces the three cases to one