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:
@@ -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')
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user