Model CommonMark's emphasis matching, and escape the delimiter that only closes #25
@@ -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~~
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user