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