Model CommonMark's emphasis matching, and escape the delimiter that only closes #25

Merged
lilleman merged 3 commits from tick-2e5 into main 2026-08-27 13:17:36 +02:00
7 changed files with 76 additions and 30 deletions
Showing only changes of commit 1108d36d1f - Show all commits
+3 -2
View File
@@ -122,8 +122,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text - Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
only ones in play, and a pair that matching hands to another delimiter rides the carry instead. only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
`matchEmphasis` is a line-for-line transcription of the reference `process_emphasis` and stays one `matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and
function: split into named steps it drifts from the algorithm whose fidelity is the whole point. opener search stay whole: broken into named steps they drift from the algorithm being faithful is
the whole point of.
- 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.
@@ -80,6 +80,68 @@
} }
], ],
"type": "paragraph" "type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strike"
}
],
"text": "un",
"type": "text"
},
{
"marks": [
{
"type": "strike"
},
{
"type": "em"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "strike"
},
{
"type": "em"
},
{
"type": "strong"
}
],
"text": "b",
"type": "text"
},
{
"marks": [
{
"type": "strike"
},
{
"type": "strong"
}
],
"text": "c",
"type": "text"
},
{
"marks": [
{
"type": "strike"
}
],
"text": "istic",
"type": "text"
}
],
"type": "paragraph"
} }
], ],
"type": "doc", "type": "doc",
@@ -1,3 +1,5 @@
un:adf{json="{\"marks\":[{\"type\":\"em\"}],\"text\":\"a\",\"type\":\"text\"}"}:adf{json="{\"marks\":[{\"type\":\"em\"},{\"type\":\"strong\"}],\"text\":\"b\",\"type\":\"text\"}"}**c**istic un:adf{json="{\"marks\":[{\"type\":\"em\"}],\"text\":\"a\",\"type\":\"text\"}"}:adf{json="{\"marks\":[{\"type\":\"em\"},{\"type\":\"strong\"}],\"text\":\"b\",\"type\":\"text\"}"}**c**istic
***a*b**:adf{json="{\"marks\":[{\"type\":\"strong\"},{\"type\":\"em\"}],\"text\":\"c\",\"type\":\"text\"}"} ***a*b**:adf{json="{\"marks\":[{\"type\":\"strong\"},{\"type\":\"em\"}],\"text\":\"c\",\"type\":\"text\"}"}
~~un~~:adf{json="{\"marks\":[{\"type\":\"strike\"},{\"type\":\"em\"}],\"text\":\"a\",\"type\":\"text\"}"}:adf{json="{\"marks\":[{\"type\":\"strike\"},{\"type\":\"em\"},{\"type\":\"strong\"}],\"text\":\"b\",\"type\":\"text\"}"}~~**c**istic~~
+6 -19
View File
@@ -297,32 +297,19 @@ test('escapes a literal delimiter that would merge with an emitted one', () => {
assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n') assert.equal(emitted(marked('x', { attrs: { href: 'https://example.com/' }, type: 'link' }), { text: '{}', type: 'text' }), '[x](https://example.com/){}\n')
}) })
test('escapes a literal delimiter run that flanks either way', () => { test('escapes a literal delimiter run that only closes', () => {
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) const emitted = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text, type: 'text' }))))
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) assert.equal(emitted('a* b'), 'a\\* b\n')
assert.equal(emitted({ text: 'un', type: 'text' }, marked('a* b', { type: 'em' }), { text: 'istic', type: 'text' }), 'un*a\\* b*istic\n') assert.equal(emitted('2 * 3'), '2 * 3\n')
assert.equal(emitted(marked('a~~ b', { type: 'strike' })), '~~a\\~~ b~~\n')
assert.equal(emitted(marked('a_ b', { type: 'em' })), '_a\\_ b_\n')
assert.equal(emitted({ text: 'a* b', type: 'text' }), 'a\\* b\n')
assert.equal(emitted({ text: '2 * 3', type: 'text' }), '2 * 3\n')
assert.equal(emitted({ text: 'snake_case_name', type: 'text' }), 'snake_case_name\n')
}) })
test('carries a mark run CommonMark matching pairs elsewhere', () => { test("spells a mark run CommonMark's matching pairs as written", () => {
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const em: AdfMark = { type: 'em' } const em: AdfMark = { type: 'em' }
const strong: AdfMark = { type: 'strong' } const strong: AdfMark = { type: 'strong' }
assert.equal(
emitted({ text: 'un', type: 'text' }, marked('a', em), marked('b', em, strong), marked('c', strong), { text: 'istic', type: 'text' }),
'un:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}' +
':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"},{\\"type\\":\\"strong\\"}],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}**c**istic\n',
)
assert.equal(
emitted(marked('a', strong, em), marked('b', strong), marked('c', strong, em)),
'***a*b**:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"},{\\"type\\":\\"em\\"}],\\"text\\":\\"c\\",\\"type\\":\\"text\\"}"}\n',
)
assert.equal(emitted({ text: 'un', type: 'text' }, marked('a', em, strong), { text: 'istic', type: 'text' }), 'un***a***istic\n') assert.equal(emitted({ text: 'un', type: 'text' }, marked('a', em, strong), { text: 'istic', type: 'text' }), 'un***a***istic\n')
assert.equal(emitted({ text: 're', type: 'text' }, marked('structure', strong), { text: ' the code', type: 'text' }), 're**structure** the code\n')
assert.equal( assert.equal(
emitted({ text: 'un', type: 'text' }, marked('a', em), marked('b', em, strong), marked('c', em), { text: 'istic', type: 'text' }), emitted({ text: 'un', type: 'text' }, marked('a', em), marked('b', em, strong), marked('c', em), { text: 'istic', type: 'text' }),
'un*a**b**c*istic\n', 'un*a**b**c*istic\n',
+1 -2
View File
@@ -72,7 +72,6 @@ function roundTripFixtures(): { name: string; path: string }[] {
) )
} }
// One spelling for two documents is a round-trip break no parser can undo, and no parser is needed to see it.
test('no two round-trip documents share one markdown spelling', () => { test('no two round-trip documents share one markdown spelling', () => {
const spellings = new Map<string, string>() const spellings = new Map<string, string>()
for (const fixture of roundTripFixtures()) { for (const fixture of roundTripFixtures()) {
@@ -89,7 +88,7 @@ test('no round-trip fixture repeats the document another holds', () => {
const documents = new Map<string, string>() const documents = new Map<string, string>()
for (const fixture of roundTripFixtures()) { for (const fixture of roundTripFixtures()) {
const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8')) const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8'))
assert.ok(isJsonValue(parsed)) assert.ok(isJsonValue(parsed), `${fixture.name} does not hold a JSON value`)
const document = serializeCanonicalJson(parsed, 'compact') const document = serializeCanonicalJson(parsed, 'compact')
assert.equal(documents.get(document), undefined, `${fixture.name} repeats the document ${documents.get(document)} holds`) assert.equal(documents.get(document), undefined, `${fixture.name} repeats the document ${documents.get(document)} holds`)
documents.set(document, fixture.name) documents.set(document, fixture.name)
+2 -4
View File
@@ -1,8 +1,8 @@
import { isUnicodeWhitespace } from './commonmark-grammar.ts' import { isUnicodeWhitespace } from './commonmark-grammar.ts'
export type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number } type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number }
export type EmphasisPairing<Run> = { closer: Run; closerOffset: number; opener: Run; openerOffset: number; used: number } type EmphasisPairing<Run> = { closer: Run; closerOffset: number; opener: Run; openerOffset: number; used: number }
type Candidate<Run> = { type Candidate<Run> = {
head: number head: number
@@ -16,7 +16,6 @@ type Candidate<Run> = {
const unicodePunctuation = /[\p{P}\p{S}]/u const unicodePunctuation = /[\p{P}\p{S}]/u
// spec/flavour.md, Canonical form: CommonMark's own can-open and can-close, which `~` follows too.
export function delimiterFlags(character: string, before: string, after: string): { canClose: boolean; canOpen: boolean } { export function delimiterFlags(character: string, before: string, after: string): { canClose: boolean; canOpen: boolean } {
const left = isLeftFlanking(before, after) const left = isLeftFlanking(before, after)
const right = isRightFlanking(before, after) const right = isRightFlanking(before, after)
@@ -28,7 +27,6 @@ export function isWordCharacter(character: string): boolean {
return character !== '' && !isWhitespace(character) && !isPunctuation(character) return character !== '' && !isWhitespace(character) && !isPunctuation(character)
} }
// Flanking decides which delimiters may pair; this decides which ones do, and a pair it leaves out reads back as another document.
export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] { export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] {
const pairings: EmphasisPairing<Run>[] = [] const pairings: EmphasisPairing<Run>[] = []
const bottoms = new Map<string, Candidate<Run> | undefined>() const bottoms = new Map<string, Candidate<Run> | undefined>()
-3
View File
@@ -257,9 +257,6 @@ function runLength(scan: string, index: number): number {
return length return length
} }
function charAt(text: string, index: number): string { function charAt(text: string, index: number): string {
return index < 0 ? '' : text.charAt(index) return index < 0 ? '' : text.charAt(index)
} }