From d76d4c9440fa778ebcd9859d6b80a40d22528da9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 3 Nov 2026 22:34:53 +0100 Subject: [PATCH] Span a run inside inlineRuns, with the reducer asking the same grouping --- docs/comprehension-history.md | 33 +++++ docs/decisions.md | 9 +- src/markdown/emit/adf-to-markdown.test.ts | 1 + src/markdown/emit/inline-line.ts | 143 +++++++++------------- src/markdown/emit/line-escaping.ts | 2 +- src/markdown/mark-spellings.ts | 11 +- src/markdown/portable/inline-reduction.ts | 37 +++--- todo.md | 3 + 8 files changed, 122 insertions(+), 117 deletions(-) diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index fa64a58..bda4bfd 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -1339,6 +1339,39 @@ Inherited architect, decided by: - Shape, Overall: `src/adf/inline-nodes.ts:42` `takenMarkTypes`: `undefined` now means only a node type outside the tables - Shape: `src/markdown/inline-content.ts:261` `directiveMarkPieces`: this caller overrides `marksItem`'s rule that a non-node is markable, with nothing at the call site saying why +## 2026-11-03T21:09:33Z, PR #116 at 9e8df99, against 8bcf4a5 + +Ruling: better + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | same | better | worse | better | better | +| Inherited architect | same | better | worse | better | better | + +Mid A, decided by: + +- Locality, Overall: `src/markdown/mark-spellings.ts:48` `insideRun`. What counts as "in the same run" is now one function, used by the emitter and the reducer, and its comment names the footgun; at the base the two compared marks separately and nothing said they had to agree. +- Locality: `src/markdown/portable/inline-reduction.ts:221` `sharedRuns`. The reducer no longer depends on a comparator exported from the editor-normal module, which made a reader check whether normalization mattered here. +- Self-sufficiency, Overall: `src/markdown/portable/inline-reduction.ts:231` `spellableLine` and `src/markdown/portable/inline-reduction.ts:165` `trimmedEdges`. Both loops now state in one line why they terminate. +- Self-sufficiency: `src/markdown/portable/inline-reduction.ts:262` `withoutMarks` with `markCount`. A pass that takes no mark returns `undefined` and `withoutFallback` refuses it, so the argument is enforced in the code. +- Shape: `src/markdown/mark-spellings.ts:21` `RunNode`, threaded through `src/markdown/emit/inline-line.ts:213` `emitRun` and `src/markdown/emit/inline-line.ts:232` `inlineRuns`. `outer` and `depth` always hold the same value, and a reader has to trace the recursion to answer why both exist. +- Shape: `src/markdown/emit/inline-line.ts:384` `tryLink` against `src/markdown/emit/inline-line.ts:364` `codeSpans`. The same count is spelled two ways a few lines apart. +- Shape: `src/markdown/emit/inline-line.ts:288` `carriedRun` with `lineNodes` in `InlineContext`. Nodes and range can no longer disagree, but the context gains a field only this function uses. +- Locality: `src/markdown/portable/inline-reduction.ts:250` `withoutOpeningLinkCode`. A `findIndex` over the run says what it means, where the base wrapped one mark in an array to reuse a helper. + +Inherited architect, decided by: + +- Locality, Self-sufficiency: `src/markdown/portable/inline-reduction.ts:262` `withoutMarks`, with `markCount` at `:278` and the comment at `:231` in `spellableLine`. A pass that takes no mark returns `undefined` and `withoutFallback` refuses it, so the termination claim can be checked in this file. +- Locality: `src/markdown/mark-spellings.ts:48` `insideRun`. The rule for what stays in a run has one home, used by the writer's `inlineRuns` and by the reducer, where the base left a reader to prove two comparators agreed on reduced marks. +- Shape, Locality: `src/markdown/portable/editor-normal.ts:11` `joinsWhenEditorNormal`. `sameMarkWhenEditorNormal` is gone, so the file now only answers what the editor joins and normalizes. +- Shape: `src/markdown/emit/inline-line.ts:213` `emitRun`. It carries `depth` and a per-node `RunNode.outer` that are always equal, and a reader has to trace every path to learn that, because nothing in this version needs a per-node count. +- Shape: `src/markdown/emit/inline-line.ts:384` `tryLink`, against `:368` in `codeSpans`. Two sources for one number sit in the same file, where the base used `depth + 1` in both. +- Locality: `src/markdown/emit/inline-line.ts:288` `carriedRun`, with `lineNodes` at `:37`. The nodes are sliced from a new context field by `range`, so `range` must be line-absolute; a small cost against the Locality gains. +- Shape: `src/markdown/portable/inline-reduction.ts:247` `withoutOpeningLinkCode`. `findIndex` with `insideRun` and an `end` bound reads directly as the opening link's run; a real gain, smaller than the `depth` and `outer` cost. +- Shape: `src/markdown/portable/inline-reduction.ts:221` `sharedRuns`. A seven-line walk threads a `RunNode` where the depth is already the loop index, replacing a two-line `while`; it uses the shared rule but reads no easier. +- Self-sufficiency: `src/markdown/portable/inline-reduction.ts:165` `trimmedEdges`. The new comment names the termination measure: whitespace or marks per character, which merging never adds. +- Self-sufficiency: `src/markdown/mark-spellings.ts:20` `RunNode` and `:47` `insideRun`. The comments pay for the file's extra concept, though `nodeMarks(node)[outer]` at `inline-line.ts:238` now needs a jump to another file, and `src/markdown/portable/inline-reduction.test.ts:39` has a stray comment above an unrelated test. + ## 2026-10-31T03:20:46Z, PR #112 at 66ad0be, against 8bcf4a5 Ruling: worse diff --git a/docs/decisions.md b/docs/decisions.md index cc9a7cd..1098e3a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -719,10 +719,11 @@ and every ruling stays in the history. Each restructure runs one rung higher; pa the maintainer decides. A ruling judges the code, never the behaviour: item 91's spanning stays, since the paragraphs it spells, such as bold around a code span, occur in documents all the time (2026-11-03). The maintainer's call on how: refactor first, ruled alone (item 166), then make spanning part of -grouping, with the span rule in one home: a node that can't hold the run's em, strike, strong or link mark is one more -case of `insideRun`, and whether it stands between two holders is asked where `inlineRuns` closes a run and where -`sharedRuns` and `withoutOpeningLinkCode` look past a neighbour. A restructure past a pass's round cap still goes unreviewed, -so the PR summary names it. +grouping, with the span rule in one home. `runSpans`, beside `insideRun`, says which node a run passes. `inlineRuns` ends +a run at its last holder, and the reducer asks that same grouping through `runsSharedWithNext`. `withoutOpeningLinkCode` +stays on `insideRun` alone, since it answers the parted line. A refactor that merges as its own chunk took its own ruling, +so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a +pass's round cap still goes unreviewed, so the PR summary names it. ## No schema validation diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 40785b3..2c9c707 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -936,6 +936,7 @@ test('spells an emphasis run once across the code spans, mentions, dates and inl assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { timestamp: '0' }, type: 'date' }, marked(' b', strong)))), '**a !adf:date{timestamp=0} b**\n') assert.equal(readsBack(document(paragraph(marked('a ', strong), { attrs: { id: 'm' }, type: 'mediaInline' }, marked(' b', strong)))), '**a !adf:mediaInline{id=m} b**\n') assert.equal(readsBack(document(paragraph(marked('a ', strong), marked('c', { type: 'code' }), mention, { type: 'hardBreak' }, marked(' b', strong)))), '**a `c`!adf:mention[@A]{id=x}\\\n!adf:text{text=" "}b**\n') + assert.equal(readsBack(document(paragraph(marked('a', strong), marked('https://x/', { attrs: { href: 'https://x/' }, type: 'link' }, { type: 'code' }), marked('b', strong)))), '**a[`https://x/`](https://x/)b**\n') }) test('spells a link run once across the mentions and inline cards between two nodes holding it, and parts it at code and inline media that do not hold it', () => { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index b6a537d..3017857 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -6,15 +6,15 @@ import type { RunFallback } from './unspellable-runs.ts' import type { WrittenFlavour } from '../flavour.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' -import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' +import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, runSpans, spellMarkAttributes, type RunNode } from '../mark-spellings.ts' import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims } from '../flavour.ts' import { highlightDelimiter } from '../inline-tokens.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' -import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -import { inlineNodeModel, takenMarkTypes, takesMark } from '../../adf/inline-nodes.ts' +import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts' +import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts' @@ -37,18 +37,17 @@ type InlineContext = { lineNodes: readonly AdfNode[] openingLinkAsDirective: boolean path: ConvertErrorPath + spans: boolean spansLines: boolean } -type InlineRun = MarkedRun | { index: number; kind: 'plain'; node: AdfNode } +type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode } type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] } type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean } -type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } - -type SpannedNodes = { nodes: AdfNode[]; spans: boolean } +type RunGrouping = { carried: ReadonlySet; spans: boolean } // docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark. export const readBackPasses = 32 @@ -69,7 +68,7 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const fallbacks = withheldMarkFallbacks(nodes, 'portable') - const emission = lineSegments(nodes, container, path, fallbacks) + const emission = lineSegments(nodes, container, path, fallbacks, false) if (!emission.ok) return emission if (fallbacks.carried.size > 0 || emission.value.carries !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path) const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes }) @@ -110,25 +109,20 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con } } -// spec/flavour.md, Marks: a pass writes the spanned line where a run spans a node and the line needs no fallback; otherwise it writes the parted line and takes that line's fallbacks. +// spec/flavour.md, Marks: a pass writes the spanned line where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks. export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const spanned = spannedNodes(nodes, fallbacks.carried) - if (spanned.spans) { - const attempt = attemptNodes(spanned.nodes, container, path, fallbacks) - if (spannedLineRefusal(attempt, fallbacks.flavour) === undefined) return attempt - } - return attemptNodes(nodes, container, path, fallbacks) + const spanned = lineAttempt(nodes, container, path, fallbacks, true) + return spanned.ok && writesSpanned(spanned.value, fallbacks.flavour) ? spanned : lineAttempt(nodes, container, path, fallbacks, false) } -function spannedLineRefusal(attempt: Result, flavour: WrittenFlavour): 'directive' | 'error' | 'fallback' | undefined { - if (!attempt.ok) return 'error' - if (attempt.value.fallback !== undefined) return 'fallback' +function writesSpanned({ fallback, segments }: LineAttempt, flavour: WrittenFlavour): boolean { + if (fallback !== undefined) return false // A span can leave whitespace at a line edge, which only `!adf:text` spells. - return !flavourClaims[flavour].directives && attempt.value.segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix)) ? 'directive' : undefined + return flavourClaims[flavour].directives || !segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix)) } -function attemptNodes(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const emission = lineSegments(nodes, container, path, fallbacks) +function lineAttempt(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result { + const emission = lineSegments(nodes, container, path, fallbacks, spans) if (!emission.ok) return emission const { carries, segments } = emission.value return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour)) @@ -160,8 +154,8 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: return success(null) } -function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result { - const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spansLines: container === 'paragraph' } +function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result { + const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spans, spansLines: container === 'paragraph' } const emission = emitRun(nodes.map((node) => ({ node, outer: 0 })), 0, 0, context) if (!emission.ok) return emission if (emission.value.carries !== undefined) return emission @@ -235,7 +229,7 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c if (depth > largestNesting) { return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path) } - const runs = inlineRuns(nodes, depth, firstIndex, context.carried) + const runs = inlineRuns(nodes, firstIndex, context) const carries: NodeRange[] = [] const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { @@ -250,83 +244,56 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c return success(carry === undefined ? { segments } : { carries: [carry, ...others] }) } -function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet): InlineRun[] { +function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] { const runs: InlineRun[] = [] - for (const [offset, node] of nodes.entries()) { + let next = 0 + for (const [offset, { node, outer }] of nodes.entries()) { + if (offset < next) continue const index = firstIndex + offset - // `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry. - const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth] + // A carried node spells its marks inside the carry. + const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer] if (mark === undefined) { runs.push({ index, kind: 'plain', node }) continue } - const previous = runs[runs.length - 1] - if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node) - else runs.push({ index, kind: 'marked', mark, nodes: [node] }) + const inside = nodesInRun(nodes, offset, mark, firstIndex, grouping) + runs.push({ index, kind: 'marked', mark, nodes: inside }) + next = offset + inside.length } return runs } -// spec/flavour.md, Marks: the nodes as the spanned line writes them; `carried` names the nodes the line carries, which no run spans. -export function spannedNodes(nodes: readonly AdfNode[], carried: ReadonlySet): SpannedNodes { - const spanned: SpannedNodes = { nodes: [], spans: false } - lendMarks(nodes, 0, 0, carried, spanned) - return spanned -} - -function lendMarks(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet, spanned: SpannedNodes): void { - // Past this depth the spanned line is refused as the parted one is. - if (depth > largestNesting) { - for (const node of nodes) spanned.nodes.push(node) - return +// The run of `mark` opening at `first` ends at the last node holding the mark, so the nodes it spans past that one stay outside. +function nodesInRun(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, spans }: RunGrouping): RunNode[] { + const inside: RunNode[] = [] + let holders = 0 + for (let offset = first; offset < nodes.length; offset += 1) { + const runNode = nodes[offset] + if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break + const held = insideRun(runNode, mark) + if (held === undefined && !(spans && runSpans(runNode.node, mark))) break + inside.push(held ?? runNode) + if (held !== undefined) holders = inside.length } - for (const run of spannedRuns(inlineRuns(nodes, depth, firstIndex, carried), depth, carried, spanned)) { - if (run.kind === 'plain') spanned.nodes.push(run.node) - else lendMarks(run.nodes, depth + 1, run.index, carried, spanned) + return inside.slice(0, holders) +} + +// For each leaf but the last, how many runs of the spanned line hold both it and the next leaf. +export function runsSharedWithNext(leaves: readonly AdfNode[]): number[] { + const shared = leaves.map(() => 0) + countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, 0, shared) + return shared +} + +function countSharedRuns(nodes: readonly RunNode[], depth: number, firstIndex: number, shared: number[]): void { + if (depth > largestNesting) return + for (const run of inlineRuns(nodes, firstIndex, { carried: new Set(), spans: true })) { + if (run.kind === 'plain') continue + for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1 + countSharedRuns(run.nodes, depth + 1, run.index, shared) } } -function spannedRuns(runs: readonly InlineRun[], depth: number, carried: ReadonlySet, spanned: SpannedNodes): InlineRun[] { - const joined: InlineRun[] = [] - for (const run of runs) { - const open = run.kind === 'marked' ? openRun(joined, run.mark, carried) : undefined - if (open === undefined) { - joined.push(run) - continue - } - for (const between of joined.splice(joined.lastIndexOf(open) + 1)) for (const node of runNodes(between)) open.nodes.push(withMark(node, open.mark, depth)) - for (const node of runNodes(run)) open.nodes.push(node) - spanned.spans = true - } - return joined -} - -// The last run of `mark`, an emphasis or link mark, where `mark` spans every node after that run, `undefined` where none. -function openRun(runs: readonly InlineRun[], mark: AdfMark, carried: ReadonlySet): MarkedRun | undefined { - const kind = markSpelling(mark.type)?.kind - if (kind !== 'emphasis' && kind !== 'link') return undefined - for (let position = runs.length - 1; position >= 0; position -= 1) { - const run = runs[position] - if (run?.kind === 'marked' && identicalMark(run.mark, mark)) return run - if (run === undefined || !runNodes(run).every((node, offset) => spansNode(node, run.index + offset, mark, carried))) return undefined - } - return undefined -} - -// spec/flavour.md, Marks: a run breaks at a carried node, at a node a reader would give its mark, and at an empty marks array, whose `marks=empty` a lent mark would erase. -function spansNode(node: AdfNode, index: number, mark: AdfMark, carried: ReadonlySet): boolean { - return !carries(node, carried, index) && !takesMark(node, mark.type) && node.marks?.length !== 0 -} - -function runNodes(run: InlineRun): readonly AdfNode[] { - return run.kind === 'plain' ? [run.node] : run.nodes -} - -function withMark(node: AdfNode, mark: AdfMark, depth: number): AdfNode { - const marks = nodeMarks(node) - return { ...node, marks: [...marks.slice(0, depth), mark, ...marks.slice(depth)] } -} - function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { if (previous?.kind !== 'plain' || run.kind !== 'plain') return false if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false @@ -460,7 +427,7 @@ function tryLink(nodes: readonly RunNode[], mark: AdfMark, depth: number, range: const href = linkHref(nodeAttrs(mark)) if (href === undefined) return carriedRun(range, context) const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective - const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), depth + 1, context.bracketed, titleEscapes(context.flavour, context.container)) + const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), nodes[0]?.outer ?? 0, context.bracketed, titleEscapes(context.flavour, context.container)) if (commonMark === undefined) return undefined if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] }) const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 4e539c9..9d6396a 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -19,7 +19,7 @@ export type InlineEscaping = (typeof escapingCodes)[number] export type NodeRange = { first: number; last: number } -// `depth` is the index into each node's marks array that the run spells. +// `depth` is the run's nesting level. On a parted line, the only line a fallback reads, that is the index into each node's marks array that the run spells. export type MarkRun = NodeRange & { depth: number } export type InlineSegment = diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 0bebc98..64ed82b 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -9,6 +9,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts' import { isAutolink } from './commonmark/grammar.ts' import { isMarkType, linkAttrs, linkMarkFrom, markAttributes, type LinkAttrs } from '../adf/mark-attributes.ts' import { spellEmptyKeys } from './directive/empty-keys.ts' +import { takesMark } from '../adf/inline-nodes.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } @@ -44,12 +45,18 @@ export function isEmphasis(mark: AdfMark): boolean { return markSpelling(mark.type)?.kind === 'emphasis' } -// spec/flavour.md, Marks. The portable reducer must ask this too, or it trims edge whitespace by runs the writers do not spell. +// spec/flavour.md, Marks. export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined { const held = nodeMarks(node)[outer] return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined } +// spec/flavour.md, Marks. The readers refuse `marks=empty` inside a mark's spelling. +export function runSpans(node: AdfNode, mark: AdfMark): boolean { + const kind = markSpelling(mark.type)?.kind + return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0 +} + export function linkMark(definition: LinkDefinition): AdfMark { return linkMarkFrom(toLinkAttrs(definition)) } @@ -72,7 +79,7 @@ export function linkHref(attrs: AdfAttributes): string | undefined { return typeof href === 'string' ? href : undefined } -// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0. +// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0. export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean, escapes: TitleEscapes): CommonMarkLink | undefined { if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined const title = attrs['title'] diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 4363e0d..d260148 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -5,11 +5,11 @@ import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { imageAside } from '../image-aside.ts' -import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts' +import { insideRun, isEmphasis } from '../mark-spellings.ts' import { joinsWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' -import { portableLineFallback, readBackPasses, spannedNodes, type PortableLineFallback } from '../emit/inline-line.ts' +import { portableLineFallback, readBackPasses, runsSharedWithNext, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' @@ -165,12 +165,11 @@ export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { // Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark. for (let current = leaves; ; ) { const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal)) - // Judged beside the nodes as the spanned line writes them, a portable line carrying none, so whitespace beside a code span inside a run keeps the run's mark. - const spanned = spannedNodes(merged, new Set()).nodes + const shared = runsSharedWithNext(merged) let changed = false const trimmed: AdfNode[] = [] for (const [index, leaf] of merged.entries()) { - const edges = leafEdges(leaf, spanned[index - 1], spanned[index + 1]) + const edges = leafEdges(leaf, runsBeside(merged[index - 1], shared[index - 1]), runsBeside(merged[index + 1], shared[index])) if (edges === undefined) { trimmed.push(leaf) continue @@ -191,15 +190,20 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] { return leaves.slice(first, last + 1) } +// How many runs a leaf shares with its neighbour, `undefined` at a line edge. +function runsBeside(neighbour: AdfNode | undefined, shared: number | undefined): number | undefined { + return neighbour === undefined || neighbour.type === 'hardBreak' ? undefined : shared +} + // spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge. -function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | undefined): AdfNode[] | undefined { +function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: number | undefined): AdfNode[] | undefined { const marks = nodeMarks(leaf) const text = leaf.text if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined const lead = text.slice(0, text.search(/[^ \t]|$/)) const trail = text.slice(text.search(/[ \t]*$/)) - const leadDepth = edgeDepth(marks, previous, lead) - const trailDepth = edgeDepth(marks, next, trail) + const leadDepth = edgeDepth(marks, runsBefore, lead) + const trailDepth = edgeDepth(marks, runsAfter, trail) if (leadDepth === marks.length && trailDepth === marks.length) return undefined if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))] const edges: AdfNode[] = [] @@ -211,22 +215,11 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | } // How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge. -function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined { +function edgeDepth(marks: readonly AdfMark[], runsBeside: number | undefined, whitespace: string): number | undefined { if (whitespace === '') return marks.length - const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak' - const shared = lineEdge ? 0 : sharedRuns(marks, neighbour) - const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type)) + const stripping = marks.findIndex((mark, index) => index >= (runsBeside ?? 0) && edgeStrippingMarks.includes(mark.type)) const kept = stripping === -1 ? marks.length : stripping - return kept === 0 && lineEdge ? undefined : kept -} - -function sharedRuns(marks: readonly AdfMark[], neighbour: AdfNode): number { - let inside: RunNode | undefined = { node: neighbour, outer: 0 } - for (const [depth, mark] of marks.entries()) { - inside = insideRun(inside, mark) - if (inside === undefined) return depth - } - return marks.length + return kept === 0 && runsBeside === undefined ? undefined : kept } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { diff --git a/todo.md b/todo.md index e583cf4..23284bb 100644 --- a/todo.md +++ b/todo.md @@ -15,6 +15,9 @@ | 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 | | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | | 73 | 0.3.0 | 132, 133 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 163 | 0.3.0 | | | **Gather a corpus of real-world ADF documents from public Jira and Confluence sites, each recording where it came from and under what licence.** | 3 | 2 | 4 | 7 | 4 | 6 by 3 | 3.0 | +| 164 | 0.3.0 | 163 | | **Publish a feature table setting chosen corpus examples beside what each call writes for them, readable at a glance by people and parseable by LLMs, and kept current by the gate.** | 3 | 2 | 1 | 4 | 5 | 3 by 6, 6 by 4 | 6.2 | +| 165 | 0.3.0 | 164 | | **Have a reader panel seated from `## Audience` judge each feature-table row reasonable or not, and file an item for each row it rejects.** | 2 | 2 | 1 | 3 | 3 | 6 by 5 | 7.5 | | 72 | 0.3.0 | 132, 133 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | | 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 |