Escape the brace that would close a directive, and keep a link from restoring the line break a directive forbids
CI / gate (push) Successful in 4s
CI / gate (push) Successful in 4s
This commit is contained in:
@@ -111,6 +111,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
|
|||||||
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
|
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
|
||||||
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
||||||
the spot.
|
the spot.
|
||||||
|
- Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
|
||||||
|
refuses it — which is how the whitespace carry finds a line edge.
|
||||||
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell —
|
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell —
|
||||||
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the
|
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the
|
||||||
general form owns the refusal. Refusing there refuses a document the general form spells.
|
general form owns the refusal. Refusing there refuses a document the general form spells.
|
||||||
|
|||||||
+8
-5
@@ -63,7 +63,8 @@ literal bracket. Whitespace at either edge of `[content]`, space or tab, is part
|
|||||||
survives inline parsing. Each section below says whether content is required. `:` opens a
|
survives inline parsing. Each section below says whether content is required. `:` opens a
|
||||||
directive only when the name is followed immediately by `[` or `{`, and `{attrs}` must follow
|
directive only when the name is followed immediately by `[` or `{`, and `{attrs}` must follow
|
||||||
`]` (or the name) with no gap — anything else (`10:30`, `:smile:`, a stray `{…}` in text) is
|
`]` (or the name) with no gap — anything else (`10:30`, `:smile:`, a stray `{…}` in text) is
|
||||||
literal text.
|
literal text. An inline directive binds as a unit before bracket matching, the way a code span
|
||||||
|
does: a `]` or `(` inside its `{attrs}` is the directive's, never the enclosing content's.
|
||||||
|
|
||||||
**Container block**:
|
**Container block**:
|
||||||
|
|
||||||
@@ -99,7 +100,8 @@ form, and omits empty `{attrs}` except where the `{` itself claims the directive
|
|||||||
(`:hardBreak{}`).
|
(`:hardBreak{}`).
|
||||||
|
|
||||||
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
||||||
directive syntax — the leading `:` of a would-be directive, `]` inside content; outside code
|
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.
|
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
|
**Malformed directives are error results**, named: an unclosed container at end of input, a body
|
||||||
@@ -214,9 +216,10 @@ One header row plus plain inline cells is a pipe table; anything richer is the d
|
|||||||
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
|
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
|
||||||
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
||||||
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
||||||
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` in a code span,
|
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the inline
|
||||||
link destination or link title: backslash escapes are inert in everything the inline layer spells
|
layer spells as syntax — a code span, a link destination or title, an inline directive's
|
||||||
as syntax, so pipe form cannot spell that pipe and the table takes the directive form. A pipe table
|
attributes: backslash escapes are inert there, so pipe form cannot spell that pipe and the table
|
||||||
|
takes the directive form. A pipe table
|
||||||
parses back to exactly that shape.
|
parses back to exactly that shape.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ test('escapes a literal delimiter that would merge with an emitted one', () => {
|
|||||||
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n')
|
||||||
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
|
assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n')
|
||||||
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\n')
|
assert.equal(emitted({ text: '!', type: 'text' }, marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' })), '\\\n')
|
||||||
|
assert.equal(emitted(marked('x', { type: 'underline' }), { text: '{}', type: 'text' }), ':underline[x]\\{}\n')
|
||||||
|
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }, { text: '{color=red}', type: 'text' }), ':status[]\\{color=red}\n')
|
||||||
|
assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('escapes a hyphen underline a hard break would expose', () => {
|
test('escapes a hyphen underline a hard break would expose', () => {
|
||||||
@@ -377,9 +380,9 @@ test('spells a table as a pipe table only where every row and cell is plain', ()
|
|||||||
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
||||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
||||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
||||||
const piped = (node: AdfNode): boolean => directive(adfToMarkdown(table(row(cell('tableHeader', { content: [node], type: 'paragraph' })))))
|
const fallsBack = (node: AdfNode): boolean => directive(adfToMarkdown(table(row(cell('tableHeader', { content: [node], type: 'paragraph' })))))
|
||||||
assert.ok(piped({ marks: [{ type: 'code' }], text: 'a|b', type: 'text' }))
|
assert.ok(fallsBack({ marks: [{ type: 'code' }], text: 'a|b', type: 'text' }))
|
||||||
assert.ok(piped({ attrs: { style: 'a|b' }, type: 'status' }))
|
assert.ok(fallsBack({ attrs: { style: 'a|b' }, type: 'status' }))
|
||||||
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -425,6 +428,15 @@ test('spells the directive marks around the longest run they cover', () => {
|
|||||||
assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_:underline[x]_\n')
|
assert.equal(emitted(marked('x', { type: 'em' }, underline)), '_:underline[x]_\n')
|
||||||
assert.equal(emitted(marked('x', underline, { type: 'em' })), ':underline[_x_]\n')
|
assert.equal(emitted(marked('x', underline, { type: 'em' })), ':underline[_x_]\n')
|
||||||
assert.equal(emitted(marked('a', underline), { marks: [underline], type: 'hardBreak' }, marked('b', underline)), ':underline[a:hardBreak{}b]\n')
|
assert.equal(emitted(marked('a', underline), { marks: [underline], type: 'hardBreak' }, marked('b', underline)), ':underline[a:hardBreak{}b]\n')
|
||||||
|
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
|
||||||
|
assert.equal(
|
||||||
|
emitted(marked('a', underline, link), { marks: [underline, link], type: 'hardBreak' }, marked('b', underline, link)),
|
||||||
|
':underline[[a:hardBreak{}b](https://example.com/)]\n',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
emitted(marked('a', link), { marks: [link], type: 'hardBreak' }, marked('b', link)),
|
||||||
|
'[a\\\nb](https://example.com/)\n',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a mark directive attribute no spelling holds', () => {
|
test('refuses a mark directive attribute no spelling holds', () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const delimiters = ['*', '_', '`', '~']
|
|||||||
|
|
||||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||||
const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/
|
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
|
||||||
const linkOpener = /\](?=[([:])/
|
const linkOpener = /\](?=[([:])/
|
||||||
const unicodePunctuation = /[\p{P}\p{S}]/u
|
const unicodePunctuation = /[\p{P}\p{S}]/u
|
||||||
|
|
||||||
@@ -115,6 +115,7 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly
|
|||||||
function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
function mergesWithSyntax(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean {
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(escapings[index + 1])
|
if (character === '!') return scan.charAt(index + 1) === '[' && isSyntax(escapings[index + 1])
|
||||||
|
if (character === '{') return scan.charAt(index - 1) === ']' && isSyntax(escapings[index - 1])
|
||||||
if (!delimiters.includes(character)) return false
|
if (!delimiters.includes(character)) return false
|
||||||
return touchesSyntax(scan, escapings, index, -1) || touchesSyntax(scan, escapings, index, 1)
|
return touchesSyntax(scan, escapings, index, -1) || touchesSyntax(scan, escapings, index, 1)
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ function claimsCharacter(scan: string, index: number, inBrackets: boolean, conta
|
|||||||
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
|
||||||
if (character === '&') return startsEntityReference(rest)
|
if (character === '&') return startsEntityReference(rest)
|
||||||
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
|
||||||
if (character === ':') return inlineDirective.test(rest)
|
if (character === ':') return inlineDirectiveOpener.test(rest)
|
||||||
if (character === '[') return linkOpener.test(rest)
|
if (character === '[') return linkOpener.test(rest)
|
||||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||||
if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped)
|
if (character === '*' || character === '_' || character === '~') return opensEmphasis(scan, index, escaped)
|
||||||
|
|||||||
+11
-13
@@ -9,13 +9,11 @@ import { longestBacktickRun } from './backtick-runs.ts'
|
|||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||||
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
|
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
|
||||||
|
|
||||||
type Brackets = 'directive' | 'link' | 'none'
|
|
||||||
|
|
||||||
type InlineContext = {
|
type InlineContext = {
|
||||||
atBlockEnd: boolean
|
atBlockEnd: boolean
|
||||||
brackets: Brackets
|
bracketed: boolean
|
||||||
container: LineContainer
|
|
||||||
path: ConvertErrorPath
|
path: ConvertErrorPath
|
||||||
|
spansLines: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
||||||
@@ -47,7 +45,7 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, brackets: 'none', container, path })
|
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, path, spansLines: container === 'paragraph' })
|
||||||
if (!segments.ok) return segments
|
if (!segments.ok) return segments
|
||||||
return success(carryStrippedWhitespace(segments.value))
|
return success(carryStrippedWhitespace(segments.value))
|
||||||
}
|
}
|
||||||
@@ -69,7 +67,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer
|
|||||||
return success(line)
|
return success(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Inline nodes: whitespace CommonMark strips.
|
// spec/flavour.md, Inline nodes.
|
||||||
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||||
const carried: InlineSegment[] = []
|
const carried: InlineSegment[] = []
|
||||||
for (const [index, segment] of segments.entries()) {
|
for (const [index, segment] of segments.entries()) {
|
||||||
@@ -109,7 +107,7 @@ function syntax(text: string): InlineSegment {
|
|||||||
|
|
||||||
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
|
||||||
if ((node.content ?? []).length > 0 || node.text !== undefined) {
|
if ((node.content ?? []).length > 0 || node.text !== undefined) {
|
||||||
return failure('unsupported-node-shape', `a ${node.type} node carries neither content nor text`, path)
|
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text`, path)
|
||||||
}
|
}
|
||||||
return success(null)
|
return success(null)
|
||||||
}
|
}
|
||||||
@@ -165,7 +163,7 @@ function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertError
|
|||||||
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path)
|
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
const empty = refuseContentAndText(node, path)
|
const empty = refuseContentAndText(node, path)
|
||||||
if (!empty.ok) return empty
|
if (!empty.ok) return empty
|
||||||
if (context.container === 'paragraph' && !context.atBlockEnd && context.brackets !== 'directive') return success([syntax('\\\n')])
|
if (context.spansLines && !context.atBlockEnd) return success([syntax('\\\n')])
|
||||||
return success([syntax(spellLeafDirective('hardBreak', ''))])
|
return success([syntax(spellLeafDirective('hardBreak', ''))])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +184,11 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: Co
|
|||||||
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
|
||||||
const unspelled = Object.keys(node.attrs ?? {})[0]
|
const unspelled = Object.keys(node.attrs ?? {})[0]
|
||||||
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path)
|
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path)
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
|
||||||
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries no content', path)
|
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path)
|
||||||
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
|
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||||
const escaping: InlineEscaping = context.brackets === 'none' ? 'backslash' : 'bracketed'
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||||
return success(parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })))
|
return success(parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })))
|
||||||
}
|
}
|
||||||
@@ -205,7 +203,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
|
|||||||
if (vocabulary === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
if (vocabulary === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
|
||||||
const attributes = spellMarkAttributes(mark, vocabulary, path)
|
const attributes = spellMarkAttributes(mark, vocabulary, path)
|
||||||
if (!attributes.ok) return attributes
|
if (!attributes.ok) return attributes
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, brackets: 'directive' })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)])
|
return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)])
|
||||||
}
|
}
|
||||||
@@ -270,7 +268,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index
|
|||||||
if (!destination.ok) return destination
|
if (!destination.ok) return destination
|
||||||
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
|
||||||
if (!spelledTitle.ok) return spelledTitle
|
if (!spelledTitle.ok) return spelledTitle
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, brackets: 'link' })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
return success([syntax('['), ...inner.value, syntax(`](${destination.value}${spelledTitle.value})`)])
|
return success([syntax('['), ...inner.value, syntax(`](${destination.value}${spelledTitle.value})`)])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user