3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer principles round 1: 0 🔴, 4 🟡; escape a title's backslash only where CommonMark reads an escape, check pipes in lossless syntax alone, split the title tests, state the portable escapes

This commit is contained in:
2026-10-22 21:59:11 +02:00
committed by Lillem4n
parent 2d7d8f8af6
commit 0841616352
4 changed files with 46 additions and 21 deletions
+3
View File
@@ -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
+2 -2
View File
@@ -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}"`
+3 -4
View File
@@ -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
@@ -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&amp;T & &#38; &x', 'say "hi"', 'a|b', 'a\\|b']
const spelled = ['"C:\\\\dir\\\\"', '"AT\\&amp;T & \\&#38; &x"', '"say \\"hi\\""', '"a|b"', '"a\\\\|b"']
const inCell = ['"C:\\\\dir\\\\"', '"AT\\&amp;T & \\&#38; &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&amp;T & &#38; &x')), '[t](https://e.com "AT\\&amp;T & \\&#38; &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&amp;T & &#38; &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('/&amp;')))), '[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')
})