Carry the text break as its own item, not a fake node, and check it where the outermost content resolves

This commit is contained in:
2026-10-03 14:19:55 +02:00
parent fdbf43398c
commit 70e2639982
2 changed files with 108 additions and 49 deletions
+94 -49
View File
@@ -23,6 +23,13 @@ import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
type TextBreak = { kind: 'textBreak' }
type Inline = AdfNode | TextBreak
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition> export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
@@ -34,10 +41,10 @@ type Pairing = EmphasisPairing<Run>
// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty. // A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty.
type Piece = type Piece =
| Bracket | Bracket
| { kind: 'break' }
| { kind: 'carry'; node: AdfNode } | { kind: 'carry'; node: AdfNode }
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] } | { kind: 'nodes'; nodes: Inline[] }
| TextBreak
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean } | { closes: boolean; kind: 'highlight'; opens: boolean }
@@ -45,14 +52,14 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = { type Scan = {
// Nodes a carry piece restored: they never merge, and only identity tells a carried textBreak from the leaf.
carried: Set<AdfNode>
container: LineContainer | undefined container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed. // Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number deactivatedBefore: number
definitions: LinkDefinitions definitions: LinkDefinitions
highlights: boolean highlights: boolean
openingSpellableLink: boolean openingSpellableLink: boolean
// The nodes carry pieces hold: none joins its neighbour.
ownMarks: Set<AdfNode>
path: ConvertErrorPath path: ConvertErrorPath
pending: string pending: string
pieces: Piece[] pieces: Piece[]
@@ -60,7 +67,7 @@ type Scan = {
spans: NestedSpans spans: NestedSpans
} }
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: Inline[] }
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
@@ -68,13 +75,26 @@ const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>' 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> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, { carried: new Set(), container, definitions, highlights: flavour === 'plain', path, spans: noSpans }) const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights: flavour === 'plain', openingSpellableLink: false, ownMarks: new Set(), path, pending: '', pieces: [], source, spans: noSpans }
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
const nodes = partText(scanned.value.nodes, 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)
}
return success({ carry: scanned.value.carry, nodes: nodes.value })
} }
function parseInline(source: string, context: Pick<Scan, 'carried' | 'container' | 'definitions' | 'highlights' | 'path' | 'spans'>): Result<InlineContent> { function scanInline(scan: Scan): Result<Scanned> {
const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source } const { source } = scan
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
@@ -211,7 +231,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (carried.fault !== undefined) return faulted(carried.fault, scan.path) if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success(carryPiece(scan, carried.value)) return success(carryPiece(scan, carried.value))
} }
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success({ kind: 'break' }) : 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.path)
const text = readTextDirective(span) const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path) if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
@@ -219,13 +239,15 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (!slot.ok) return slot if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path) const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) const content = slot.value?.nodes
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (!node.ok) return node if (!node.ok) return node
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value)) return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value))
} }
function carryPiece(scan: Scan, node: AdfNode): Piece { function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.carried.add(node) scan.ownMarks.add(node)
return { kind: 'carry', node } return { kind: 'carry', node }
} }
@@ -240,11 +262,11 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
} }
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter. // spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined { function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece> | undefined {
const href = linkHref(nodeAttrs(mark)) const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path) if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path) if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined) === 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.path)
scan.openingSpellableLink = true scan.openingSpellableLink = true
return undefined return undefined
@@ -252,7 +274,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> { function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined) if (span.content === undefined) return success(undefined)
const parsed = parseInline(span.content, { ...scan, container: undefined, highlights: false, spans: span.spans }) const parsed = scanInline({ ...scan, container: undefined, deactivatedBefore: 0, highlights: false, openingSpellableLink: false, pending: '', pieces: [], source: span.content, spans: span.spans })
if (!parsed.ok) return parsed if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value) return success(parsed.value)
@@ -268,40 +290,44 @@ function pushNode(scan: Scan, node: AdfNode): void {
scan.pieces.push({ kind: 'nodes', nodes: [node] }) scan.pieces.push({ kind: 'nodes', nodes: [node] })
} }
function assemble(scan: Scan): Result<InlineContent> { function assemble(scan: Scan): Result<Scanned> {
const only = scan.pieces[0] const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const resolved = resolveNodes(scan.pieces, scan, true) const nodes = resolveNodes(scan.pieces, scan, true)
const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, scan.path)
}
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
} }
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it. // spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
function partText(nodes: readonly AdfNode[], scan: Scan): Result<AdfNode[]> { function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = [] const parted: AdfNode[] = []
for (const [index, node] of nodes.entries()) { for (const [index, item] of items.entries()) {
if (!isTextBreak(node, scan.carried)) { if (!isTextBreak(item)) {
parted.push(node) parted.push(item)
continue continue
} }
const previous = nodes[index - 1] const previous = items[index - 1]
const next = nodes[index + 1] const next = items[index + 1]
if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(next) || !readsAsOne(previous, next)) { if (previous === undefined || next === undefined || isTextBreak(previous) || isTextBreak(next) || !readJoins(scan)(previous, next)) {
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
} }
} }
return success(parted) return success(parted)
} }
function isTextBreak(node: AdfNode, carried: ReadonlySet<AdfNode>): boolean { function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean {
return node.type === textBreakName && !carried.has(node) return (previous, node) => !scan.ownMarks.has(previous) && !scan.ownMarks.has(node) && readsAsOne(previous, node)
}
function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item
}
function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = []
for (const item of items) if (!isTextBreak(item)) nodes.push(item)
return nodes
} }
function holdsCarry(pieces: readonly Piece[]): boolean { function holdsCarry(pieces: readonly Piece[]): boolean {
@@ -317,8 +343,8 @@ function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes)) return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
} }
function marksLink(nodes: readonly AdfNode[]): boolean { function marksLink(nodes: readonly Inline[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link')) return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
} }
function readDelimiterRun(scan: Scan, index: number): number { function readDelimiterRun(scan: Scan, index: number): number {
@@ -455,8 +481,8 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> { function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner, scan, false) const nodes = resolveNodes(inner, scan, false)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (nodes.value.some((node) => isTextBreak(node, scan.carried))) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path) if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
return success(nodes.value.map(altText).join('')) return success(adfNodes(nodes.value).map(altText).join(''))
} }
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included. // spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
@@ -468,22 +494,37 @@ function altText(node: AdfNode): string {
} }
// An image's alt text is plain, so `highlights` is off there and every `==` stays text. // An image's alt text is plain, so `highlights` is off there and every `==` stays text.
function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<AdfNode[]> { function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<Inline[]> {
const nodes = pieces.map(pieceNodes) const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces) const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs) const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings) writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : []) markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeAdjacentText(nodes.flat(), (previous, node) => !scan.carried.has(previous) && !scan.carried.has(node) && readsAsOne(previous, node))) return success(mergeText(nodes.flat(), readJoins(scan)))
}
// A text break is a wall: the text on either side joins only its own side.
function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] {
const merged: Inline[] = []
let run: AdfNode[] = []
for (const item of items) {
if (!isTextBreak(item)) {
run.push(item)
continue
}
for (const node of mergeAdjacentText(run, joins)) merged.push(node)
merged.push(item)
run = []
}
for (const node of mergeAdjacentText(run, joins)) merged.push(node)
return merged
} }
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it. // A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): AdfNode[] { function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) { switch (piece.kind) {
case 'break':
return [{ type: textBreakName }]
case 'carry': case 'carry':
return [piece.node] return [piece.node]
case 'highlight': case 'highlight':
@@ -496,6 +537,8 @@ function pieceNodes(piece: Piece): AdfNode[] {
return bracketNodes(piece) return bracketNodes(piece)
case 'run': case 'run':
return [] return []
case 'textBreak':
return [piece]
} }
} }
@@ -508,7 +551,7 @@ function delimiterRuns(pieces: readonly Piece[]): Run[] {
} }
// A run gives its delimiters up from the head closing and the tail opening; what is left between them is text. // A run gives its delimiters up from the head closing and the tail opening; what is left between them is text.
function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: readonly Pairing[]): void { function writeUnpaired(nodes: Inline[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
const heads = new Map<Run, number>() const heads = new Map<Run, number>()
const tails = new Map<Run, number>() const tails = new Map<Run, number>()
for (const pairing of pairings) { for (const pairing of pairings) {
@@ -523,7 +566,7 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado
} }
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks). // Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean { function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: readonly Pairing[]): boolean {
for (const pairing of pairings) { for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) } const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) { for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
@@ -534,12 +577,12 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true return true
} }
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] { function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] {
const found: HighlightDelimiter[] = [] const found: HighlightDelimiter[] = []
let line = 0 let line = 0
let position = 0 let position = 0
for (const [index, piece] of pieces.entries()) { for (const [index, piece] of pieces.entries()) {
const held = nodes[index] ?? [] const held = adfNodes(nodes[index] ?? [])
const [holder] = held const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) { if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position }) found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
@@ -555,7 +598,7 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[]
} }
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks. // Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] { function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): { closer: number; opener: number }[] {
const found = highlightDelimiters(pieces, nodes) const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = [] const paired: { closer: number; opener: number }[] = []
let closer = 0 let closer = 0
@@ -574,9 +617,9 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
} }
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes. // Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void { function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly { closer: number; opener: number }[]): void {
for (const [index, piece] of pieces.entries()) { for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter })) if (piece.kind === 'highlight') nodes[index] = adfNodes(nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
} }
for (const { closer, opener } of paired) { for (const { closer, opener } of paired) {
nodes[opener] = [] nodes[opener] = []
@@ -585,7 +628,8 @@ function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: re
} }
} }
function highlighted(node: AdfNode): AdfNode { function highlighted(node: Inline): Inline {
if (isTextBreak(node)) return node
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
return { ...node, marks: [editorHighlight, ...marks] } return { ...node, marks: [editorHighlight, ...marks] }
@@ -597,8 +641,9 @@ function markType(character: string, used: number): string {
} }
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation). // A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => { return nodes.map((node) => {
if (isTextBreak(node)) return node
const marks = nodeMarks(node) const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] } return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
}) })
@@ -1076,3 +1076,17 @@ test('names the directive mark left without the content it wraps', () => {
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named) assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
assert.equal(content(markdownToAdf('!adf:underline{}\n')), named) assert.equal(content(markdownToAdf('!adf:underline{}\n')), named)
}) })
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else'
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
}
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text')
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
})