diff --git a/MIGRATION.md b/MIGRATION.md index 32a84d3..d4c40d7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -55,6 +55,7 @@ it named converts. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | | a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | +| a link whose text already holds one (`[](/v)`) | drops the outer link | leaves the outer brackets literal text | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | diff --git a/README.md b/README.md index 5902463..ecdab0a 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ emit refuses: whose text holds an autolink keeps the inner link and leaves the outer brackets literal text, where the reference nests one `` inside another against the spec's own prose. The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite holds no example of - the fourth, which a normalization fixture pins instead. + the fourth. - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`. - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index c762b77..0804e5b 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -39,6 +39,8 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. type Scan = { container: LineContainer | undefined + // Pieces below this have been walked for openers to deactivate, so a nest of doomed brackets stays linear. + deactivatedBefore: number definitions: LinkDefinitions openingSpellableLink: boolean path: ConvertErrorPath @@ -61,7 +63,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions, } function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result { - const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans } + const scan: Scan = { container, deactivatedBefore: 0, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans } let index = 0 while (index < source.length) { switch (source.charAt(index)) { @@ -346,29 +348,46 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio return { definition, length: label?.length ?? 0 } } -// `false` keeps the brackets text: an empty link text gives the mark no node to ride, and a linked one no room for a second. function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path) const resolved = resolveNodes(inner, scan.path) if (!resolved.ok) return resolved const nodes = resolved.value - if (nodes.length === 0 || holdsLink(nodes)) return success(false) + // An empty link text gives the mark no node to ride, so the brackets stay text. + if (nodes.length === 0) return success(false) + if (holdsLink(nodes)) { + deactivateOpeners(scan, at) + return success(false) + } const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } - scan.pieces.length = at - // CommonMark: no link nests inside another, though an image's description holds one. - for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false + truncatePieces(scan, at) + deactivateOpeners(scan, at) scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) }) return success(true) } +// CommonMark: no link nests inside another, though an image's description holds one. +function deactivateOpeners(scan: Scan, before: number): void { + for (let index = Math.min(scan.deactivatedBefore, before); index < before; index += 1) { + const piece = scan.pieces[index] + if (piece?.kind === 'open' && !piece.image) piece.active = false + } + scan.deactivatedBefore = before +} + +function truncatePieces(scan: Scan, to: number): void { + scan.pieces.length = to + scan.deactivatedBefore = Math.min(scan.deactivatedBefore, to) +} + function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) const resolved = imageAlt(inner, scan.path) if (!resolved.ok) return resolved const alt = resolved.value const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } - scan.pieces.length = at + truncatePieces(scan, at) scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } }) return success(null) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 9012925..18a2f9e 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1020,6 +1020,7 @@ test('names the link a directive link wraps, no link holding another', () => { assert.equal(content(markdownToAdf('!adf:link[]{collection=c href="/u"}\n')), named) assert.equal(content(markdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n')), named) assert.equal(content(markdownToAdf('!adf:link[a b]{collection=c href="/u"}\n')), named) + assert.equal(content(markdownToAdf('!adf:link[]{href="/u"}\n')), named) }) test('names the directive mark left without the content it wraps', () => {