Read the emphasis, the links and the lone image CommonMark spells #34
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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')}`
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,})$/
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 === '`') {
|
function readBackticks(scan: Scan, index: number): number {
|
||||||
const span = readCodeSpan(source, index)
|
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 === '<') {
|
function readAngle(scan: Scan, index: number): Result<number> {
|
||||||
const autolink = readAutolink(source, index)
|
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,13 +266,14 @@ 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 {
|
||||||
@@ -239,8 +281,7 @@ function markType(character: string, used: number): string {
|
|||||||
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' },
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user