3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-14 20:40:25 +01:00

Find code-span and link openers and unspellable mark runs by reading the escaped line back

This commit is contained in:
2026-10-28 19:11:23 +01:00
committed by Lillem4n
parent c295611a9b
commit b0bf362ef9
12 changed files with 193 additions and 251 deletions
@@ -342,6 +342,15 @@ test('escapes only text that would otherwise open a construct', () => {
assert.equal(emitted('x"_y"'), 'x"\\_y"\n')
})
test('escapes every text bracket in a line where one opens link text, and every text backtick where one opens a code span', () => {
const emitted = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text, type: 'text' }))))
assert.equal(emitted('[a](b) [c]'), '\\[a](b) \\[c]\n')
assert.equal(emitted('see [1] and [docs](url) [2]'), 'see \\[1] and \\[docs](url) \\[2]\n')
assert.equal(emitted('[a] b](c'), '[a] b](c\n')
assert.equal(emitted('[x]('), '[x](\n')
assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n')
})
test('escapes a heading closing sequence', () => {
const heading = (text: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
assert.equal(heading('done #'), '## done \\#\n')
+57 -139
View File
@@ -1,12 +1,14 @@
import type { Claims, WrittenFlavour } from '../flavour.ts'
import type { InlineReading } from '../inline-content.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { delimiterRunToken, highlightFlanking, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { delimiterRunToken, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts'
import { flavourClaims } from '../flavour.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { isWordCharacter, runLength } from '../commonmark/emphasis-matching.ts'
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
import { readLineBack } from '../inline-content.ts'
export type DelimiterRole = 'close' | 'open'
@@ -28,10 +30,6 @@ export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspe
type ScanLine = { position: LinePosition; start: number; text: string }
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
type Escapings = (index: number) => InlineEscaping | undefined
// Where a segment's first character landed in the output.
@@ -40,19 +38,19 @@ type PlacedSegment = { segment: InlineSegment; start: number }
// Which of a run's characters escape: every escapable one, or every one before `end`.
type RunEscapes = { end: number; escapable: false } | { escapable: true }
// Which text characters escape wherever they stand: one of them opened a construct when the line was read back.
type TextEscapes = { backticks: boolean; brackets: boolean }
type EscapeWalk = {
readonly claims: Claims
readonly container: LineContainer
readonly escapings: Escapings
readonly headingCloser: number | undefined
readonly inline: InlineSource
readonly linkClose: number
}
const escapingCodes: readonly ['backslash', 'bracketed', 'bracketed-link-target', 'none'] = ['backslash', 'bracketed', 'bracketed-link-target', 'none']
const followsLinkText = /[([]/
const runCharacters = '*_`~'
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): AssembledLine {
@@ -101,13 +99,41 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl
const code = codes[index]
return code === undefined ? undefined : escapingCodes[code]
}
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, claims))
const { output, placed } = backslashed(segments, scan, escaped)
if (container === 'paragraph' && opensLinkDefinition(output)) {
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRuns: unspellableRuns(placed, output) }
const claimed = escapeClaims(scan, escapings, container, claims)
let escapes: TextEscapes = { backticks: false, brackets: false }
// Terminates because a pass whose reading asks for no kind of escape the last pass lacked returns.
for (;;) {
const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes))
const { faulted, reading } = readLineBack(output, container, claims)
const opened = faulted ? { backticks: true, brackets: true } : textOpeners(placed, output.length, reading)
const next = { backticks: escapes.backticks || opened.backticks, brackets: escapes.brackets || opened.brackets }
if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) {
const unspellable = unspellableRuns(placed, reading)
if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output, unspellableRuns: unspellable }
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRuns: unspellable }
}
escapes = next
}
return { line: output, unspellableRuns: unspellableRuns(placed, output) }
}
function withTextEscapes(scan: string, escapings: Escapings, claimed: Uint8Array, { backticks, brackets }: TextEscapes): Uint8Array {
if (!backticks && !brackets) return claimed
const escaped = claimed.slice()
for (let index = 0; index < scan.length; index += 1) {
const character = scan.charAt(index)
if (((backticks && character === '`') || (brackets && character === '[')) && isEscapable(escapings(index))) escaped[index] = 1
}
return escaped
}
// A construct the reader opened at a text character is one the line does not mean.
function textOpeners(placed: readonly PlacedSegment[], length: number, reading: InlineReading): TextEscapes {
const text = new Uint8Array(length)
for (const [index, { segment, start }] of placed.entries()) {
if (isEscapable(segment.escaping)) text.fill(1, start, placed[index + 1]?.start ?? length)
}
return { backticks: reading.codeSpans.some((at) => text[at] === 1), brackets: reading.links.some((at) => text[at] === 1) }
}
function backslashed(segments: readonly InlineSegment[], scan: string, escaped: Uint8Array): { output: string; placed: PlacedSegment[] } {
@@ -146,7 +172,6 @@ function escapeClaims(scan: string, escapings: Escapings, container: LineContain
escapings,
headingCloser: container === 'heading' ? closingHashes(scan) : undefined,
inline: inlineSource(scan),
linkClose: lastLinkClose(scan, escapings),
}
const escaped = new Uint8Array(scan.length)
let line = scanLine(scan, 0)
@@ -195,7 +220,7 @@ function claimsRunRest({ claims, container, escapings, inline }: EscapeWalk, ind
return token.kind !== 'text'
}
function claimsCharacter({ container, escapings, headingCloser, inline: { text: scan }, linkClose }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
function claimsCharacter({ container, escapings, headingCloser, inline: { text: scan } }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
const escaping = escapings(index)
if (escaping === 'bracketed-link-target') return token.kind === 'directive'
if (!isEscapable(escaping)) return false
@@ -211,8 +236,7 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text:
case 'html':
return true
case 'bracket':
if (token.image) return isSyntax(escapings(index + 1))
return escaping === 'bracketed' || index < linkClose
return token.image ? isSyntax(escapings(index + 1)) : escaping === 'bracketed'
case 'bracket-close':
return escaping === 'bracketed'
case 'highlight':
@@ -227,120 +251,23 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text:
}
}
// CommonMark reads no escape inside a code span, so a backtick string left beside an escape still closes a span that an earlier bare run of its length opens.
function escapeClosedRuns(scan: string, escapings: Escapings, claimed: Uint8Array): Uint8Array {
const escaped = claimed.slice()
// The lengths of the backtick strings escapes leave: a bare run of one of these lengths would open a span one of them closes, so it is escaped too.
const formed = new Set<number>()
let end = scan.length - 1
while (end >= 0) {
if (scan.charAt(end) !== '`') {
end -= 1
continue
}
let start = end
while (scan.charAt(start - 1) === '`') start -= 1
let segmentEnd = end
for (let index = end; index > start; index -= 1) {
if (claimed[index] !== 1) continue
formed.add(segmentEnd - index + 1)
segmentEnd = index - 1
}
if (segmentEnd !== end || claimed[start] === 1) formed.add(segmentEnd - start + 1)
else if (escapings(start) !== 'none' && formed.has(end - start + 1)) {
escaped.fill(1, start, end + 1)
// Each escaped backtick then stands alone, a string of length one.
formed.add(1)
}
end = start - 1
}
return escaped
}
// One emphasis run, the innermost, or every highlight run the line cannot spell.
function unspellableRuns(placed: readonly PlacedSegment[], output: string): MarkRun[] {
const { nodes, runs } = emittedRuns(placed, output)
const pair = misflanked(runs) ?? unpaired(runs)
const run = pair === undefined ? undefined : nodes[pair]
return run === undefined ? unreadHighlights(placed, output) : [run]
}
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
function unreadHighlights(placed: readonly PlacedSegment[], output: string): MarkRun[] {
const unread: MarkRun[] = []
// The innermost emphasis run the line does not read back as written, else every highlight run it does not.
function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading): MarkRun[] {
const openers: { nodes: MarkRun; start: number }[] = []
const unreadHighlights: MarkRun[] = []
let innermost: { nodes: MarkRun; start: number } | undefined
let highlight: { nodes: MarkRun; start: number } | undefined
for (const { segment, start } of placed) {
if (segment.highlight === undefined) continue
const flanking = highlightFlanking(output, start)
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
}
return unread
}
function misflanked(runs: readonly EmittedRun[]): number | undefined {
for (const run of runs) {
for (const delimiter of run.delimiters) {
if (!(delimiter.closes ? run.canClose : run.canOpen)) return delimiter.pair
if (segment.emphasis === 'open') openers.push({ nodes: segment.nodes, start })
if (segment.highlight === 'open') highlight = { nodes: segment.nodes, start }
if (segment.emphasis === 'close') {
const opener = openers.pop()
const read = opener === undefined ? undefined : reading.emphasis.get(opener.start)
if (opener !== undefined && (read?.closer !== start || read.width !== segment.text.length) && opener.start > (innermost?.start ?? -1)) innermost = opener
}
if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlights.push(highlight.nodes)
}
return undefined
}
function unpaired(runs: readonly EmittedRun[]): number | undefined {
const matched = new Set<number>()
for (const pairing of matchEmphasis(runs)) {
const opened = delimiterAt(pairing.opener, false, pairing.openerOffset, pairing.used)
const closed = delimiterAt(pairing.closer, true, pairing.closerOffset, pairing.used)
if (opened !== undefined && closed !== undefined && opened.pair === closed.pair) matched.add(opened.pair)
}
// The last opener left unpaired is the innermost: the smallest carry that changes the line.
let innermost: number | undefined
for (const run of runs) {
for (const delimiter of run.delimiters) {
if (!delimiter.closes && !matched.has(delimiter.pair)) innermost = delimiter.pair
}
}
return innermost
}
function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: number): EmittedDelimiter | undefined {
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
}
function emittedRuns(placed: readonly PlacedSegment[], output: string): { nodes: MarkRun[]; runs: EmittedRun[] } {
const runs: EmittedRun[] = []
const nodes: MarkRun[] = []
const open: number[] = []
for (const { segment, start } of placed) {
if (segment.emphasis === undefined) continue
const closes = segment.emphasis === 'close'
const pair = closes ? (open.pop() ?? nodes.length) : nodes.length
if (!closes) {
nodes.push(segment.nodes)
open.push(pair)
}
const width = segment.text.length
const previous = runs[runs.length - 1]
if (previous !== undefined && previous.start + previous.length === start && previous.character === segment.text.charAt(0)) {
previous.delimiters.push({ closes, offset: start - previous.start, pair, width })
previous.length += width
continue
}
runs.push({
canClose: false,
canOpen: false,
character: segment.text.charAt(0),
delimiters: [{ closes, offset: 0, pair, width }],
length: width,
start,
})
}
for (const run of runs) {
const flags = delimiterFlags(run.character, output.charAt(run.start - 1), output.charAt(run.start + run.length))
run.canClose = flags.canClose
run.canOpen = flags.canOpen
}
return { nodes, runs }
return innermost === undefined ? unreadHighlights : [innermost.nodes]
}
function isEscapable(escaping: InlineEscaping | undefined): boolean {
@@ -370,12 +297,3 @@ function scanLine(scan: string, start: number): ScanLine {
const end = scan.indexOf('\n', start)
return { position: start === 0 ? 'first' : 'later', start, text: scan.slice(start, end === -1 ? undefined : end) }
}
// A `]` the emitter spelled sits inside a construct that binds before link text does.
function lastLinkClose(scan: string, escapings: Escapings): number {
for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) {
if (scan.charAt(cursor) !== ']' || isSyntax(escapings(cursor))) continue
if (followsLinkText.test(scan.charAt(cursor + 1))) return cursor
}
return -1
}
@@ -1,28 +1,27 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { ClaimedPrefix, DirectiveSpan, NestedSpans } from '../directive/syntax.ts'
import type { Claims, Flavour } from '../flavour.ts'
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
import type { LineContainer } from '../line-container.ts'
import type { InlineTarget, LinkDefinition, TargetMemo } from '../commonmark/link-syntax.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } from '../mark-spellings.ts'
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter, inlineSource, readInlineToken } from '../inline-tokens.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { imageAside } from '../image-aside.ts'
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from '../commonmark/link-syntax.ts'
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
import { contentOffset, noSpans, readInlineDirective } from '../directive/syntax.ts'
import { openingLinkTakesDirective } from '../emit/spelling-questions.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from '../directive/mark-reading.ts'
import { readInlineDirectiveNode } from '../directive/node-reading.ts'
import { readTextDirective } from '../directive/text.ts'
import type { AdfMark, AdfNode } from '../adf/document.ts'
import type { ClaimedPrefix, DirectiveSpan, NestedSpans } from './directive/syntax.ts'
import type { Claims, Flavour } from './flavour.ts'
import type { EmphasisPairing } from './commonmark/emphasis-matching.ts'
import type { InlineSource, InlineToken } from './inline-tokens.ts'
import type { LineContainer } from './line-container.ts'
import type { InlineTarget, LinkDefinition, TargetMemo } from './commonmark/link-syntax.ts'
import { centeredImage, externalMedia } from '../adf/plain-image.ts'
import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } from './mark-spellings.ts'
import { decodeTextEscapes, trimTrailingSpace } from './commonmark/grammar.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../result.ts'
import { flavourClaims } from './flavour.ts'
import { highlightDelimiter, inlineSource, readInlineToken } from './inline-tokens.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../adf/document.ts'
import { imageAside } from './image-aside.ts'
import { inlineNodeModel, takesMark } from '../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from './adjacent-text.ts'
import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from './commonmark/link-syntax.ts'
import { matchEmphasis } from './commonmark/emphasis-matching.ts'
import { contentOffset, noSpans, readInlineDirective } from './directive/syntax.ts'
import { readCarriedInline } from './opaque-carry.ts'
import { readDirectiveMark } from './directive/mark-reading.ts'
import { readInlineDirectiveNode } from './directive/node-reading.ts'
import { readTextDirective } from './directive/text.ts'
// Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is its linked alt text.
export type InlineContent = { image?: AdfNode; nodes: AdfNode[] }
@@ -51,7 +50,9 @@ type Nodes = { kind: 'nodes'; nodes: Inline[] }
type Resolved = ImageLink | Nodes
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; opens: boolean; position: number; stretch: number }
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; offset: number; opens: boolean; position: number; stretch: number }
type HighlightPair = { closer: number; closerOffset: number; opener: number; openerOffset: number }
// `index` is the bracket's position in `pieces`; `start` is where its text starts in `source`.
type OpenBracket = { image: boolean; index: number; start: number }
@@ -65,10 +66,20 @@ type Piece =
| ImageLink
| Nodes
| TextBreak
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number }
| { closes: boolean; kind: 'highlight'; offset: number; opens: boolean }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number; offset: number }
// What the reader took where, by offset into the outermost content, so a writer can check its line reads as it meant.
export type InlineReading = {
readonly codeSpans: number[]
// Each emphasis pairing, keyed by where its opening delimiters start.
readonly emphasis: Map<number, { closer: number; width: number }>
readonly highlights: Map<number, number>
// Where the `[` of each link or image stands.
readonly links: number[]
}
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
@@ -87,36 +98,45 @@ type Scan = {
path: ConvertErrorPath
pending: string
pieces: Piece[]
reading: InlineReading
source: string
spans: NestedSpans
targets: TargetMemo
}
// What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'targets'>
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'reading' | 'targets'>
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none'
const noDefinitions: LinkDefinitions = new Map()
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const scan = freshScan(source, { definitions, path, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer.
export function readInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent & { openingSpellableLink: boolean }> {
const scan = freshScan(source, { definitions, path, reading: emptyReading(), targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
const nodes = partText(contentNodes(scanned.value.pieces), scan)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
const { image } = scanned.value
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
const { openingSpellableLink } = scan
return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink })
}
// A line that fails to read still holds what the reader took before the fault.
export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } {
const reading = emptyReading()
const scan = freshScan(line, { definitions: noDefinitions, path: [], reading, targets: targetMemo(line) }, { claims, container, inLink: false, offset: 0, spans: noSpans })
return { faulted: !scanInline(scan).ok, reading }
}
function emptyReading(): InlineReading {
return { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] }
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
@@ -153,11 +173,12 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
return closeBracket(scan, index)
case 'code-span':
flush(scan, false)
scan.reading.codeSpans.push(scan.offset + index)
pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' })
return success(end)
case 'delimiter-run':
flush(scan, false)
scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width })
scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width, offset: scan.offset + index })
return success(end)
case 'entity':
case 'escape':
@@ -171,7 +192,7 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
return success(end)
case 'highlight':
flush(scan, false)
scan.pieces.push({ closes: token.closes, kind: 'highlight', opens: token.opens })
scan.pieces.push({ closes: token.closes, kind: 'highlight', offset: scan.offset + index, opens: token.opens })
return success(end)
case 'line-ending':
return success(readLineEnding(scan, index))
@@ -255,9 +276,9 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result<Resolved[] | undefined> {
if (span.content === undefined) return success(undefined)
const { claims, definitions, path, targets } = scan
const { claims, definitions, path, reading, targets } = scan
const offset = scan.offset + index + contentOffset(span)
const parsed = scanInline(freshScan(span.content, { definitions, path, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
const parsed = scanInline(freshScan(span.content, { definitions, path, reading, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
if (!parsed.ok) return parsed
return success(parsed.value.pieces)
}
@@ -399,6 +420,7 @@ function closeBracket(scan: Scan, index: number): Result<number> {
if (!open.image) scan.linkOpenersFrom = scan.brackets.length
// Before `linkPieces`' own-marks guard: brackets that go literal mark nothing, so a carry inside them is no error.
if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index))
scan.reading.links.push(scan.offset + open.start - 1)
const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition())
if (!closed.ok) return closed
scan.pieces.length = open.index
@@ -490,9 +512,12 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean):
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
for (const { closer, closerOffset, opener, openerOffset, used } of pairings) scan.reading.emphasis.set(opener.offset + openerOffset, { closer: closer.offset + closerOffset, width: used })
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
const paired = highlights ? pairedHighlights(pieces, nodes) : []
for (const { closerOffset, openerOffset } of paired) scan.reading.highlights.set(openerOffset, closerOffset)
markHighlights(pieces, nodes, paired)
return success(nodes)
}
@@ -538,7 +563,7 @@ function pieceNodes(piece: Piece): Inline[] {
function delimiterRuns(pieces: readonly Piece[]): Run[] {
const runs: Run[] = []
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, offset: piece.offset })
}
return runs
}
@@ -600,7 +625,7 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][
const held = adfNodes(nodes[index] ?? [])
const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, opens: piece.opens, position, stretch })
found.push({ closes: piece.closes, holder, index, offset: piece.offset, opens: piece.opens, position, stretch })
position += highlightDelimiter.length
continue
}
@@ -613,9 +638,9 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][
}
// Each opener pairs with the next closer at least one character after it, and only when both sit in one stretch under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): { closer: number; opener: number }[] {
function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightPair[] {
const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = []
const paired: HighlightPair[] = []
let closer = 0
let resume = 0
for (const opener of found) {
@@ -628,14 +653,14 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]):
}
if (candidate === undefined) break
if (candidate.stretch !== opener.stretch || !identicalMarks(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue
paired.push({ closer: candidate.index, opener: opener.index })
paired.push({ closer: candidate.index, closerOffset: candidate.offset, opener: opener.index, openerOffset: opener.offset })
resume = candidate.position + highlightDelimiter.length
}
return paired
}
// A node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly { closer: number; opener: number }[]): void {
function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly HighlightPair[]): void {
for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = adfNodes(nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
}
+16 -3
View File
@@ -2,10 +2,11 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from '../directive/node-reading.ts'
import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../flavour.ts'
import type { InlineContent, LinkDefinitions } from '../inline-content.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/spelling-questions.ts'
import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts'
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
import { emptyKeys, nodeAttrs } from '../../adf/document.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts'
@@ -14,7 +15,7 @@ import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { notAString } from '../../not-a-string.ts'
import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts'
import { readInlineContent, spellableLink } from '../inline-content.ts'
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
import { spellsEmpty } from '../directive/empty-keys.ts'
@@ -257,3 +258,15 @@ export function contentNode(node: AdfNode, text: string, reading: Reading, path:
if (!content.ok) return content
return success(withContent(node, content.value.nodes))
}
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const read = readInlineContent(source, definitions, path, container, flavour)
if (!read.ok) return read
const { image, nodes, openingSpellableLink } = read.value
if (openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
return success(image === undefined ? { nodes } : { image, nodes })
}
@@ -2,11 +2,10 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, parseInlineContent, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
import { mintTaskIds } from './task-ids.ts'
import { parseInlineContent } from '../parse/inline-content.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { positioned, success } from '../../result.ts'
import { withinEngineLimits } from '../../length-bound.ts'