Read the emphasis, the links and the lone image CommonMark spells #34

Merged
lilleman merged 4 commits from tick-3e into main 2026-08-31 22:33:55 +02:00
9 changed files with 166 additions and 102 deletions
Showing only changes of commit 2245ab01a9 - Show all commits
+5 -2
View File
@@ -148,7 +148,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
- `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`, - `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`,
`html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both `html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both
directions read. A construct's reader lives in that root beside the regex the emitter escapes directions read. A construct's reader lives in that root beside the regex the emitter escapes
against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`. A rule both against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is
part of a construct the root already holds — a grammar stays in one file rather than splitting
across the seam. A rule both
directions must answer alike — whether a list marker interrupts a paragraph — is one function directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. there too, never a copy per direction, however conservative the copy would be.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
@@ -180,7 +182,8 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
validation or exported validator — a refusal that keeps the round-trip is not schema validation, validation or exported validator — a refusal that keeps the round-trip is not schema validation,
so the one a spelled node carrying the same mark type twice earns stays, no shipped CSS (§4), no so the one a spelled node carrying the same mark type twice earns stays, and input nesting a
spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS (§4), no
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal. figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
+32
View File
@@ -0,0 +1,32 @@
import type { AdfMark, AdfNode } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark)
}
// AGENTS.md §2: adjacent text nodes carrying identical marks are one node.
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && sameMarks(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(previous.marks ?? []) === marksKey(node.marks ?? [])
}
function marksKey(marks: readonly AdfMark[]): string {
return marks.map(markKey).join('\n')
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { runLength } from './emphasis-matching.ts'
export function backtickRun(text: string, index: number): number { export function backtickRun(text: string, index: number): number {
let length = 0 return text.charAt(index) === '`' ? runLength(text, index) : 0
while (text.charAt(index + length) === '`') length += 1
return length
} }
// Where the run of exactly `opener` backticks closing a code span begins, `undefined` where none does. // Where the run of exactly `opener` backticks closing a code span begins, `undefined` where none does.
+4 -1
View File
@@ -56,7 +56,10 @@ const pipeClaim = /^\|/
const bulletListOpener = /^[*+-](?:[ \t]|$)/ const bulletListOpener = /^[*+-](?:[ \t]|$)/
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip. // A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip.
const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, /^:{2,}/, pipeClaim] const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFenceOpener, /^:{2,}/, pipeClaim]
const emailAutolink = /<[^\s<>@]+@[^\s<>@]+>/y // CommonMark 0.31.2, Autolinks: the email production, whose label may not open or close with a hyphen.
const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
const orderedListOpener = /^(\d{1,9})([.)])(?:[ \t]|$)/ const orderedListOpener = /^(\d{1,9})([.)])(?:[ \t]|$)/
const setextUnderline = /^(=+|-+)[ \t]*$/ const setextUnderline = /^(=+|-+)[ \t]*$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
+1 -5
View File
@@ -9,7 +9,7 @@ import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts' import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts'
import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
@@ -297,7 +297,3 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range
return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] }) return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] })
} }
function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
if (candidate.type !== mark.type) return false
return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact')
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { isUnicodeWhitespace } from './commonmark-grammar.ts'
type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number } type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number }
type EmphasisPairing<Run> = { closer: Run; closerOffset: number; opener: Run; openerOffset: number; used: number } export type EmphasisPairing<Run> = { closer: Run; closerOffset: number; opener: Run; openerOffset: number; used: number }
type Candidate<Run> = { type Candidate<Run> = {
head: number head: number
+95 -72
View File
@@ -1,11 +1,12 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] } export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
@@ -13,13 +14,14 @@ export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
type Pairing = EmphasisPairing<Run>
type Piece = Bracket | { kind: 'nodes'; nodes: AdfNode[] } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } type Piece = Bracket | { kind: 'nodes'; nodes: AdfNode[] } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number } type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
type Scan = { definitions: LinkDefinitions; image: AdfNode | undefined; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } type Scan = { definitions: LinkDefinitions; image: AdfNode | undefined; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
const emphasisCharacters = '*_~'
const hardBreakSpaces = / {2,}$/ const hardBreakSpaces = / {2,}$/
const imageAlone = 'an image fits only as a paragraph of its own' const imageAlone = 'an image fits only as a paragraph of its own'
const trailingSpace = /[ \t]+$/ const trailingSpace = /[ \t]+$/
@@ -28,74 +30,101 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
const scan: Scan = { definitions, image: undefined, path, pending: '', pieces: [], source } const scan: Scan = { definitions, image: undefined, path, pending: '', pieces: [], source }
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
const character = source.charAt(index) switch (source.charAt(index)) {
if (character === '\\' && source.charAt(index + 1) === '\n') { case '\\':
index = readBackslash(scan, index)
break
case '\n':
index = readLineEnding(scan, index)
break
case '`':
index = readBackticks(scan, index)
break
case '<': {
const angle = readAngle(scan, index)
if (!angle.ok) return angle
index = angle.value
break
}
case '!':
case '[':
index = openBracket(scan, index)
break
case ']': {
const closed = closeBracket(scan, index)
if (!closed.ok) return closed
index = closed.value
break
}
case '*':
case '_':
case '~':
index = readDelimiterRun(scan, index)
break
default:
scan.pending += source.charAt(index)
index += 1
}
}
flush(scan, true)
return assemble(scan)
}
function readBackslash(scan: Scan, index: number): number {
if (scan.source.charAt(index + 1) === '\n') {
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash. // CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
flush(scan, false) flush(scan, false)
pushNode(scan, { type: 'hardBreak' }) pushNode(scan, { type: 'hardBreak' })
index += 2 return index + 2
continue
} }
if (backslashEscape(source, index) !== undefined) { const width = backslashEscape(scan.source, index) === undefined ? 1 : 2
scan.pending += source.slice(index, index + 2) scan.pending += scan.source.slice(index, index + width)
index += 2 return index + width
continue
} }
if (character === '\n') {
function readLineEnding(scan: Scan, index: number): number {
const hard = hardBreakSpaces.test(scan.pending) const hard = hardBreakSpaces.test(scan.pending)
flush(scan, true) flush(scan, true)
if (hard) pushNode(scan, { type: 'hardBreak' }) if (hard) pushNode(scan, { type: 'hardBreak' })
else scan.pending = ' ' else scan.pending = ' '
index += 1 return index + 1
continue
} }
if (character === '`') {
const span = readCodeSpan(source, index) function readBackticks(scan: Scan, index: number): number {
const span = readCodeSpan(scan.source, index)
if (span === undefined) { if (span === undefined) {
const run = backtickRun(source, index) const run = backtickRun(scan.source, index)
scan.pending += source.slice(index, index + run) scan.pending += scan.source.slice(index, index + run)
index += run return index + run
continue
} }
flush(scan, false) flush(scan, false)
pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' }) pushNode(scan, { marks: [{ type: 'code' }], text: span.text, type: 'text' })
index = span.end return span.end
continue
} }
if (character === '<') {
const autolink = readAutolink(source, index) function readAngle(scan: Scan, index: number): Result<number> {
const autolink = readAutolink(scan.source, index)
if (autolink !== undefined) { if (autolink !== undefined) {
flush(scan, false) flush(scan, false)
pushNode(scan, autolink.node) pushNode(scan, autolink.node)
index += autolink.length return success(index + autolink.length)
continue
} }
const construct = inlineHtmlConstruct(source, index) const construct = inlineHtmlConstruct(scan.source, index)
if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, path) if (construct !== undefined) return failure('unmappable-html', `no ADF node carries ${construct}`, scan.path)
scan.pending += '<'
return success(index + 1)
}
function openBracket(scan: Scan, index: number): number {
const image = scan.source.charAt(index) === '!'
if (image && scan.source.charAt(index + 1) !== '[') {
scan.pending += '!'
return index + 1
} }
if (character === '[' || (character === '!' && source.charAt(index + 1) === '[')) {
const image = character === '!'
const width = image ? 2 : 1 const width = image ? 2 : 1
flush(scan, false) flush(scan, false)
scan.pieces.push({ active: true, image, kind: 'open', start: index + width }) scan.pieces.push({ active: true, image, kind: 'open', start: index + width })
index += width return index + width
continue
}
if (character === ']') {
const closed = closeBracket(scan, index)
if (!closed.ok) return closed
index = closed.value
continue
}
if (emphasisCharacters.includes(character)) {
index = readDelimiterRun(scan, index)
continue
}
scan.pending += character
index += 1
}
flush(scan, true)
return assemble(scan)
} }
function flush(scan: Scan, strip: boolean): void { function flush(scan: Scan, strip: boolean): void {
@@ -209,11 +238,23 @@ function buildImage(scan: Scan, inner: readonly Piece[], definition: LinkDefinit
function resolveNodes(pieces: readonly Piece[]): AdfNode[] { function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
const nodes = pieces.map((piece) => (piece.kind === 'nodes' ? piece.nodes : piece.kind === 'open' ? bracketNodes(piece) : [])) const nodes = pieces.map((piece) => (piece.kind === 'nodes' ? piece.nodes : piece.kind === 'open' ? bracketNodes(piece) : []))
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings)
markPairings(nodes, pairings)
return mergeAdjacentText(nodes.flat())
}
function delimiterRuns(pieces: readonly Piece[]): Run[] {
const runs: Run[] = [] const runs: Run[] = []
for (const [index, piece] of pieces.entries()) { for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length }) if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length })
} }
const pairings = matchEmphasis(runs) return runs
}
// A run gives its delimiters up from the head closing and the tail opening; what is left between them is text.
function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
const heads = new Map<Run, number>() const heads = new Map<Run, number>()
const tails = new Map<Run, number>() const tails = new Map<Run, number>()
for (const pairing of pairings) { for (const pairing of pairings) {
@@ -225,22 +266,22 @@ function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
const tail = tails.get(run) ?? run.length const tail = tails.get(run) ?? run.length
if (tail > head) nodes[run.index] = [{ text: run.character.repeat(tail - head), type: 'text' }] if (tail > head) nodes[run.index] = [{ text: run.character.repeat(tail - head), type: 'text' }]
} }
}
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(nodes: AdfNode[][], pairings: readonly Pairing[]): void {
for (const pairing of pairings) { for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) } const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) { for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) nodes[index] = applyMark(nodes[index] ?? [], mark)
nodes[index] = applyMark(nodes[index] ?? [], mark)
} }
} }
return mergeText(nodes.flat())
}
function markType(character: string, used: number): string { function markType(character: string, used: number): string {
if (character === '~') return 'strike' if (character === '~') return 'strike'
return used === 2 ? 'strong' : 'em' return used === 2 ? 'strong' : 'em'
} }
// CommonMark nests a spelling inside its own kind (`*(*a*)*`); the mark it names is idempotent, and // A node cannot carry one mark type twice (AGENTS.md §14).
// a node carrying it twice is the shape AGENTS.md §14 has the emitter refuse.
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
return nodes.map((node) => { return nodes.map((node) => {
const marks = node.marks ?? [] const marks = node.marks ?? []
@@ -248,24 +289,6 @@ function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
}) })
} }
// Editor-normal (AGENTS.md §2): adjacent text nodes carrying identical marks are one node.
function mergeText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && markKey(previous) === markKey(node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
function markKey(node: AdfNode): string {
return (node.marks ?? []).map((mark) => `${mark.type}${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}`).join(' ')
}
function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined { function readCodeSpan(source: string, index: number): { end: number; text: string } | undefined {
const opener = backtickRun(source, index) const opener = backtickRun(source, index)
const closer = closingBacktickRun(source, index + opener, opener) const closer = closingBacktickRun(source, index + opener, opener)
@@ -423,6 +423,11 @@ test('reads an autolink, the email form as the mailto link it means', () => {
assert.deepEqual(content(markdownToAdf('a <a@b.example.com> c\n')), [ assert.deepEqual(content(markdownToAdf('a <a@b.example.com> c\n')), [
{ content: [text('a '), marked('a@b.example.com', link('mailto:a@b.example.com')), text(' c')], type: 'paragraph' }, { content: [text('a '), marked('a@b.example.com', link('mailto:a@b.example.com')), text(' c')], type: 'paragraph' },
]) ])
assert.deepEqual(content(markdownToAdf('a <a@b-c.example.com> d\n')), [
{ content: [text('a '), marked('a@b-c.example.com', link('mailto:a@b-c.example.com')), text(' d')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('a <b@-c.example.com> d\n')), [paragraph('a <b@-c.example.com> d')])
assert.deepEqual(content(markdownToAdf('a <b[c@example.com> d\n')), [paragraph('a <b[c@example.com> d')])
assert.deepEqual(content(markdownToAdf('<https://example.com/?a=\\*>\n')), [ assert.deepEqual(content(markdownToAdf('<https://example.com/?a=\\*>\n')), [
{ content: [marked('https://example.com/?a=\\*', link('https://example.com/?a=\\*'))], type: 'paragraph' }, { content: [marked('https://example.com/?a=\\*', link('https://example.com/?a=\\*'))], type: 'paragraph' },
]) ])
+3 -1
View File
@@ -308,7 +308,9 @@ detail is settled at its own milestone.
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
concatenated `text`. And a count of the dozen elements the CommonMark subset covers concatenated `text`. And a count of the dozen elements the CommonMark subset covers
against the marks and nodes they map to. The fixpoint alone is self-consistency a parser against the marks and nodes they map to — counting distinct mark types per text node, since
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
`em`. The fixpoint alone is self-consistency a parser
returning the empty document passes, and the text alone one dropping every emphasis; the returning the empty document passes, and the text alone one dropping every emphasis; the
counts close both. The exception list stays the maintainer's. One outcome is no counts close both. The exception list stays the maintainer's. One outcome is no
exception and must not be filed as one: valid CommonMark parsing to a document exception and must not be filed as one: valid CommonMark parsing to a document