diff --git a/corpus/round-trip/combinations/directive-link-brackets.json b/corpus/round-trip/combinations/directive-link-brackets.json new file mode 100644 index 0000000..850d741 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-brackets.json @@ -0,0 +1,111 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "subsup" + }, + { + "attrs": { + "href": "[" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "]" + }, + "type": "link" + } + ], + "text": "b", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + } + ], + "text": " ", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "x", + "title": "[" + }, + "type": "link" + } + ], + "text": "c", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "e[f]" + }, + "type": "link" + } + ], + "text": "d", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "http://a[b" + }, + "type": "link" + } + ], + "text": "http://a[b", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/directive-link-brackets.md b/corpus/round-trip/combinations/directive-link-brackets.md new file mode 100644 index 0000000..e677943 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-brackets.md @@ -0,0 +1,7 @@ +:subsup[[a](\[)] + +:underline[[b](\]) [c](x "\[")] + +:underline[[d](e[f])] + +:underline[[http://a\[b](http://a\[b)] diff --git a/spec/flavour.md b/spec/flavour.md index 8ae4022..98bfb8a 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -129,9 +129,11 @@ quoted where bare carries it, an escape longer than it need be, an empty `{attrs each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as -directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right -after a directive's closing `]`, which would otherwise be read as the attributes it has none -of; outside code spans and code blocks, a backslash before `:` in input yields a literal colon. +directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's +destination and title inside content leave unbalanced (an autolink holding one takes the +`[text](url)` form), a `{` right after a directive's closing `]`, which would otherwise be read as +the attributes it has none of; outside code spans and code blocks, a backslash before `:` in input +yields a literal colon. **Malformed directives are error results**, named: an unclosed container at end of input, a body fence line of the container's length or longer, a bare colon-run line outside any container or diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 807357e..e889383 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -3,6 +3,7 @@ import type { InlineDirective } from '../../adf/inline-directives.ts' import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' +import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { holdsEntityReference } from '../entity-references.ts' import { inlineDirective } from '../../adf/inline-directives.ts' @@ -12,7 +13,6 @@ import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' -import { spellDestination, spellLinkTarget } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -284,12 +284,14 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 - if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) + const balanced = !context.bracketed || escapeUnbalanced(href, '[', ']') === href + if (bare && balanced && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) if (!target.ok) return target const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${target.value})`)] }) + const spelledTarget = context.bracketed ? escapeUnbalanced(target.value, '[', ']') : target.value + return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${spelledTarget})`)] }) } diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts index 7be2a26..38aff4d 100644 --- a/src/markdown/link-syntax.ts +++ b/src/markdown/link-syntax.ts @@ -103,7 +103,7 @@ export function spellDestination(href: string, path: ConvertErrorPath): Result`) } if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path) - return success(escapeUnbalanced(href)) + return success(escapeUnbalanced(href, '(', ')')) } export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result { @@ -114,6 +114,20 @@ export function spellLinkTarget(href: string, title: string | undefined, path: C return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`) } +export function escapeUnbalanced(spelling: string, opener: string, closer: string): string { + const open: number[] = [] + const unbalanced = new Set() + for (let index = 0; index < spelling.length; index += backslashEscape(spelling, index) === undefined ? 1 : 2) { + const character = spelling.charAt(index) + if (character === opener) open.push(index) + if (character === closer && open.pop() === undefined) unbalanced.add(index) + } + for (const index of open) unbalanced.add(index) + let spelled = '' + for (let index = 0; index < spelling.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + spelling.charAt(index) + return spelled +} + function spellTitle(title: string, path: ConvertErrorPath): Result { if (/[\n\r\\]/.test(title)) { return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path) @@ -121,17 +135,3 @@ function spellTitle(title: string, path: ConvertErrorPath): Result { if (holdsEntityReference(title)) return failure('unspellable-link', '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 -}