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:
@@ -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
|
||||
|
||||
@@ -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}"`
|
||||
|
||||
@@ -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('`)], '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&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)), '[](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)), '[](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')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user