35b - the writer spells the plain flavour #146

Merged
lilleman merged 14 commits from 35b into main 2026-09-29 18:40:30 +02:00
6 changed files with 61 additions and 53 deletions
Showing only changes of commit 43869a7219 - Show all commits
+1 -1
View File
@@ -147,7 +147,7 @@ read replaces mentions, attachments and macros with text.
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`, `_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`,
`_(link card not included)_`, `_(extension not included)_`. `_(link card not included)_`, `_(extension not included)_`.
- `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
keeping its text, and so does a mark CommonMark cannot spell where it stands. keeping its text, and so does a mark the flavour cannot spell where it stands.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops. paragraph drops.
+3 -2
View File
@@ -162,7 +162,7 @@ function quoted(text: string): string {
.join('\n') .join('\n')
} }
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const items: PlacedBlock[][] = [] const items: PlacedBlock[][] = []
let headroom = largestNesting - depth - 1 let headroom = largestNesting - depth - 1
// A child other than a task nests in the task before it. // A child other than a task nests in the task before it.
@@ -176,7 +176,8 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
else for (const block of walk.value.blocks) previous.push(block) else for (const block of walk.value.blocks) previous.push(block)
} }
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- ')) const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
return lines.includes(undefined) ? undefined : success({ headroom, spelling: 'list', text: lines.join('\n') }) // The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
} }
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> { function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
+3 -2
View File
@@ -38,7 +38,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean } type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun } export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun; runs: MarkRun[] }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour) const emitted = emitLine(nodes, container, path, flavour)
@@ -129,7 +129,8 @@ function attemptLine(segments: readonly InlineSegment[], container: LineContaine
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } { function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container, flavour) const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' } if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
if (assembled.unspellableRun !== undefined) return { kind: 'unspellable-run', run: assembled.unspellableRun } const [run] = assembled.unspellableRuns
if (run !== undefined) return { kind: 'unspellable-run', run, runs: assembled.unspellableRuns }
const lines = assembled.line.split('\n') const lines = assembled.line.split('\n')
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1 const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' } return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
+14 -12
View File
@@ -23,7 +23,7 @@ export type InlineSegment =
| { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string } | { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: MarkRun | undefined } export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] }
type ScanLine = { position: LinePosition; start: number; text: string } type ScanLine = { position: LinePosition; start: number; text: string }
@@ -81,10 +81,10 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, hi
output += scan.charAt(index) output += scan.charAt(index)
} }
if (container === 'paragraph' && opensLinkDefinition(output)) { if (container === 'paragraph' && opensLinkDefinition(output)) {
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined } if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } return { line: `\\${output}`, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
return { line: output, unspellableRun: unspellableRun(segments, output, placements) } return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> { function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> {
@@ -148,25 +148,27 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl
return escaped return escaped
} }
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun | undefined { // One emphasis run, the innermost, or every highlight run the line cannot spell.
function unspellableRuns(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const { nodes, runs } = emittedRuns(segments, placements, output) const { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs) const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? unreadHighlight(segments, output, placements) : nodes[pair] const run = pair === undefined ? undefined : nodes[pair]
return run === undefined ? unreadHighlights(segments, output, placements) : [run]
} }
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks and both share a line. // No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
function unreadHighlight(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun | undefined { function unreadHighlights(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const unread: MarkRun[] = []
let cursor = 0 let cursor = 0
let opener = 0
for (const segment of segments) { for (const segment of segments) {
const start = placements[cursor] ?? 0 const start = placements[cursor] ?? 0
cursor += segment.text.length cursor += segment.text.length
if (segment.highlight === undefined) continue if (segment.highlight === undefined) continue
const flanking = highlightFlanking(output, start) const flanking = highlightFlanking(output, start)
if (segment.highlight === 'open') opener = start const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
if (segment.highlight === 'open' ? !flanking.opens : !flanking.closes || output.slice(opener, start).includes('\n')) return segment.nodes if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
} }
return undefined return unread
} }
function misflanked(runs: readonly EmittedRun[]): number | undefined { function misflanked(runs: readonly EmittedRun[]): number | undefined {
+13 -6
View File
@@ -1,5 +1,6 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from './line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
@@ -130,7 +131,7 @@ function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' } return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' }
} }
// A highlight goes outermost so its run is one run at depth 0, and code innermost, the only place its spelling holds. // A highlight goes first, where `highlighted` looks for it, and code last, the only place its spelling holds.
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] { function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
const kept: AdfMark[] = [] const kept: AdfMark[] = []
for (const mark of marks) { for (const mark of marks) {
@@ -174,7 +175,7 @@ function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
const marks = nodeMarks(leaf) const marks = nodeMarks(leaf)
if (marks[0]?.type === highlight) { if (marks[0]?.type === highlight) {
const held = marks.slice(1) const held = marks.slice(1)
shared = run.length === 0 ? held.filter((mark) => mark.type !== 'code') : shared.filter((mark) => held.some((other) => sameMark(other, mark))) shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
run.push(leaf) run.push(leaf)
continue continue
} }
@@ -265,7 +266,7 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver
} }
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined { function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
if (fallback.kind === 'unspellable-run') return withoutMark(leaves, fallback.run.first, fallback.run.last, fallback.run.depth) if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line) const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
const mark = nodeMarks(leaves[first] ?? {})[0] const mark = nodeMarks(leaves[first] ?? {})[0]
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
@@ -274,7 +275,7 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target. // A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code') const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
return withoutMark(leaves, first, last, 0) return withoutMarks(leaves, [{ depth: 0, first, last }])
} }
function lineStart(leaves: readonly AdfNode[], line: number): number { function lineStart(leaves: readonly AdfNode[], line: number): number {
@@ -283,6 +284,12 @@ function lineStart(leaves: readonly AdfNode[], line: number): number {
return index return index
} }
function withoutMark(leaves: readonly AdfNode[], first: number, last: number, depth: number): AdfNode[] { // The runs cover disjoint leaves, so one pass drops them all.
return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth)))) 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))
})
} }
+27 -30
View File
@@ -164,18 +164,24 @@ function joinedLists(first: AdfNode, second: AdfNode): AdfNode {
return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] } return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] }
} }
// A task keeps its marker as text; a list item stands as one, and anything else nests in the item before it.
function tasksAsText(list: AdfNode): AdfNode { function tasksAsText(list: AdfNode): AdfNode {
if (list.type !== 'taskList') return list if (list.type !== 'taskList') return list
const items: AdfNode[] = [] const items: AdfNode[] = []
for (const child of nodeContent(list)) { for (const child of nodeContent(list)) {
const marker = taskMarker(nodeAttrs(child)['state']) const previous = isTask(child) || child.type === 'listItem' ? undefined : items.pop()
const previous = child.type === 'taskItem' || child.type === 'blockTaskItem' ? undefined : items.pop() items.push(itemOf(previous === undefined ? taskAsText(child) : mergedLists([...nodeContent(previous), child])))
if (previous !== undefined) items.push(itemOf([...nodeContent(previous), child]))
else items.push(itemOf(child.type === 'taskItem' ? [paragraph(nodeContent(child).length === 0 ? [text(marker)] : [text(`${marker} `), ...nodeContent(child)])] : marked(nodeContent(child), marker)))
} }
return { content: items, type: 'bulletList' } return { content: items, type: 'bulletList' }
} }
function taskAsText(child: AdfNode): readonly AdfNode[] {
const marker = taskMarker(nodeAttrs(child)['state'])
if (child.type === 'taskItem') return [paragraph(nodeContent(child).length === 0 ? [text(marker)] : [text(`${marker} `), ...nodeContent(child)])]
if (child.type === 'blockTaskItem') return marked(nodeContent(child), marker)
return child.type === 'listItem' ? nodeContent(child) : [child]
}
function paragraph(content: readonly AdfNode[]): AdfNode { function paragraph(content: readonly AdfNode[]): AdfNode {
return { content: [...content], type: 'paragraph' } return { content: [...content], type: 'paragraph' }
} }
@@ -209,10 +215,17 @@ function reducePanel(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const held = nodeAttrs(node)['title'] const held = nodeAttrs(node)['title']
const title = typeof held === 'string' ? oneLine(held).replace(/^[ \t]+|[ \t]+$/g, '') : '' const title = typeof held === 'string' ? withoutTrailingBlanks(oneLine(held).replace(/^[ \t]+/, '')) : ''
return contained(title === '' ? { type: node.type } : { attrs: { title }, type: node.type }, node, reduction) return contained(title === '' ? { type: node.type } : { attrs: { title }, type: node.type }, node, reduction)
} }
// A backward scan: an unanchored-end regex retries from every blank in a long run.
function withoutTrailingBlanks(text: string): string {
let end = text.length
while (end > 0 && (text.charAt(end - 1) === ' ' || text.charAt(end - 1) === '\t')) end -= 1
return text.slice(0, end)
}
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const level = nodeAttrs(node)['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction) if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
@@ -261,16 +274,21 @@ function blankedLines(code: readonly AdfNode[]): AdfNode[] {
return blanked === '' ? [] : [text(blanked)] return blanked === '' ? [] : [text(blanked)]
} }
// A task list opening with a task and holding tasks and task lists alone keeps its spelling; a list nests in the task before it. // A task list opening with a task and holding tasks and task lists alone keeps its spelling, a list nesting in the task before it; any other keeps its markers as text.
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const children = nodeContent(node) const children = nodeContent(node)
if (!isTask(children[0]) || children.some((child) => !isTask(child) && child.type !== 'taskList')) return reduceTasksAsText(node, reduction) const regular = isTask(children[0]) && children.every((child) => isTask(child) || child.type === 'taskList')
const tasks: AdfNode[] = [] const tasks: AdfNode[] = []
let nested: AdfNode[] = [] let nested: AdfNode[] = []
for (const [index, child] of children.entries()) { for (const [index, child] of children.entries()) {
const at = childReduction(reduction, index) const at = childReduction(reduction, index)
const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at) const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at)
if (!reduced.ok) return reduced if (!reduced.ok) return reduced
if (!regular) {
const standsAlone = !isTask(child) && child.type !== 'taskList'
for (const block of standsAlone ? [{ content: reduced.value, type: 'listItem' }] : reduced.value) tasks.push(block)
continue
}
if (isTask(child)) { if (isTask(child)) {
nestIn(tasks, nested) nestIn(tasks, nested)
nested = [] nested = []
@@ -278,7 +296,8 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
for (const block of reduced.value) (isTask(child) ? tasks : nested).push(block) for (const block of reduced.value) (isTask(child) ? tasks : nested).push(block)
} }
nestIn(tasks, nested) nestIn(tasks, nested)
return success([{ content: tasks, type: 'taskList' }]) if (regular) return success([{ content: tasks, type: 'taskList' }])
return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList'))
} }
// The writer nests a list in the task before it, so one closing a block task item's blocks merges with it. // The writer nests a list in the task before it, so one closing a block task item's blocks merges with it.
@@ -302,28 +321,6 @@ function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks
} }
function reduceTasksAsText(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const items: AdfNode[] = []
for (const [index, child] of nodeContent(node).entries()) {
const blocks = taskBlocks(child, childReduction(reduction, index))
if (!blocks.ok) return blocks
const previous = child.type === 'taskList' ? items.pop() : undefined
items.push(itemOf(previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value])))
}
return success(listOf(items, 'bulletList'))
}
function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
const marker = taskMarker(nodeAttrs(child)['state'])
if (child.type === 'taskItem') {
const content = reduceInline(nodeContent(child), 'paragraph', at.path, at.depth)
return content.ok ? success([paragraph(content.value.length === 0 ? [text(marker)] : [text(`${marker} `), ...content.value])]) : content
}
if (child.type !== 'blockTaskItem') return reduceStanding(child, at)
const blocks = reduceBlocks(nodeContent(child), at)
return blocks.ok ? success(marked(blocks.value, marker)) : blocks
}
// The marker leads the first paragraph, or stands as one where the blocks open with another. // The marker leads the first paragraph, or stands as one where the blocks open with another.
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] { function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
const [first, ...rest] = blocks const [first, ...rest] = blocks