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

Answer architecture round 1: 0 🔴, 7 🟡 (0 misread); the read-back loop parts from the verdict, the reader takes the opening-link consult, the reading is absent on the parse path, a refused link escapes no backtick; root imports, highlight pairing and closeBracket filed as items 153 to 155

This commit is contained in:
2026-10-28 14:42:09 +01:00
committed by Lillem4n
parent b0bf362ef9
commit 5ede92bf91
8 changed files with 91 additions and 45 deletions
+2 -1
View File
@@ -950,7 +950,8 @@ by reading the escaped line back.
A writer reads each escaped line back. Where a code span opens at a text backtick, every text
backtick in the line escapes, and where a link or an image opens at a text `[`, every text `[`
does: `[a](b) [c]` is written `\[a](b) \[c]`. A text `[` or backtick that opens nothing stays
bare. Escaping only up to the last construct text opens read as arbitrary to the panel, and
bare. A line the reader refuses without showing what opened at text escapes every text backtick
and `[`. Escaping only up to the last construct text opens read as arbitrary to the panel, and
escaping each opener in turn costs a read per opener, against goal 9. The grammar:
`spec/flavour.md` §Canonical form.
+2 -1
View File
@@ -57,7 +57,8 @@ normalizes to it through the round-trip.
A backtick run escapes whole. The escaped line is then read back. Where it opens a code span at a
text backtick, every text backtick in the line escapes: CommonMark reads no escape inside a code
span, so a backtick string beside an escape can still close one. Where it opens a link or an
image at a text `[`, every text `[` in the line escapes.
image at a text `[`, every text `[` in the line escapes. A line the reader refuses without
showing what opened at text escapes every text backtick and `[`.
Where a paragraph opens with what reads as a link reference definition, which resolves before
any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[`
escapes and an opening link takes the directive link (Marks).
@@ -351,6 +351,11 @@ test('escapes every text bracket in a line where one opens link text, and every
assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n')
})
test('keeps a text backtick bare in a line where a text bracket opens a link the reader refuses', () => {
const refused = paragraph({ text: 'a ` [', type: 'text' }, { attrs: { id: '1' }, type: 'mention' }, { text: '](u)', type: 'text' })
assert.equal(markdown(adfToLosslessMarkdown(document(refused))), 'a ` \\[!adf:mention{id=1}](u)\n')
})
test('escapes a heading closing sequence', () => {
const heading = (text: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
assert.equal(heading('done #'), '## done \\#\n')
+23 -7
View File
@@ -35,6 +35,9 @@ type Escapings = (index: number) => InlineEscaping | undefined
// Where a segment's first character landed in the output.
type PlacedSegment = { segment: InlineSegment; start: number }
// The escaped line once reading it back asks for no further escape.
type ReadBack = { output: string; placed: PlacedSegment[]; reading: InlineReading }
// Which of a run's characters escape: every escapable one, or every one before `end`.
type RunEscapes = { end: number; escapable: false } | { escapable: true }
@@ -87,6 +90,11 @@ 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) }
}
function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack {
const scan = segments.map((segment) => segment.text).join('')
// One byte a character, never an array element or Set entry: a line may run to hundreds of millions of characters.
const codes = new Uint8Array(scan.length)
@@ -105,18 +113,26 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl
for (;;) {
const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes))
const { faulted, reading } = readLineBack(output, container, claims)
const opened = faulted ? { backticks: true, brackets: true } : textOpeners(placed, output.length, reading)
const shown = textOpeners(placed, output.length, reading)
// A code span a directive slot swallowed leaves no record before the directive fault.
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) {
const unspellable = unspellableRuns(placed, reading)
if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output, unspellableRuns: unspellable }
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRuns: unspellable }
}
if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) return { output, placed, reading }
escapes = next
}
}
// 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.
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}` }
}
// Only the emitter's link opener is a `[` segment spelling nodes.
function opensWithLink([first]: readonly PlacedSegment[]): boolean {
return first?.segment.nodes !== undefined && first.segment.text === '['
}
function withTextEscapes(scan: string, escapings: Escapings, claimed: Uint8Array, { backticks, brackets }: TextEscapes): Uint8Array {
if (!backticks && !brackets) return claimed
const escaped = claimed.slice()
+26 -16
View File
@@ -69,7 +69,7 @@ type Piece =
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number }
| { closes: boolean; kind: 'highlight'; offset: number; opens: boolean }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number; offset: number }
type Run = Extract<Piece, { kind: 'run' }> & { 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 = {
@@ -81,6 +81,8 @@ export type InlineReading = {
readonly links: number[]
}
type ReadingEntry = { at: number; closer: number; kind: 'emphasis'; width: number } | { at: number; closer: number; kind: 'highlights' } | { at: number; kind: 'codeSpans' | 'links' }
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
// CommonMark's bracket stack, innermost last.
@@ -98,7 +100,8 @@ type Scan = {
path: ConvertErrorPath
pending: string
pieces: Piece[]
reading: InlineReading
// Only a read-back takes a reading; parsing records none.
reading: InlineReading | undefined
source: string
spans: NestedSpans
targets: TargetMemo
@@ -113,30 +116,37 @@ const hreflessLink = 'the link mark spells its href: this one spells none'
const noDefinitions: LinkDefinitions = new Map()
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them'
export const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer.
export function readInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent & { openingSpellableLink: boolean }> {
const scan = freshScan(source, { definitions, path, reading: emptyReading(), targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans })
// 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.
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)
if (!scanned.ok) return scanned
const nodes = partText(contentNodes(scanned.value.pieces), scan)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
const { image } = scanned.value
const { openingSpellableLink } = scan
return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink })
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.
export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } {
const reading = emptyReading()
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 emptyReading(): InlineReading {
return { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] }
function record({ reading }: Pick<Scan, 'reading'>, 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)
else reading[entry.kind].push(entry.at)
}
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
@@ -173,7 +183,7 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
return closeBracket(scan, index)
case 'code-span':
flush(scan, false)
scan.reading.codeSpans.push(scan.offset + index)
record(scan, { at: scan.offset + index, kind: 'codeSpans' })
pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' })
return success(end)
case 'delimiter-run':
@@ -420,7 +430,7 @@ function closeBracket(scan: Scan, index: number): Result<number> {
if (!open.image) scan.linkOpenersFrom = scan.brackets.length
// Before `linkPieces`' own-marks guard: brackets that go literal mark nothing, so a carry inside them is no error.
if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index))
scan.reading.links.push(scan.offset + open.start - 1)
record(scan, { at: scan.offset + open.start - 1, kind: 'links' })
const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition())
if (!closed.ok) return closed
scan.pieces.length = open.index
@@ -512,11 +522,11 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean):
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
for (const { closer, closerOffset, opener, openerOffset, used } of pairings) scan.reading.emphasis.set(opener.offset + openerOffset, { closer: closer.offset + closerOffset, width: used })
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)
const paired = highlights ? pairedHighlights(pieces, nodes) : []
for (const { closerOffset, openerOffset } of paired) scan.reading.highlights.set(openerOffset, closerOffset)
for (const { closerOffset, openerOffset } of paired) record(scan, { at: openerOffset, closer: closerOffset, kind: 'highlights' })
markHighlights(pieces, nodes, paired)
return success(nodes)
}
@@ -563,7 +573,7 @@ function pieceNodes(piece: Piece): Inline[] {
function delimiterRuns(pieces: readonly Piece[]): Run[] {
const runs: Run[] = []
for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length, offset: piece.offset })
if (piece.kind === 'run') runs.push({ ...piece, index })
}
return runs
}
+4 -17
View File
@@ -2,9 +2,8 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from '../directive/node-reading.ts'
import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../flavour.ts'
import type { InlineContent, LinkDefinitions } from '../inline-content.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from '../inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts'
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
@@ -15,7 +14,7 @@ import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { notAString } from '../../not-a-string.ts'
import { parseBlocks } from './blocks.ts'
import { readInlineContent, spellableLink } from '../inline-content.ts'
import { parseInlineContent } from '../inline-content.ts'
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
import { spellsEmpty } from '../directive/empty-keys.ts'
@@ -247,26 +246,14 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C
// spec/flavour.md, The CommonMark image: only a `paragraph` block gives a lone image the block it needs.
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective)
if (!content.ok) return content
const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
}
export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour, openingLinkTakesDirective)
if (!content.ok) return content
return success(withContent(node, content.value.nodes))
}
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const read = readInlineContent(source, definitions, path, container, flavour)
if (!read.ok) return read
const { image, nodes, openingSpellableLink } = read.value
if (openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
return success(image === undefined ? { nodes } : { image, nodes })
}
@@ -2,10 +2,12 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, parseInlineContent, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
import { mintTaskIds } from './task-ids.ts'
import { openingLinkTakesDirective } from '../emit/spelling-questions.ts'
import { parseInlineContent } from '../inline-content.ts'
import { pathTo, splitTaskList } from './task-list.ts'
import { positioned, success } from '../../result.ts'
import { withinEngineLimits } from '../../length-bound.ts'
@@ -55,7 +57,7 @@ function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective)
if (!title.ok) return title
const text = titleText(title.value.nodes)
return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1))
+25 -1
View File
@@ -6,7 +6,7 @@
`Bar = 0.1`
`Next ID = 153`
`Next ID = 156`
## Items
@@ -24,8 +24,11 @@
| 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 | 33, 91, 109, 115, 132, 133, 145, 146, 150, 152 | | **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 |
| 72 | 0.3.0 | 33, 91, 109, 115, 132, 133, 145, 146, 150, 152 | 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 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
| 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
| 154 | 0.4.0 | | | **Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 |
| 155 | 0.4.0 | | | **Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 |
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 |
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
@@ -186,6 +189,14 @@ Gitea copy holds no issues or releases to move. Actions stay off on the mirror:
otherwise run there. When this lands, the `docs/decisions.md` entry "GitHub is canonical" loses its
interim sentence.
### 153. 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.
The writers read each escaped line back through `src/markdown/inline-content.ts`'s scan. A writer
question put back on that scan's path, such as the opening-link consult it takes as an argument
today, would close the cycle writer → reader → writer and recurse without bound on a paragraph
opening with a spellable `!adf:link` (§Nothing recurses unbounded). No `no-restricted-imports`
override covers `src/markdown/*.ts`; adding one, with its `ci.sh` probe, extends the decision.
### 128. Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.
Mutation testing left about 40 mutants alive in code moved verbatim into `src/markdown/portable/`
@@ -202,6 +213,19 @@ type", that is an ordinary code language, so the property reaches `readCarriedBl
where a random edit lands in a canonical document. Write `adf:<type>` and `adf:` fences, canonical
and hostile. Technical principle: for pure logic over many inputs, prefer a property test, so the property must reach the fenced carry's refusals itself. Found by the technical-principles audit, 2026-10-04.
### 154. Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter.
A comprehension seat found `src/markdown/inline-content.ts` `resolveNodes` / `pairedHighlights`
hard: an empty text node stands in for each `==` until pairing, then a cursor walk overwrites it.
The pieces now carry their offsets, so the pairing can run on them first. Make that place easier to
read.
### 155. Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller.
A comprehension seat found `src/markdown/inline-content.ts` `closeBracket` / `truncatePieces`
hard: `closeBracket`, `unopened`, `literalClose` and `popBracket` each write `scan.pieces`, the
link openers or the reading. Make that place easier to read.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the