3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-14 04:30:24 +01:00

Answer stability round 1: 1 🔴, 4 🟡 (1 misread); a line gives up its first misread emphasis run, else its first misread highlight, as shipped, the run list narrows to one run, Scan holds SharedScan, the Piece arms are named, and textOpeners and guardLinkDefinition are pinned

This commit is contained in:
2026-10-28 17:36:28 +01:00
committed by Lillem4n
parent 6636f01497
commit b388b33d31
6 changed files with 83 additions and 70 deletions
+13 -1
View File
@@ -333,6 +333,8 @@ test('escapes only text that would otherwise open a construct', () => {
assert.equal(emitted(':::panel info'), ':::panel info\n')
assert.equal(emitted('10:30 tomorrow'), '10:30 tomorrow\n')
assert.equal(emitted('[a](b)'), '\\[a](b)\n')
assert.equal(emitted('[a]: b'), '\\[a]: b\n')
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { level: 1 }, content: [{ text: '[a]: b', type: 'text' }], type: 'heading' }))), '# [a]: b\n')
assert.equal(emitted('**bold**'), '\\*\\*bold\\*\\*\n')
assert.equal(emitted('a `x` b'), 'a \\`x` b\n')
assert.equal(emitted('~~struck~~'), '\\~~struck\\~~\n')
@@ -342,13 +344,14 @@ 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, none where none does, and every text backtick where one opens a code span', () => {
test('escapes every text bracket in a line where one opens link text and every text backtick where one opens a code span, and none where none does', () => {
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')
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: 'c', type: 'text' }, { text: 'a ` b', type: 'text' })))), '`c`a ` b\n')
})
test('keeps a text backtick bare in a line where a text bracket opens a link the reader refuses', () => {
@@ -398,6 +401,15 @@ test('carries a mark spelling that cannot open or close where it sits', () => {
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
})
test('carries the mark spelling that cannot close where it sits, and keeps the later one the reader would pair it with', () => {
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
const b: AdfNode = { text: 'b', type: 'text' }
assert.equal(emitted(marked(':', { type: 'em' }), b, marked('c', { type: 'em' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\":\\",\\"type\\":\\"text\\"}"}b*c*\n')
assert.equal(emitted(marked(')', { type: 'strong' }), b, marked('c', { type: 'strong' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\")\\",\\"type\\":\\"text\\"}"}b**c**\n')
assert.equal(emitted(marked('^', { type: 'strike' }), b, marked('c', { type: 'strike' })), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strike\\"}],\\"text\\":\\"^\\",\\"type\\":\\"text\\"}"}b~~c~~\n')
})
test('refuses a node carrying one mark type twice', () => {
const em: AdfMark = { type: 'em' }
assert.equal(code(adfToLosslessMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
+4 -4
View File
@@ -41,7 +41,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour)
@@ -136,7 +136,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt {
const verdict = lineVerdict(segments, container, flavour)
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
if (verdict.kind === 'unspellable-run') return { fallback: verdict.run }
return { line: verdict.text }
}
@@ -144,8 +144,8 @@ function attemptLine(segments: readonly InlineSegment[], container: LineContaine
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): PortableLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
const [run, ...others] = assembled.unspellableRuns
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
const run = assembled.unspellableRun
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', run }
}
// A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}.
+11 -12
View File
@@ -26,7 +26,7 @@ export type InlineSegment =
| { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: MarkRun | undefined }
type ScanLine = { position: LinePosition; start: number; text: string }
@@ -91,7 +91,7 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine {
const { output, placed, reading } = readBack(segments, container, claims)
return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading) }
return { ...guardLinkDefinition(output, placed, container), unspellableRun: unspellableRun(placed, reading) }
}
function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack {
@@ -109,12 +109,12 @@ function readBack(segments: readonly InlineSegment[], container: LineContainer,
}
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.
// At most three passes: each pass that doesn't return turns on at least one more of the two escape kinds.
for (;;) {
const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes))
const { faulted, reading } = readLineBack(output, container, claims)
const shown = textOpeners(placed, output.length, reading)
// A code span a directive slot swallowed leaves no record before the directive fault.
// A text backtick in a directive's slot can open a code span past the slot's `]`: the directive faults before the reader records that span.
const opened = faulted && !shown.backticks && !shown.brackets ? { backticks: true, brackets: true } : shown
const next = { backticks: escapes.backticks || opened.backticks, brackets: escapes.brackets || opened.brackets }
if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) return { output, placed, reading }
@@ -122,7 +122,7 @@ function readBack(segments: readonly InlineSegment[], container: LineContainer,
}
}
// spec/flavour.md, Canonical form: a paragraph opening with what reads as a link reference definition escapes its `[`, or spells the emitter's opening link as a directive.
// spec/flavour.md, Canonical form.
function guardLinkDefinition(output: string, placed: readonly PlacedSegment[], container: LineContainer): { line: string; openingLinkAsDirective?: true } {
if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output }
return opensWithLink(placed) ? { line: output, openingLinkAsDirective: true } : { line: `\\${output}` }
@@ -267,22 +267,21 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text:
}
}
// 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[] {
// Of the runs the line does not read back as written, the first emphasis run to close, else the first highlight run: one misread pair can re-pair the delimiters after it.
function unspellableRun(placed: readonly PlacedSegment[], reading: InlineReading): MarkRun | undefined {
const openers: { nodes: MarkRun; start: number }[] = []
const unreadHighlights: MarkRun[] = []
let innermost: { nodes: MarkRun; start: number } | undefined
let highlight: { nodes: MarkRun; start: number } | undefined
let unreadHighlight: MarkRun | undefined
for (const { segment, start } of placed) {
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()
if (opener !== undefined && !readAsWritten(reading, opener.start, start, segment.text.length) && opener.start > (innermost?.start ?? -1)) innermost = opener
if (opener !== undefined && !readAsWritten(reading, opener.start, start, segment.text.length)) return opener.nodes
}
if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlights.push(highlight.nodes)
if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlight ??= highlight.nodes
}
return innermost === undefined ? unreadHighlights : [innermost.nodes]
return unreadHighlight
}
function readAsWritten(reading: InlineReading, opener: number, closer: number, width: number): boolean {
+43 -44
View File
@@ -59,23 +59,21 @@ type OpenBracket = { image: boolean; index: number; start: number }
type Pairing = EmphasisPairing<Run>
type Piece =
| Bracket
| OwnMarks
| Image
| ImageLink
| Nodes
| TextBreak
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number }
| { closes: boolean; kind: 'highlight'; offset: number; opens: boolean }
// `offset` is where the delimiter starts in the outermost content.
type HighlightPiece = { closes: boolean; kind: 'highlight'; offset: number; opens: boolean }
type RunPiece = { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number }
type Run = Extract<Piece, { kind: 'run' }> & { index: number }
type Piece = Bracket | HighlightPiece | Image | ImageLink | Nodes | OwnMarks | RunPiece | TextBreak
type Run = RunPiece & { index: 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 = {
// Where the opening backtick string of each code span stands.
readonly codeSpans: number[]
// Each emphasis pairing, keyed by where its opening delimiters start.
readonly emphasis: Map<number, { closer: number; width: number }>
// Each highlight pairing: its opening `==` to its closing one.
readonly highlights: Map<number, number>
// Where the `[` of each link or image stands.
readonly links: number[]
@@ -89,26 +87,28 @@ type Scan = {
brackets: OpenBracket[]
claims: Claims
container: LineContainer | undefined
definitions: LinkDefinitions
// Whether this content is an `!adf:link`'s text, where an image reads as its plain alt text.
inLink: boolean
// Link openers below this index of `brackets` were open when a link closed, so they stay literal: no link holds another.
linkOpenersFrom: number
// Where `source` starts in the outermost content, the text `targets` reads.
// Where `source` starts in the outermost content, the text `shared.targets` reads.
offset: number
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
pieces: Piece[]
// Only a read-back takes a reading; parsing records none.
reading: InlineReading | undefined
shared: SharedScan
source: string
spans: NestedSpans
targets: TargetMemo
}
// What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'reading' | 'targets'>
type SharedScan = {
definitions: LinkDefinitions
path: ConvertErrorPath
// Only a read-back takes a reading; parsing records none.
reading: InlineReading | undefined
targets: TargetMemo
}
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' }
@@ -119,7 +119,7 @@ const unlinkable = 'the text of this link holds only nodes that take no link: wr
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. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer, so the caller passes its answer in.
// The outermost content: only here does a text break see both its neighbours. The writers read lines back through this file, so it takes the emitter's `openingLinkTakesDirective` as an argument and imports nothing from `emit/`.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour, openingLinkTakesDirective: (nodes: readonly AdfNode[], path: ConvertErrorPath) => Result<boolean>): Result<InlineContent> {
const scan = freshScan(source, { definitions, path, reading: undefined, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
const scanned = scanInline(scan)
@@ -135,14 +135,14 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
}
// A line that fails to read still holds what the reader took before the fault.
// The reading of a line that faults still holds what the reader took before the fault.
export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } {
const reading: InlineReading = { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] }
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 record({ reading }: Pick<Scan, 'reading'>, entry: ReadingEntry): void {
function record({ shared: { reading } }: Scan, entry: ReadingEntry): void {
if (reading === undefined) return
if (entry.kind === 'emphasis') reading.emphasis.set(entry.at, { closer: entry.closer, width: entry.width })
else if (entry.kind === 'highlights') reading.highlights.set(entry.at, entry.closer)
@@ -150,7 +150,7 @@ function record({ reading }: Pick<Scan, 'reading'>, entry: ReadingEntry): void {
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
return { ...shared, ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], source }
return { ...own, brackets: [], linkOpenersFrom: 0, openingSpellableLink: false, pending: '', pieces: [], shared, source }
}
function scanInline(scan: Scan): Result<Scanned> {
@@ -167,7 +167,7 @@ function scanInline(scan: Scan): Result<Scanned> {
function readToken(scan: Scan, inline: InlineSource, index: number, token: InlineToken): Result<number> {
if (token.kind === 'directive') return readDirective(scan, inline, token.prefix)
if (token.kind === 'html') return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.path)
if (token.kind === 'html') return failure('unmappable-html', `no raw HTML converts at this version: ${token.construct}`, scan.shared.path)
const end = index + token.width
switch (token.kind) {
case 'autolink':
@@ -221,7 +221,7 @@ function readDirective(scan: Scan, inline: InlineSource, prefix: ClaimedPrefix):
const held = scan.spans.get(prefix.index)
if (held !== undefined) return pushDirective(scan, held, prefix.index)
const directive = readInlineDirective(inline, prefix)
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
if (directive.fault !== undefined) return faulted(directive.fault, scan.shared.path)
return pushDirective(scan, directive.value, prefix.index)
}
@@ -236,21 +236,21 @@ function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result<n
function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result<Piece[]> {
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
if (carried.fault !== undefined) return faulted(carried.fault, scan.shared.path)
return success([{ kind: 'ownMarks', node: carried.value }])
}
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success([textBreak]) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success([textBreak]) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.shared.path)
const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text?.fault !== undefined) return faulted(text.fault, scan.shared.path)
if (text !== undefined) return success([{ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }])
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
const mark = readDirectiveMark(span.name, span.attributes, scan.shared.path)
// A node's slot is the node's own content, never the text of a link around the node.
const slot = slotContent(scan, span, index, mark !== undefined && (scan.inLink || span.name === 'link'))
if (!slot.ok) return slot
if (mark !== undefined) return mark.ok ? directiveMarkPieces(scan, span.name, mark.value, slot.value, index) : mark
const content = slot.value === undefined ? undefined : contentNodes(slot.value)
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.shared.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.shared.path)
if (!node.ok) return node
return success([node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value }])
}
@@ -258,11 +258,11 @@ function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result
function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: readonly Resolved[] | undefined, index: number): Result<Piece[]> {
const nodes = slot === undefined ? [] : contentNodes(slot)
if (slot === undefined || nodes.length === 0) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.shared.path)
}
if (nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path)
if (!nodes.some((node) => marksItem(node, mark.type))) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them`, scan.path)
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark: write the ${name} mark around text beside them`, scan.shared.path)
}
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, nodes, index) : undefined
if (refused !== undefined) return refused
@@ -276,19 +276,18 @@ function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: read
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `parseInlineContent` asks the emitter.
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece[]> | undefined {
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.shared.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.shared.path)
if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined, 'lossless') === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.shared.path)
scan.openingSpellableLink = true
return undefined
}
function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result<Resolved[] | undefined> {
if (span.content === undefined) return success(undefined)
const { claims, definitions, path, reading, targets } = scan
const offset = scan.offset + index + contentOffset(span)
const parsed = scanInline(freshScan(span.content, { definitions, path, reading, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
const parsed = scanInline(freshScan(span.content, scan.shared, { claims: scan.claims, container: undefined, inLink, offset, spans: span.spans }))
if (!parsed.ok) return parsed
return success(parsed.value.pieces)
}
@@ -374,7 +373,7 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const previous = items[index - 1]
const next = items[index + 1]
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !joinsWhenRead(previous, next)) {
return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path)
return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.shared.path)
}
}
return success(parted)
@@ -464,7 +463,7 @@ function bracketNodes(bracket: Bracket | OpenBracket): AdfNode[] {
function resolveTarget(scan: Scan, start: number, index: number): InlineTarget | undefined {
const after = index + 1
if (scan.source.charAt(after) === '(') {
const inline = readInlineTarget(scan.targets, scan.offset + after)
const inline = readInlineTarget(scan.shared.targets, scan.offset + after)
// The memo reads the outermost content; a target it reads holds here exactly when the target ends inside `source`.
if (inline !== undefined && after + inline.length <= scan.source.length) return inline
}
@@ -472,20 +471,20 @@ function resolveTarget(scan: Scan, start: number, index: number): InlineTarget |
const labelIsText = label === undefined || label.value === ''
if (labelIsText && index - start > labelLimit) return undefined
const name = labelIsText ? scan.source.slice(start, index) : label.value
const definition = scan.definitions.get(normalizeLabel(name))
const definition = scan.shared.definitions.get(normalizeLabel(name))
if (definition === undefined) return undefined
return { definition: () => definition, length: label?.length ?? 0 }
}
// An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image.
function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece[]> {
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path)
const [only] = inner
if (inner.length === 1 && only?.kind === 'image') return success([{ ...only, link: definition }])
const nodes = resolveNodes(inner, scan, true)
if (!nodes.ok) return nodes
if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) {
return failure('unsupported-node-shape', unlinkable, scan.path)
return failure('unsupported-node-shape', unlinkable, scan.shared.path)
}
const images = inner.flatMap((piece) => (piece.kind === 'image' ? [piece.definition] : []))
return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(images, definition)])
@@ -499,7 +498,7 @@ function imagePieces(scan: Scan, inner: readonly Piece[], definition: LinkDefini
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner.filter((piece) => piece.kind !== 'imageLink'), scan, false)
if (!nodes.ok) return nodes
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path)
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.shared.path)
return success(adfNodes(nodes.value).map(altText).join(''))
}
@@ -524,7 +523,7 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean):
const pairings = matchEmphasis(runs)
for (const { closer, closerOffset, opener, openerOffset, used } of pairings) record(scan, { at: opener.offset + openerOffset, closer: closer.offset + closerOffset, kind: 'emphasis', width: used })
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.shared.path)
const paired = highlights ? pairedHighlights(pieces, nodes) : []
for (const { closerOffset, openerOffset } of paired) record(scan, { at: openerOffset, closer: closerOffset, kind: 'highlights' })
markHighlights(pieces, nodes, paired)
@@ -384,6 +384,15 @@ test('drops the mark of a run CommonMark flanking or matching cannot spell', ()
assert.equal(portable(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
})
test('drops the mark of the run that cannot close where it sits, and keeps the later run the reader would pair it with', () => {
const highlight: AdfMark = { type: 'backgroundColor' }
const strike: AdfMark = { type: 'strike' }
assert.equal(portable(paragraph(text(':', em), text('b'), text('c', em))), ':b*c*\n')
assert.equal(portable(paragraph(text(')', strong), text('b'), text('c', strong))), ')b**c**\n')
assert.equal(portable(paragraph(text('^', strike), text('b'), text('c', strike))), '^b~~c~~\n')
assert.equal(portable(paragraph(text('~', highlight), text('b'), text('日本', highlight))), '~b==日本==\n')
})
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
+3 -9
View File
@@ -230,7 +230,7 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver
}
function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback): AdfNode[] | undefined {
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
if (fallback.kind === 'unspellable-run') return withoutMark(leaves, fallback.run)
const mark = nodeMarks(leaves[0] ?? {})[0]
if (mark === undefined || mark.type !== 'link') return undefined
let last = 0
@@ -240,12 +240,6 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallb
return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined
}
// The runs cover disjoint leaves, so one pass drops them all.
function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] {
const depths = new Map<number, number>()
for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth)
return leaves.map((leaf, index) => {
const depth = depths.get(index)
return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth))
})
function withoutMark(leaves: readonly AdfNode[], run: MarkRun): AdfNode[] {
return leaves.map((leaf, index) => (index < run.first || index > run.last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== run.depth))))
}