diff --git a/corpus/round-trip/combinations/attribute-escapes.json b/corpus/round-trip/combinations/attribute-escapes.json index 594712a..3ec83d2 100644 --- a/corpus/round-trip/combinations/attribute-escapes.json +++ b/corpus/round-trip/combinations/attribute-escapes.json @@ -43,7 +43,7 @@ }, { "attrs": { - "url": "b](c" + "url": "b](c)" }, "type": "inlineCard" } diff --git a/corpus/round-trip/combinations/attribute-escapes.md b/corpus/round-trip/combinations/attribute-escapes.md index 5e5920d..534efe2 100644 --- a/corpus/round-trip/combinations/attribute-escapes.md +++ b/corpus/round-trip/combinations/attribute-escapes.md @@ -4,4 +4,4 @@ a `b :inlineCard{url="c\u0060d"} ::extension {extensionKey=jira parameters="{\"jql\":\"a\u007cb \u0060c\u0060 \u0026 d \u003c e\"}"} -[a:inlineCard{url="b](c"} +[a:inlineCard{url="b](c)"} diff --git a/spec/flavour.md b/spec/flavour.md index 51eb85f..1dd9f6f 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -94,9 +94,10 @@ container is open, a named error otherwise. valid — no attributes. A bare value matches `[A-Za-z0-9_-]+`; any other value is double-quoted with JSON string escaping (`\"` `\\` `\n` `\t` `\uXXXX`, …) — total over Unicode, and raw newlines never appear inside quotes. A quoted value also escapes `` ` ``, `&`, -`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`; `*`, `_`, `~`, `[` and `(` resolve after a -directive binds and stay raw. The closing `}` is the first one outside quotes, since a quoted value -holds `}` unescaped. All values are strings at the grammar level; each node's section assigns types. +`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`; `*`, `_`, `~`, `[`, `]` and `(` resolve +after a directive binds and stay raw. The closing `}` is the first one outside quotes, since a +quoted value holds `}` unescaped. All values are strings at the grammar level; each node's section +assigns types. Canonical form orders keys alphabetically, spells values bare wherever allowed, escapes inside quotes in the shortest form each escape has, and omits empty `{attrs}` except where the `{` itself claims the directive (`:hardBreak{}`). @@ -220,8 +221,8 @@ One header row plus plain inline cells is a pipe table; anything richer is the d 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, mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the -inline layer spells as syntax — a code span, a link destination or title: pipe form cannot spell -that pipe and the table takes the directive form. A pipe table parses back to exactly that shape. +inline layer spells as syntax: a code span, an autolink, a link destination or title. A `|` there +takes the directive form instead. A pipe table parses back to exactly that shape. ``` | Part | Qty | diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index 9e5aa0d..d41b5e4 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -255,6 +255,11 @@ test('escapes a literal delimiter that would merge with an emitted one', () => { assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' }))))), 'unspellable-mark') 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({ text: '`a', type: 'text' }, marked('b', { type: 'code' })), '\\`a`b`\n') + assert.equal( + emitted({ text: '' }, type: 'inlineCard' }), + '\\?@[\\\]^_`{|}~]/ const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/ -const linkTextCloser = /[([:]/ +const followsLinkText = /[([:]/ const unicodePunctuation = /[\p{P}\p{S}]/u export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { @@ -179,11 +179,11 @@ function claimsCharacter( return false } -// A `]` the emitter spelled closes the construct it belongs to, which binds first — never link text. +// A `]` the emitter spelled sits inside a construct that binds before link text does. function opensLink(scan: string, escapings: readonly (InlineEscaping | undefined)[], index: number): boolean { for (let cursor = index + 1; cursor < scan.length; cursor += 1) { if (scan.charAt(cursor) !== ']' || isSyntax(escapings[cursor])) continue - if (linkTextCloser.test(scan.charAt(cursor + 1))) return true + if (followsLinkText.test(scan.charAt(cursor + 1))) return true } return false }