3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Span a run inside inlineRuns, with the reducer asking the same grouping

This commit is contained in:
2026-11-03 22:34:53 +01:00
committed by Lillem4n
parent a8d9595a1f
commit d76d4c9440
8 changed files with 122 additions and 117 deletions
+33
View File
@@ -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
+5 -4
View File
@@ -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
@@ -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', () => {
+55 -88
View File
@@ -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<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] }
type SpannedNodes = { nodes: AdfNode[]; spans: boolean }
type RunGrouping = { carried: ReadonlySet<number>; 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<PortableLineFallback | undefined> {
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<LineAttempt> {
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<LineAttempt>, 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<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks)
function lineAttempt(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result<LineAttempt> {
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<Emission> {
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<Emission> {
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<number>): 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<number>): 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<number>, 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<number>, 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<number>): 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<number>): 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<number>): 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 })
+1 -1
View File
@@ -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 =
+9 -2
View File
@@ -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']
+15 -22
View File
@@ -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<AdfNode[]> {
+3
View File
@@ -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 `<table>` 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 |