From 0841616352305ee17a84ef1c498cf39fee90a7cb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 22 Oct 2026 21:59:11 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20principles=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1;=20escape=20a=20title's=20back?= =?UTF-8?q?slash=20only=20where=20CommonMark=20reads=20an=20escape,=20chec?= =?UTF-8?q?k=20pipes=20in=20lossless=20syntax=20alone,=20split=20the=20tit?= =?UTF-8?q?le=20tests,=20state=20the=20portable=20escapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++ src/markdown/commonmark/link-syntax.ts | 4 +- src/markdown/emit/inline-line.ts | 7 ++- .../portable/adf-to-portable-markdown.test.ts | 53 +++++++++++++------ 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0628d14..14455dc 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,9 @@ saving what this pair read replaces mentions, attachments and macros with text. - Text that would read as a marker takes a backslash: `==` wherever it could open or close a highlight, `[!…]` opening a quote, asterisks that would read as a rule after a `* ` marker (`* \**`), and `[x]` or `[ ]` opening any list item, since GitHub reads that marker per item. +- A link title takes a backslash before a quote, before a backslash that ends the title or that + CommonMark would read as an escape, before an `&` opening an entity reference such as `&`, and + in a table cell before a `|`: `[docs](https://example.com "AT\&T")`. - A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its position, the same on every read. Join markdown bound for one document and read it once: the same diff --git a/src/markdown/commonmark/link-syntax.ts b/src/markdown/commonmark/link-syntax.ts index a5cea8d..eb41d2b 100644 --- a/src/markdown/commonmark/link-syntax.ts +++ b/src/markdown/commonmark/link-syntax.ts @@ -5,7 +5,7 @@ export type LinkDefinition = { destination: string; title?: string } export type LinkPart = { length: number; value: string } -// spec/flavour.md, Marks: the lossless flavour escapes a quote alone and writes any other title CommonMark reads otherwise as a directive; the portable flavour has none. +// The lossless flavour escapes a quote alone, writing a title holding a backslash or an entity reference as a directive (spec/flavour.md, Marks); the portable flavour writes no directive, so it escapes those too, and a table cell's `|` (README, Portable markdown). export type TitleEscapes = 'lossless' | 'portable' | 'portable-table-cell' const bracketedDestination = /<((?:[^\n<>\\]|\\[^\n])*)>/y @@ -125,7 +125,7 @@ function spellTitle(title: string, escapes: TitleEscapes): string | undefined { let spelled = '' for (let index = 0; index < title.length; index += 1) { const character = title.charAt(index) - const escaped = character === '\\' || character === '"' || (character === '&' && readEntityReference(title, index) !== undefined) || (character === '|' && escapes === 'portable-table-cell') + const escaped = (character === '\\' && (backslashEscape(title, index) !== undefined || index === title.length - 1)) || character === '"' || (character === '&' && readEntityReference(title, index) !== undefined) || (character === '|' && escapes === 'portable-table-cell') spelled += escaped ? `\\${character}` : character } return ` "${spelled}"` diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 419b091..c198da7 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -4,7 +4,6 @@ import type { LineContainer } from '../line-container.ts' import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts' import type { WrittenFlavour } from '../flavour.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' -import { barePipeCells } from '../pipe-table-syntax.ts' import { carriedInline } from '../opaque-carry.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' @@ -67,9 +66,8 @@ export function portableLineFallback(nodes: readonly AdfNode[], container: LineC export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined { const emitted = emitLine(nodes, 'table-cell', path, flavour) if (!emitted.ok) return undefined - // spec/flavour.md, Tables: the lossless flavour writes a table holding a `|` in syntax as directives; the portable flavour's only such `|` is in a title, escaped. - const splitsCell = (syntax: string): boolean => (flavour === 'lossless' ? syntax.includes('|') : barePipeCells(syntax) !== undefined) - if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && splitsCell(segment.text))) return undefined + // spec/flavour.md, Tables; the portable reduction leaves a `|` in syntax only escaped, in a link title. + if (flavour === 'lossless' && emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined return emitted.value.line } @@ -78,6 +76,7 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ const destination = spellDestination(url) if (destination === undefined) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] + // Both flavours read an image's description with highlights off, so the lossless flavour's escapes read back in either. if (link === undefined) return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line const target = spellLinkTarget(link.destination, link.title, titleEscapes(flavour, 'paragraph')) if (target === undefined) return undefined diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 7cefc52..02ccdba 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -323,20 +323,43 @@ test('keeps a table as a pipe table headed by its first row, one line per cell', assert.equal(portable(node('table', {}, row())), '') }) -test('escapes a backslash, an ampersand opening an entity reference and, in a table cell, a pipe in a link title, which reads back', () => { - const titles = ['C:\\dir\\', 'AT&T & & &x', 'say "hi"', 'a|b', 'a\\|b'] - const spelled = ['"C:\\\\dir\\\\"', '"AT\\&T & \\& &x"', '"say \\"hi\\""', '"a|b"', '"a\\\\|b"'] - const inCell = ['"C:\\\\dir\\\\"', '"AT\\&T & \\& &x"', '"say \\"hi\\""', '"a\\|b"', '"a\\\\\\|b"'] - for (const [index, title] of titles.entries()) { - const linked = paragraph(text('t', link('https://e.com', title))) - const table: AdfNode = { content: [row(cell('tableHeader', linked))], type: 'table' } - assert.equal(portable(linked), `[t](https://e.com ${spelled[index]})\n`) - assert.equal(portable(table), `| [t](https://e.com ${inCell[index]}) |\n| --- |\n`) - assert.deepEqual(portableMarkdownToAdf(portable(linked)), { ok: true, value: document(linked) }) - assert.deepEqual(portableMarkdownToAdf(portable(table)), { ok: true, value: document(table) }) - } - const image: AdfNode = { attrs: { alt: 'Moon', type: 'external', url: 'https://e.com/moon.png' }, marks: [link('https://e.com', 'C:\\dir')], type: 'media' } - assert.equal(portable(node('mediaSingle', { layout: 'center' }, image)), '[![Moon](https://e.com/moon.png)](https://e.com "C:\\\\dir")\n') +function titledLink(title: string): AdfNode { + return paragraph(text('t', link('https://e.com', title))) +} + +function headerOnly(content: AdfNode): AdfNode { + return { content: [row(cell('tableHeader', content))], type: 'table' } +} + +function assertTitleReadsBack(title: string): void { + for (const content of [titledLink(title), headerOnly(titledLink(title))]) assert.deepEqual(portableMarkdownToAdf(portable(content)), { ok: true, value: document(content) }, title) +} + +test('escapes a quote, a backslash CommonMark would read as an escape and an ampersand opening an entity reference in a link title', () => { + assert.equal(portable(titledLink('say "hi"')), '[t](https://e.com "say \\"hi\\"")\n') + assert.equal(portable(titledLink('C:\\dir\\')), '[t](https://e.com "C:\\dir\\\\")\n') + assert.equal(portable(titledLink('\\"\\&x \\|')), '[t](https://e.com "\\\\\\"\\\\&x \\\\|")\n') + assert.equal(portable(titledLink('AT&T & & &x')), '[t](https://e.com "AT\\&T & \\& &x")\n') +}) + +test('escapes a pipe in a link title in a table cell, and only there', () => { + assert.equal(portable(titledLink('a|b')), '[t](https://e.com "a|b")\n') + assert.equal(portable(headerOnly(titledLink('a|b'))), '| [t](https://e.com "a\\|b") |\n| --- |\n') + assert.equal(portable(headerOnly(titledLink('a\\|b'))), '| [t](https://e.com "a\\\\\\|b") |\n| --- |\n') +}) + +test('reads each escaped link title back to the same title, in a paragraph and in a table cell', () => { + assertTitleReadsBack('say "hi"') + assertTitleReadsBack('C:\\dir\\') + assertTitleReadsBack('\\"\\&x \\|') + assertTitleReadsBack('AT&T & & &x') + assertTitleReadsBack('a|b') + assertTitleReadsBack('a\\|b') +}) + +test('escapes a linked image\'s title as a paragraph\'s link title', () => { + const image: AdfNode = { attrs: { alt: 'Moon', type: 'external', url: 'https://e.com/moon.png' }, marks: [link('https://e.com', 'a\\|b')], type: 'media' } + assert.equal(portable(node('mediaSingle', { layout: 'center' }, image)), '[![Moon](https://e.com/moon.png)](https://e.com "a\\\\|b")\n') }) test('keeps code, em, link, strike and strong and drops every other mark, and code text every mark but link, keeping its text', () => { @@ -349,7 +372,7 @@ test('keeps code, em, link, strike and strong and drops every other mark, and co test('percent-encodes a link href no CommonMark escape writes until one does', () => { assert.equal(portable(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n') - assert.equal(portable(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com "a\\\\b")\n') + assert.equal(portable(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com "a\\b")\n') assert.equal(portable(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n') })