From b388b33d31dea79e7a0ec39461552832d419c4c1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 28 Oct 2026 17:36:28 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1=20(1=20misread);=20a=20line=20?= =?UTF-8?q?gives=20up=20its=20first=20misread=20emphasis=20run,=20else=20i?= =?UTF-8?q?ts=20first=20misread=20highlight,=20as=20shipped,=20the=20run?= =?UTF-8?q?=20list=20narrows=20to=20one=20run,=20Scan=20holds=20SharedScan?= =?UTF-8?q?,=20the=20Piece=20arms=20are=20named,=20and=20textOpeners=20and?= =?UTF-8?q?=20guardLinkDefinition=20are=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/markdown/emit/adf-to-markdown.test.ts | 14 ++- src/markdown/emit/inline-line.ts | 8 +- src/markdown/emit/line-escaping.ts | 23 +++-- src/markdown/inline-content.ts | 87 +++++++++---------- .../portable/adf-to-portable-markdown.test.ts | 9 ++ src/markdown/portable/inline-reduction.ts | 12 +-- 6 files changed, 83 insertions(+), 70 deletions(-) diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index f356af2..ac81806 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -333,6 +333,8 @@ test('escapes only text that would otherwise open a construct', () => { assert.equal(emitted(':::panel info'), ':::panel info\n') assert.equal(emitted('10:30 tomorrow'), '10:30 tomorrow\n') assert.equal(emitted('[a](b)'), '\\[a](b)\n') + assert.equal(emitted('[a]: b'), '\\[a]: b\n') + assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { level: 1 }, content: [{ text: '[a]: b', type: 'text' }], type: 'heading' }))), '# [a]: b\n') assert.equal(emitted('**bold**'), '\\*\\*bold\\*\\*\n') assert.equal(emitted('a `x` b'), 'a \\`x` b\n') assert.equal(emitted('~~struck~~'), '\\~~struck\\~~\n') @@ -342,13 +344,14 @@ test('escapes only text that would otherwise open a construct', () => { assert.equal(emitted('x"_y"'), 'x"\\_y"\n') }) -test('escapes every text bracket in a line where one opens link text, none where none does, and every text backtick where one opens a code span', () => { +test('escapes every text bracket in a line where one opens link text and every text backtick where one opens a code span, and none where none does', () => { const emitted = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text, type: 'text' })))) assert.equal(emitted('[a](b) [c]'), '\\[a](b) \\[c]\n') assert.equal(emitted('see [1] and [docs](url) [2]'), 'see \\[1] and \\[docs](url) \\[2]\n') assert.equal(emitted('[a] b](c'), '[a] b](c\n') assert.equal(emitted('[x]('), '[x](\n') assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n') + assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: 'c', type: 'text' }, { text: 'a ` b', type: 'text' })))), '`c`a ` b\n') }) test('keeps a text backtick bare in a line where a text bracket opens a link the reader refuses', () => { @@ -398,6 +401,15 @@ test('carries a mark spelling that cannot open or close where it sits', () => { assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n') }) +test('carries the mark spelling that cannot close where it sits, and keeps the later one the reader would pair it with', () => { + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content)))) + const b: AdfNode = { text: 'b', type: 'text' } + assert.equal(emitted(marked(':', { type: 'em' }), b, marked('c', { type: 'em' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\":\\",\\"type\\":\\"text\\"}"}b*c*\n') + assert.equal(emitted(marked(')', { type: 'strong' }), b, marked('c', { type: 'strong' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\")\\",\\"type\\":\\"text\\"}"}b**c**\n') + assert.equal(emitted(marked('^', { type: 'strike' }), b, marked('c', { type: 'strike' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strike\\"}],\\"text\\":\\"^\\",\\"type\\":\\"text\\"}"}b~~c~~\n') +}) + test('refuses a node carrying one mark type twice', () => { const em: AdfMark = { type: 'em' } assert.equal(code(adfToLosslessMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape') diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index c198da7..97fafb1 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -41,7 +41,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } -export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } +export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun } export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { const emitted = emitLine(nodes, container, path, flavour) @@ -136,7 +136,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt { const verdict = lineVerdict(segments, container, flavour) if (verdict.kind === 'opening-link') return { fallback: 'opening-link' } - if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] } + if (verdict.kind === 'unspellable-run') return { fallback: verdict.run } return { line: verdict.text } } @@ -144,8 +144,8 @@ function attemptLine(segments: readonly InlineSegment[], container: LineContaine function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): PortableLineFallback | { kind: 'line'; text: string } { const assembled = assembleInlineLine(segments, container, flavour) if (assembled.openingLinkAsDirective) return { kind: 'opening-link' } - const [run, ...others] = assembled.unspellableRuns - return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] } + const run = assembled.unspellableRun + return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', run } } // A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}. diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 57b1870..4080b6b 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -26,7 +26,7 @@ export type InlineSegment = | { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string } -export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] } +export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: MarkRun | undefined } type ScanLine = { position: LinePosition; start: number; text: string } @@ -91,7 +91,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine { const { output, placed, reading } = readBack(segments, container, claims) - return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading) } + return { ...guardLinkDefinition(output, placed, container), unspellableRun: unspellableRun(placed, reading) } } function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack { @@ -109,12 +109,12 @@ function readBack(segments: readonly InlineSegment[], container: LineContainer, } const claimed = escapeClaims(scan, escapings, container, claims) let escapes: TextEscapes = { backticks: false, brackets: false } - // Terminates because a pass whose reading asks for no kind of escape the last pass lacked returns. + // At most three passes: each pass that doesn't return turns on at least one more of the two escape kinds. for (;;) { const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes)) const { faulted, reading } = readLineBack(output, container, claims) const shown = textOpeners(placed, output.length, reading) - // A code span a directive slot swallowed leaves no record before the directive fault. + // A text backtick in a directive's slot can open a code span past the slot's `]`: the directive faults before the reader records that span. const opened = faulted && !shown.backticks && !shown.brackets ? { backticks: true, brackets: true } : shown const next = { backticks: escapes.backticks || opened.backticks, brackets: escapes.brackets || opened.brackets } if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) return { output, placed, reading } @@ -122,7 +122,7 @@ function readBack(segments: readonly InlineSegment[], container: LineContainer, } } -// spec/flavour.md, Canonical form: a paragraph opening with what reads as a link reference definition escapes its `[`, or spells the emitter's opening link as a directive. +// spec/flavour.md, Canonical form. function guardLinkDefinition(output: string, placed: readonly PlacedSegment[], container: LineContainer): { line: string; openingLinkAsDirective?: true } { if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output } return opensWithLink(placed) ? { line: output, openingLinkAsDirective: true } : { line: `\\${output}` } @@ -267,22 +267,21 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text: } } -// The innermost emphasis run the line does not read back as written, else every highlight run it does not. -function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading): MarkRun[] { +// Of the runs the line does not read back as written, the first emphasis run to close, else the first highlight run: one misread pair can re-pair the delimiters after it. +function unspellableRun(placed: readonly PlacedSegment[], reading: InlineReading): MarkRun | undefined { const openers: { nodes: MarkRun; start: number }[] = [] - const unreadHighlights: MarkRun[] = [] - let innermost: { nodes: MarkRun; start: number } | undefined let highlight: { nodes: MarkRun; start: number } | undefined + let unreadHighlight: MarkRun | undefined for (const { segment, start } of placed) { if (segment.emphasis === 'open') openers.push({ nodes: segment.nodes, start }) if (segment.highlight === 'open') highlight = { nodes: segment.nodes, start } if (segment.emphasis === 'close') { const opener = openers.pop() - if (opener !== undefined && !readAsWritten(reading, opener.start, start, segment.text.length) && opener.start > (innermost?.start ?? -1)) innermost = opener + if (opener !== undefined && !readAsWritten(reading, opener.start, start, segment.text.length)) return opener.nodes } - if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlights.push(highlight.nodes) + if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlight ??= highlight.nodes } - return innermost === undefined ? unreadHighlights : [innermost.nodes] + return unreadHighlight } function readAsWritten(reading: InlineReading, opener: number, closer: number, width: number): boolean { diff --git a/src/markdown/inline-content.ts b/src/markdown/inline-content.ts index f6199a1..7bb6b4b 100644 --- a/src/markdown/inline-content.ts +++ b/src/markdown/inline-content.ts @@ -59,23 +59,21 @@ type OpenBracket = { image: boolean; index: number; start: number } type Pairing = EmphasisPairing -type Piece = - | Bracket - | OwnMarks - | Image - | ImageLink - | Nodes - | TextBreak - | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number } - | { closes: boolean; kind: 'highlight'; offset: number; opens: boolean } +// `offset` is where the delimiter starts in the outermost content. +type HighlightPiece = { closes: boolean; kind: 'highlight'; offset: number; opens: boolean } +type RunPiece = { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number } -type Run = Extract & { index: number } +type Piece = Bracket | HighlightPiece | Image | ImageLink | Nodes | OwnMarks | RunPiece | TextBreak + +type Run = RunPiece & { index: number } // What the reader took where, by offset into the outermost content, so a writer can check its line reads as it meant. export type InlineReading = { + // Where the opening backtick string of each code span stands. readonly codeSpans: number[] // Each emphasis pairing, keyed by where its opening delimiters start. readonly emphasis: Map + // Each highlight pairing: its opening `==` to its closing one. readonly highlights: Map // Where the `[` of each link or image stands. readonly links: number[] @@ -89,26 +87,28 @@ type Scan = { brackets: OpenBracket[] claims: Claims container: LineContainer | undefined - definitions: LinkDefinitions // Whether this content is an `!adf:link`'s text, where an image reads as its plain alt text. inLink: boolean // Link openers below this index of `brackets` were open when a link closed, so they stay literal: no link holds another. linkOpenersFrom: number - // Where `source` starts in the outermost content, the text `targets` reads. + // Where `source` starts in the outermost content, the text `shared.targets` reads. offset: number openingSpellableLink: boolean - path: ConvertErrorPath pending: string pieces: Piece[] - // Only a read-back takes a reading; parsing records none. - reading: InlineReading | undefined + shared: SharedScan source: string spans: NestedSpans - targets: TargetMemo } // What a directive's content slot inherits from the content holding it. -type SharedScan = Pick +type SharedScan = { + definitions: LinkDefinitions + path: ConvertErrorPath + // Only a read-back takes a reading; parsing records none. + reading: InlineReading | undefined + targets: TargetMemo +} const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks' const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } @@ -119,7 +119,7 @@ const unlinkable = 'the text of this link holds only nodes that take no link: wr const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' const textBreak: TextBreak = { kind: 'textBreak' } -// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer, so the caller passes its answer in. +// The outermost content: only here does a text break see both its neighbours. The writers read lines back through this file, so it takes the emitter's `openingLinkTakesDirective` as an argument and imports nothing from `emit/`. export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour, openingLinkTakesDirective: (nodes: readonly AdfNode[], path: ConvertErrorPath) => Result): Result { const scan = freshScan(source, { definitions, path, reading: undefined, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans }) const scanned = scanInline(scan) @@ -135,14 +135,14 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions, return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value }) } -// A line that fails to read still holds what the reader took before the fault. +// The reading of a line that faults still holds what the reader took before the fault. export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } { const reading: InlineReading = { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] } const scan = freshScan(line, { definitions: noDefinitions, path: [], reading, targets: targetMemo(line) }, { claims, container, inLink: false, offset: 0, spans: noSpans }) return { faulted: !scanInline(scan).ok, reading } } -function record({ reading }: Pick, entry: ReadingEntry): void { +function record({ shared: { reading } }: Scan, entry: ReadingEntry): void { if (reading === undefined) return if (entry.kind === 'emphasis') reading.emphasis.set(entry.at, { closer: entry.closer, width: entry.width }) else if (entry.kind === 'highlights') reading.highlights.set(entry.at, entry.closer) @@ -150,7 +150,7 @@ function record({ reading }: Pick, entry: ReadingEntry): void { } function freshScan(source: string, shared: SharedScan, own: Pick): Scan { - return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source } + return { ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], shared, source } } function scanInline(scan: Scan): Result { @@ -167,7 +167,7 @@ function scanInline(scan: Scan): Result { function readToken(scan: Scan, inline: InlineSource, index: number, token: InlineToken): Result { if (token.kind === 'directive') return readDirective(scan, inline, token.prefix) - if (token.kind === 'html') return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.path) + if (token.kind === 'html') return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.shared.path) const end = index + token.width switch (token.kind) { case 'autolink': @@ -221,7 +221,7 @@ function readDirective(scan: Scan, inline: InlineSource, prefix: ClaimedPrefix): const held = scan.spans.get(prefix.index) if (held !== undefined) return pushDirective(scan, held, prefix.index) const directive = readInlineDirective(inline, prefix) - if (directive.fault !== undefined) return faulted(directive.fault, scan.path) + if (directive.fault !== undefined) return faulted(directive.fault, scan.shared.path) return pushDirective(scan, directive.value, prefix.index) } @@ -236,21 +236,21 @@ function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result { const carried = readCarriedInline(span) if (carried !== undefined) { - if (carried.fault !== undefined) return faulted(carried.fault, scan.path) + if (carried.fault !== undefined) return faulted(carried.fault, scan.shared.path) return success([{ kind: 'ownMarks', node: carried.value }]) } - if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success([textBreak]) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path) + if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success([textBreak]) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.shared.path) const text = readTextDirective(span) - if (text?.fault !== undefined) return faulted(text.fault, scan.path) + if (text?.fault !== undefined) return faulted(text.fault, scan.shared.path) if (text !== undefined) return success([{ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }]) - const mark = readDirectiveMark(span.name, span.attributes, scan.path) + const mark = readDirectiveMark(span.name, span.attributes, scan.shared.path) // A node's slot is the node's own content, never the text of a link around the node. const slot = slotContent(scan, span, index, mark !== undefined && (scan.inLink || span.name === 'link')) if (!slot.ok) return slot if (mark !== undefined) return mark.ok ? directiveMarkPieces(scan, span.name, mark.value, slot.value, index) : mark const content = slot.value === undefined ? undefined : contentNodes(slot.value) - if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path) - const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path) + if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.shared.path) + const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.shared.path) if (!node.ok) return node return success([node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value }]) } @@ -258,11 +258,11 @@ function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: readonly Resolved[] | undefined, index: number): Result { const nodes = slot === undefined ? [] : contentNodes(slot) if (slot === undefined || nodes.length === 0) { - return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path) + return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.shared.path) } - if (nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) + if (nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path) if (!nodes.some((node) => marksItem(node, mark.type))) { - return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them`, scan.path) + return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them`, scan.shared.path) } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, nodes, index) : undefined if (refused !== undefined) return refused @@ -276,19 +276,18 @@ function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: read // spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `parseInlineContent` asks the emitter. function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result | undefined { const href = linkHref(nodeAttrs(mark)) - if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path) - if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path) + if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.shared.path) + if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.shared.path) if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined, 'lossless') === undefined) return undefined - if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) + if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.shared.path) scan.openingSpellableLink = true return undefined } function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result { if (span.content === undefined) return success(undefined) - const { claims, definitions, path, reading, targets } = scan const offset = scan.offset + index + contentOffset(span) - const parsed = scanInline(freshScan(span.content, { definitions, path, reading, targets }, { claims, container: undefined, inLink, offset, spans: span.spans })) + const parsed = scanInline(freshScan(span.content, scan.shared, { claims: scan.claims, container: undefined, inLink, offset, spans: span.spans })) if (!parsed.ok) return parsed return success(parsed.value.pieces) } @@ -374,7 +373,7 @@ function partText(items: readonly Inline[], scan: Scan): Result { const previous = items[index - 1] const next = items[index + 1] if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !joinsWhenRead(previous, next)) { - return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path) + return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.shared.path) } } return success(parted) @@ -464,7 +463,7 @@ function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] { function resolveTarget(scan: Scan, start: number, index: number): InlineTarget | undefined { const after = index + 1 if (scan.source.charAt(after) === '(') { - const inline = readInlineTarget(scan.targets, scan.offset + after) + const inline = readInlineTarget(scan.shared.targets, scan.offset + after) // The memo reads the outermost content; a target it reads holds here exactly when the target ends inside `source`. if (inline !== undefined && after + inline.length <= scan.source.length) return inline } @@ -472,20 +471,20 @@ function resolveTarget(scan: Scan, start: number, index: number): InlineTarget | const labelIsText = label === undefined || label.value === '' if (labelIsText && index - start > labelLimit) return undefined const name = labelIsText ? scan.source.slice(start, index) : label.value - const definition = scan.definitions.get(normalizeLabel(name)) + const definition = scan.shared.definitions.get(normalizeLabel(name)) if (definition === undefined) return undefined return { definition: () => definition, length: label?.length ?? 0 } } // An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image. function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result { - if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) + if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path) const [only] = inner if (inner.length === 1 && only?.kind === 'image') return success([{ ...only, link: definition }]) const nodes = resolveNodes(inner, scan, true) if (!nodes.ok) return nodes if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) { - return failure('unsupported-node-shape', unlinkable, scan.path) + return failure('unsupported-node-shape', unlinkable, scan.shared.path) } const images = inner.flatMap((piece) => (piece.kind === 'image' ? [piece.definition] : [])) return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(images, definition)]) @@ -499,7 +498,7 @@ function imagePieces(scan: Scan, inner: readonly Piece[], definition: LinkDefini function imageAlt(inner: readonly Piece[], scan: Scan): Result { const nodes = resolveNodes(inner.filter((piece) => piece.kind !== 'imageLink'), scan, false) if (!nodes.ok) return nodes - if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path) + if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.shared.path) return success(adfNodes(nodes.value).map(altText).join('')) } @@ -524,7 +523,7 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): const pairings = matchEmphasis(runs) for (const { closer, closerOffset, opener, openerOffset, used } of pairings) record(scan, { at: opener.offset + openerOffset, closer: closer.offset + closerOffset, kind: 'emphasis', width: used }) writeUnpaired(nodes, runs, pairings) - if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) + if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path) const paired = highlights ? pairedHighlights(pieces, nodes) : [] for (const { closerOffset, openerOffset } of paired) record(scan, { at: openerOffset, closer: closerOffset, kind: 'highlights' }) markHighlights(pieces, nodes, paired) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 02ccdba..1c1a1de 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -384,6 +384,15 @@ test('drops the mark of a run CommonMark flanking or matching cannot spell', () assert.equal(portable(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n') }) +test('drops the mark of the run that cannot close where it sits, and keeps the later run the reader would pair it with', () => { + const highlight: AdfMark = { type: 'backgroundColor' } + const strike: AdfMark = { type: 'strike' } + assert.equal(portable(paragraph(text(':', em), text('b'), text('c', em))), ':b*c*\n') + assert.equal(portable(paragraph(text(')', strong), text('b'), text('c', strong))), ')b**c**\n') + assert.equal(portable(paragraph(text('^', strike), text('b'), text('c', strike))), '^b~~c~~\n') + assert.equal(portable(paragraph(text('~', highlight), text('b'), text('日本', highlight))), '~b==日本==\n') +}) + test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => { assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n') assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n') diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 0c73a70..4a35548 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -230,7 +230,7 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver } function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback): AdfNode[] | undefined { - if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs) + if (fallback.kind === 'unspellable-run') return withoutMark(leaves, fallback.run) const mark = nodeMarks(leaves[0] ?? {})[0] if (mark === undefined || mark.type !== 'link') return undefined let last = 0 @@ -240,12 +240,6 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallb return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined } -// The runs cover disjoint leaves, so one pass drops them all. -function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { - const depths = new Map() - for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth) - return leaves.map((leaf, index) => { - const depth = depths.get(index) - return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth)) - }) +function withoutMark(leaves: readonly AdfNode[], run: MarkRun): AdfNode[] { + return leaves.map((leaf, index) => (index < run.first || index > run.last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== run.depth)))) }