mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Find code-span and link openers and unspellable mark runs by reading the escaped line back
This commit is contained in:
@@ -62,7 +62,6 @@ In `docs/decisions.md`:
|
||||
- Firefox reads the build
|
||||
- The coverage floors
|
||||
- The size ratchet
|
||||
- The project ships under the comprehension floor until item 95 lands
|
||||
- Properties on a fixed seed
|
||||
- The CommonMark suite checks three ways
|
||||
- The flavour spec is read as a source
|
||||
@@ -79,6 +78,7 @@ In `docs/decisions.md`:
|
||||
- Cost fixes are measured, never timed
|
||||
- Only the hard break holds a raw newline
|
||||
- Emphasis follows CommonMark's matching
|
||||
- A line that opens a code span or a link at text escapes every text backtick or `[` in it
|
||||
- Readable spellings take the `try` prefix
|
||||
- The attribute vocabulary is ADF's
|
||||
- The source parts by ADF and format
|
||||
|
||||
@@ -58,6 +58,11 @@
|
||||
that is neither escaped nor closed, as CommonMark requires: `[a](b(c "t")` is now text, where it
|
||||
read as a link to `b(c`. Markdown the writers wrote reads as before, since they escape such a
|
||||
parenthesis.
|
||||
- `adfToLosslessMarkdown` and `adfToPortableMarkdown` escape every text `[` in a line where one
|
||||
would open a link, and every text backtick where one would open a code span: the text
|
||||
`[a](b) [c]` is now written `\[a](b) \[c]`, where the second `[` stayed bare. A text `[` that
|
||||
opens nothing stays bare: `[a] b](c` is now written as is, where it was `\[a] b](c`. Markdown
|
||||
either version wrote reads the same in both.
|
||||
- The README compares the package with seven other ADF converters, each checked against its
|
||||
published version.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
\`a \`\`b``
|
||||
\`a \`\`b\`\`
|
||||
|
||||
\`a\
|
||||
\`\`\`b``
|
||||
\`\`\`b\`\`
|
||||
|
||||
+12
-34
@@ -761,40 +761,6 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul
|
||||
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
||||
exits 0 on.
|
||||
|
||||
## The project ships under the comprehension floor until item 95 lands
|
||||
|
||||
2026-10-03, the maintainer; item 95 in place of item 61, 2026-10-09, the maintainer; item 62
|
||||
landed 2026-10-22. KISS, a technical principle, and its comprehension floor of 7.
|
||||
Valid while `todo.md` item 95 is open.
|
||||
|
||||
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
|
||||
2026-10-08 is the baseline.
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior | 7 | 5.5 | 6.5 | 5.5 | 6 |
|
||||
| Mid | 7 | 5.5 | 6.5 | 5.5 | 6 |
|
||||
| Senior | 7 | 5.5 | 6 | 5 | 6 |
|
||||
| Architect | 6.5 | 6 | 6 | 6.5 | 6 |
|
||||
| Mean | 6.88 | 5.63 | 6.25 | 5.63 | 6.00 |
|
||||
|
||||
Panels on near-identical code scored overall means of 5.75 to 6.00, and a seat moves ±0.5 between
|
||||
runs. The maintainer shipped under a fall twice after three rounds: the move to GitHub, 2026-10-04,
|
||||
whose chunk changed only comments in `src/`; and item 78, 2026-10-07, whose three runs fell below
|
||||
the run before it on Navigation, Locality and Shape while the overall held. Item 94, 2026-10-10,
|
||||
also shipped after three rounds: a definitions-only paragraph leaves no block, so ending the list it
|
||||
follows takes state carried across lines, and every design read worse on Locality while the overall
|
||||
held; the scores at 6182ba6 were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean 5.63. Item
|
||||
56, still open, is to give that state one owner. Items 85 and 86, 2026-10-11, shipped after three
|
||||
rounds under a fall the seats laid on item 93's `splitPath` and `splitPlace`, which placed a refused
|
||||
block's path by replaying `splitTaskList` on stand-in nodes until item 102, 2026-10-22, removed them; both seats read the chunk's own image
|
||||
builder as a gain. The scores at be23835 were Junior 6.5, Mid 6, Senior 6 and Architect 6, mean
|
||||
6.13. Item 106, 2026-10-13, shipped
|
||||
after three rounds under a worse ruling, the maintainer's call: both paired seats read the overall
|
||||
as the same, but Locality fell to a mean of 5.13, laid on the spelling memo the chunk splits between
|
||||
the CommonMark ask, which fills it, and the walk, which drops each entry as it reads it. The overall
|
||||
scores at 1f4551a were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean 5.63.
|
||||
|
||||
## Properties on a fixed seed
|
||||
|
||||
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
|
||||
@@ -976,6 +942,18 @@ Emphasis is spelled against CommonMark's matching, never flanking alone: a delim
|
||||
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
|
||||
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
|
||||
|
||||
## A line that opens a code span or a link at text escapes every text backtick or `[` in it
|
||||
|
||||
2026-10-28, a writer panel, 3 of 3. Goals 1, 6 and 9. Valid while the writers find a construct
|
||||
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
|
||||
escaping each opener in turn costs a read per opener, against goal 9. The grammar:
|
||||
`spec/flavour.md` §Canonical form.
|
||||
|
||||
## Readable spellings take the `try` prefix
|
||||
|
||||
2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
|
||||
|
||||
@@ -498,3 +498,7 @@ Read most (opens and searches):
|
||||
## Reading cost, chunk ccd1a10b3e06, finished 2026-10-28T12:01:54Z
|
||||
|
||||
120318 new input tokens before the first commit.
|
||||
|
||||
## Reading cost, chunk 3d2d130377ed, finished 2026-10-28T12:27:43Z
|
||||
|
||||
80655 new input tokens before the first commit.
|
||||
|
||||
+4
-4
@@ -54,10 +54,10 @@ normalizes to it through the round-trip.
|
||||
- Entity references in input decode to their characters; output backslash-escapes only where text
|
||||
would otherwise parse as syntax, scanning the assembled line rather than each text node: escape
|
||||
the leading delimiter of a construct that would otherwise open, re-scan from there, and repeat.
|
||||
A backtick run escapes whole, and a bare one escapes wherever a later backtick string of its
|
||||
length forms around an escape in the same inline content — an escaped backtick alone, one joined
|
||||
to the bare run after it, or a bare run an escape splits off: CommonMark reads no escape inside a
|
||||
code span, so such a string still closes one.
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -342,6 +342,15 @@ test('escapes only text that would otherwise open a construct', () => {
|
||||
assert.equal(emitted('x"_y"'), 'x"\\_y"\n')
|
||||
})
|
||||
|
||||
test('escapes every text bracket in a line where one opens link text, and every text backtick where one opens a code span', () => {
|
||||
const emitted = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
assert.equal(emitted('[a](b) [c]'), '\\[a](b) \\[c]\n')
|
||||
assert.equal(emitted('see [1] and [docs](url) [2]'), 'see \\[1] and \\[docs](url) \\[2]\n')
|
||||
assert.equal(emitted('[a] b](c'), '[a] b](c\n')
|
||||
assert.equal(emitted('[x]('), '[x](\n')
|
||||
assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Claims, WrittenFlavour } from '../flavour.ts'
|
||||
import type { InlineReading } from '../inline-content.ts'
|
||||
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { delimiterRunToken, highlightFlanking, inlineSource, readInlineToken } from '../inline-tokens.ts'
|
||||
import { delimiterRunToken, inlineSource, readInlineToken } from '../inline-tokens.ts'
|
||||
import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||
import { isWordCharacter, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||
import { readLineBack } from '../inline-content.ts'
|
||||
|
||||
export type DelimiterRole = 'close' | 'open'
|
||||
|
||||
@@ -28,10 +30,6 @@ export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspe
|
||||
|
||||
type ScanLine = { position: LinePosition; start: number; text: string }
|
||||
|
||||
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
|
||||
|
||||
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
|
||||
|
||||
type Escapings = (index: number) => InlineEscaping | undefined
|
||||
|
||||
// Where a segment's first character landed in the output.
|
||||
@@ -40,19 +38,19 @@ type PlacedSegment = { segment: InlineSegment; start: number }
|
||||
// Which of a run's characters escape: every escapable one, or every one before `end`.
|
||||
type RunEscapes = { end: number; escapable: false } | { escapable: true }
|
||||
|
||||
// Which text characters escape wherever they stand: one of them opened a construct when the line was read back.
|
||||
type TextEscapes = { backticks: boolean; brackets: boolean }
|
||||
|
||||
type EscapeWalk = {
|
||||
readonly claims: Claims
|
||||
readonly container: LineContainer
|
||||
readonly escapings: Escapings
|
||||
readonly headingCloser: number | undefined
|
||||
readonly inline: InlineSource
|
||||
readonly linkClose: number
|
||||
}
|
||||
|
||||
const escapingCodes: readonly ['backslash', 'bracketed', 'bracketed-link-target', 'none'] = ['backslash', 'bracketed', 'bracketed-link-target', 'none']
|
||||
|
||||
const followsLinkText = /[([]/
|
||||
|
||||
const runCharacters = '*_`~'
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): AssembledLine {
|
||||
@@ -101,13 +99,41 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl
|
||||
const code = codes[index]
|
||||
return code === undefined ? undefined : escapingCodes[code]
|
||||
}
|
||||
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, claims))
|
||||
const { output, placed } = backslashed(segments, scan, escaped)
|
||||
if (container === 'paragraph' && opensLinkDefinition(output)) {
|
||||
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
|
||||
return { line: `\\${output}`, unspellableRuns: unspellableRuns(placed, output) }
|
||||
const claimed = escapeClaims(scan, escapings, container, claims)
|
||||
let escapes: TextEscapes = { backticks: false, brackets: false }
|
||||
// Terminates because a pass whose reading asks for no kind of escape the last pass lacked returns.
|
||||
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 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 }
|
||||
}
|
||||
escapes = next
|
||||
}
|
||||
return { line: output, unspellableRuns: unspellableRuns(placed, output) }
|
||||
}
|
||||
|
||||
function withTextEscapes(scan: string, escapings: Escapings, claimed: Uint8Array, { backticks, brackets }: TextEscapes): Uint8Array {
|
||||
if (!backticks && !brackets) return claimed
|
||||
const escaped = claimed.slice()
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
const character = scan.charAt(index)
|
||||
if (((backticks && character === '`') || (brackets && character === '[')) && isEscapable(escapings(index))) escaped[index] = 1
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
|
||||
// A construct the reader opened at a text character is one the line does not mean.
|
||||
function textOpeners(placed: readonly PlacedSegment[], length: number, reading: InlineReading): TextEscapes {
|
||||
const text = new Uint8Array(length)
|
||||
for (const [index, { segment, start }] of placed.entries()) {
|
||||
if (isEscapable(segment.escaping)) text.fill(1, start, placed[index + 1]?.start ?? length)
|
||||
}
|
||||
return { backticks: reading.codeSpans.some((at) => text[at] === 1), brackets: reading.links.some((at) => text[at] === 1) }
|
||||
}
|
||||
|
||||
function backslashed(segments: readonly InlineSegment[], scan: string, escaped: Uint8Array): { output: string; placed: PlacedSegment[] } {
|
||||
@@ -146,7 +172,6 @@ function escapeClaims(scan: string, escapings: Escapings, container: LineContain
|
||||
escapings,
|
||||
headingCloser: container === 'heading' ? closingHashes(scan) : undefined,
|
||||
inline: inlineSource(scan),
|
||||
linkClose: lastLinkClose(scan, escapings),
|
||||
}
|
||||
const escaped = new Uint8Array(scan.length)
|
||||
let line = scanLine(scan, 0)
|
||||
@@ -195,7 +220,7 @@ function claimsRunRest({ claims, container, escapings, inline }: EscapeWalk, ind
|
||||
return token.kind !== 'text'
|
||||
}
|
||||
|
||||
function claimsCharacter({ container, escapings, headingCloser, inline: { text: scan }, linkClose }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
|
||||
function claimsCharacter({ container, escapings, headingCloser, inline: { text: scan } }: EscapeWalk, index: number, token: InlineToken, line: ScanLine): boolean {
|
||||
const escaping = escapings(index)
|
||||
if (escaping === 'bracketed-link-target') return token.kind === 'directive'
|
||||
if (!isEscapable(escaping)) return false
|
||||
@@ -211,8 +236,7 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text:
|
||||
case 'html':
|
||||
return true
|
||||
case 'bracket':
|
||||
if (token.image) return isSyntax(escapings(index + 1))
|
||||
return escaping === 'bracketed' || index < linkClose
|
||||
return token.image ? isSyntax(escapings(index + 1)) : escaping === 'bracketed'
|
||||
case 'bracket-close':
|
||||
return escaping === 'bracketed'
|
||||
case 'highlight':
|
||||
@@ -227,120 +251,23 @@ function claimsCharacter({ container, escapings, headingCloser, inline: { text:
|
||||
}
|
||||
}
|
||||
|
||||
// CommonMark reads no escape inside a code span, so a backtick string left beside an escape still closes a span that an earlier bare run of its length opens.
|
||||
function escapeClosedRuns(scan: string, escapings: Escapings, claimed: Uint8Array): Uint8Array {
|
||||
const escaped = claimed.slice()
|
||||
// The lengths of the backtick strings escapes leave: a bare run of one of these lengths would open a span one of them closes, so it is escaped too.
|
||||
const formed = new Set<number>()
|
||||
let end = scan.length - 1
|
||||
while (end >= 0) {
|
||||
if (scan.charAt(end) !== '`') {
|
||||
end -= 1
|
||||
continue
|
||||
}
|
||||
let start = end
|
||||
while (scan.charAt(start - 1) === '`') start -= 1
|
||||
let segmentEnd = end
|
||||
for (let index = end; index > start; index -= 1) {
|
||||
if (claimed[index] !== 1) continue
|
||||
formed.add(segmentEnd - index + 1)
|
||||
segmentEnd = index - 1
|
||||
}
|
||||
if (segmentEnd !== end || claimed[start] === 1) formed.add(segmentEnd - start + 1)
|
||||
else if (escapings(start) !== 'none' && formed.has(end - start + 1)) {
|
||||
escaped.fill(1, start, end + 1)
|
||||
// Each escaped backtick then stands alone, a string of length one.
|
||||
formed.add(1)
|
||||
}
|
||||
end = start - 1
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
|
||||
// One emphasis run, the innermost, or every highlight run the line cannot spell.
|
||||
function unspellableRuns(placed: readonly PlacedSegment[], output: string): MarkRun[] {
|
||||
const { nodes, runs } = emittedRuns(placed, output)
|
||||
const pair = misflanked(runs) ?? unpaired(runs)
|
||||
const run = pair === undefined ? undefined : nodes[pair]
|
||||
return run === undefined ? unreadHighlights(placed, output) : [run]
|
||||
}
|
||||
|
||||
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
|
||||
function unreadHighlights(placed: readonly PlacedSegment[], output: string): MarkRun[] {
|
||||
const unread: MarkRun[] = []
|
||||
// The innermost emphasis run the line does not read back as written, else every highlight run it does not.
|
||||
function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading): MarkRun[] {
|
||||
const openers: { nodes: MarkRun; start: number }[] = []
|
||||
const unreadHighlights: MarkRun[] = []
|
||||
let innermost: { nodes: MarkRun; start: number } | undefined
|
||||
let highlight: { nodes: MarkRun; start: number } | undefined
|
||||
for (const { segment, start } of placed) {
|
||||
if (segment.highlight === undefined) continue
|
||||
const flanking = highlightFlanking(output, start)
|
||||
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
|
||||
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
function misflanked(runs: readonly EmittedRun[]): number | undefined {
|
||||
for (const run of runs) {
|
||||
for (const delimiter of run.delimiters) {
|
||||
if (!(delimiter.closes ? run.canClose : run.canOpen)) return delimiter.pair
|
||||
if (segment.emphasis === 'open') openers.push({ nodes: segment.nodes, start })
|
||||
if (segment.highlight === 'open') highlight = { nodes: segment.nodes, start }
|
||||
if (segment.emphasis === 'close') {
|
||||
const opener = openers.pop()
|
||||
const read = opener === undefined ? undefined : reading.emphasis.get(opener.start)
|
||||
if (opener !== undefined && (read?.closer !== start || read.width !== segment.text.length) && opener.start > (innermost?.start ?? -1)) innermost = opener
|
||||
}
|
||||
if (segment.highlight === 'close' && highlight !== undefined && reading.highlights.get(highlight.start) !== start) unreadHighlights.push(highlight.nodes)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function unpaired(runs: readonly EmittedRun[]): number | undefined {
|
||||
const matched = new Set<number>()
|
||||
for (const pairing of matchEmphasis(runs)) {
|
||||
const opened = delimiterAt(pairing.opener, false, pairing.openerOffset, pairing.used)
|
||||
const closed = delimiterAt(pairing.closer, true, pairing.closerOffset, pairing.used)
|
||||
if (opened !== undefined && closed !== undefined && opened.pair === closed.pair) matched.add(opened.pair)
|
||||
}
|
||||
// The last opener left unpaired is the innermost: the smallest carry that changes the line.
|
||||
let innermost: number | undefined
|
||||
for (const run of runs) {
|
||||
for (const delimiter of run.delimiters) {
|
||||
if (!delimiter.closes && !matched.has(delimiter.pair)) innermost = delimiter.pair
|
||||
}
|
||||
}
|
||||
return innermost
|
||||
}
|
||||
|
||||
function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: number): EmittedDelimiter | undefined {
|
||||
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
|
||||
}
|
||||
|
||||
function emittedRuns(placed: readonly PlacedSegment[], output: string): { nodes: MarkRun[]; runs: EmittedRun[] } {
|
||||
const runs: EmittedRun[] = []
|
||||
const nodes: MarkRun[] = []
|
||||
const open: number[] = []
|
||||
for (const { segment, start } of placed) {
|
||||
if (segment.emphasis === undefined) continue
|
||||
const closes = segment.emphasis === 'close'
|
||||
const pair = closes ? (open.pop() ?? nodes.length) : nodes.length
|
||||
if (!closes) {
|
||||
nodes.push(segment.nodes)
|
||||
open.push(pair)
|
||||
}
|
||||
const width = segment.text.length
|
||||
const previous = runs[runs.length - 1]
|
||||
if (previous !== undefined && previous.start + previous.length === start && previous.character === segment.text.charAt(0)) {
|
||||
previous.delimiters.push({ closes, offset: start - previous.start, pair, width })
|
||||
previous.length += width
|
||||
continue
|
||||
}
|
||||
runs.push({
|
||||
canClose: false,
|
||||
canOpen: false,
|
||||
character: segment.text.charAt(0),
|
||||
delimiters: [{ closes, offset: 0, pair, width }],
|
||||
length: width,
|
||||
start,
|
||||
})
|
||||
}
|
||||
for (const run of runs) {
|
||||
const flags = delimiterFlags(run.character, output.charAt(run.start - 1), output.charAt(run.start + run.length))
|
||||
run.canClose = flags.canClose
|
||||
run.canOpen = flags.canOpen
|
||||
}
|
||||
return { nodes, runs }
|
||||
return innermost === undefined ? unreadHighlights : [innermost.nodes]
|
||||
}
|
||||
|
||||
function isEscapable(escaping: InlineEscaping | undefined): boolean {
|
||||
@@ -370,12 +297,3 @@ function scanLine(scan: string, start: number): ScanLine {
|
||||
const end = scan.indexOf('\n', start)
|
||||
return { position: start === 0 ? 'first' : 'later', start, text: scan.slice(start, end === -1 ? undefined : end) }
|
||||
}
|
||||
|
||||
// A `]` the emitter spelled sits inside a construct that binds before link text does.
|
||||
function lastLinkClose(scan: string, escapings: Escapings): number {
|
||||
for (let cursor = scan.length - 1; cursor >= 0; cursor -= 1) {
|
||||
if (scan.charAt(cursor) !== ']' || isSyntax(escapings(cursor))) continue
|
||||
if (followsLinkText.test(scan.charAt(cursor + 1))) return cursor
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { ClaimedPrefix, DirectiveSpan, NestedSpans } from '../directive/syntax.ts'
|
||||
import type { Claims, Flavour } from '../flavour.ts'
|
||||
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { InlineTarget, LinkDefinition, TargetMemo } from '../commonmark/link-syntax.ts'
|
||||
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
|
||||
import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } from '../mark-spellings.ts'
|
||||
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { highlightDelimiter, inlineSource, readInlineToken } from '../inline-tokens.ts'
|
||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { imageAside } from '../image-aside.ts'
|
||||
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
|
||||
import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from '../commonmark/link-syntax.ts'
|
||||
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
|
||||
import { contentOffset, noSpans, readInlineDirective } from '../directive/syntax.ts'
|
||||
import { openingLinkTakesDirective } from '../emit/spelling-questions.ts'
|
||||
import { readCarriedInline } from '../opaque-carry.ts'
|
||||
import { readDirectiveMark } from '../directive/mark-reading.ts'
|
||||
import { readInlineDirectiveNode } from '../directive/node-reading.ts'
|
||||
import { readTextDirective } from '../directive/text.ts'
|
||||
import type { AdfMark, AdfNode } from '../adf/document.ts'
|
||||
import type { ClaimedPrefix, DirectiveSpan, NestedSpans } from './directive/syntax.ts'
|
||||
import type { Claims, Flavour } from './flavour.ts'
|
||||
import type { EmphasisPairing } from './commonmark/emphasis-matching.ts'
|
||||
import type { InlineSource, InlineToken } from './inline-tokens.ts'
|
||||
import type { LineContainer } from './line-container.ts'
|
||||
import type { InlineTarget, LinkDefinition, TargetMemo } from './commonmark/link-syntax.ts'
|
||||
import { centeredImage, externalMedia } from '../adf/plain-image.ts'
|
||||
import { commonMarkLink, linkDefinition, linkHref, linkMark, toLinkAttrs } from './mark-spellings.ts'
|
||||
import { decodeTextEscapes, trimTrailingSpace } from './commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||
import { flavourClaims } from './flavour.ts'
|
||||
import { highlightDelimiter, inlineSource, readInlineToken } from './inline-tokens.ts'
|
||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../adf/document.ts'
|
||||
import { imageAside } from './image-aside.ts'
|
||||
import { inlineNodeModel, takesMark } from '../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakName, textBreakSpelling } from './adjacent-text.ts'
|
||||
import { labelLimit, normalizeLabel, readInlineTarget, readLabel, targetMemo } from './commonmark/link-syntax.ts'
|
||||
import { matchEmphasis } from './commonmark/emphasis-matching.ts'
|
||||
import { contentOffset, noSpans, readInlineDirective } from './directive/syntax.ts'
|
||||
import { readCarriedInline } from './opaque-carry.ts'
|
||||
import { readDirectiveMark } from './directive/mark-reading.ts'
|
||||
import { readInlineDirectiveNode } from './directive/node-reading.ts'
|
||||
import { readTextDirective } from './directive/text.ts'
|
||||
|
||||
// Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is its linked alt text.
|
||||
export type InlineContent = { image?: AdfNode; nodes: AdfNode[] }
|
||||
@@ -51,7 +50,9 @@ type Nodes = { kind: 'nodes'; nodes: Inline[] }
|
||||
|
||||
type Resolved = ImageLink | Nodes
|
||||
|
||||
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; opens: boolean; position: number; stretch: number }
|
||||
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; offset: number; opens: boolean; position: number; stretch: number }
|
||||
|
||||
type HighlightPair = { closer: number; closerOffset: number; opener: number; openerOffset: number }
|
||||
|
||||
// `index` is the bracket's position in `pieces`; `start` is where its text starts in `source`.
|
||||
type OpenBracket = { image: boolean; index: number; start: number }
|
||||
@@ -65,10 +66,20 @@ type Piece =
|
||||
| ImageLink
|
||||
| Nodes
|
||||
| TextBreak
|
||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
||||
| { 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 }
|
||||
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number; offset: 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 = {
|
||||
readonly codeSpans: number[]
|
||||
// Each emphasis pairing, keyed by where its opening delimiters start.
|
||||
readonly emphasis: Map<number, { closer: number; width: number }>
|
||||
readonly highlights: Map<number, number>
|
||||
// Where the `[` of each link or image stands.
|
||||
readonly links: number[]
|
||||
}
|
||||
|
||||
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
|
||||
type Scan = {
|
||||
@@ -87,36 +98,45 @@ type Scan = {
|
||||
path: ConvertErrorPath
|
||||
pending: string
|
||||
pieces: Piece[]
|
||||
reading: InlineReading
|
||||
source: string
|
||||
spans: NestedSpans
|
||||
targets: TargetMemo
|
||||
}
|
||||
|
||||
// What a directive's content slot inherits from the content holding it.
|
||||
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'targets'>
|
||||
type SharedScan = Pick<Scan, 'definitions' | 'path' | 'reading' | 'targets'>
|
||||
|
||||
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
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'
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
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 textBreak: TextBreak = { kind: 'textBreak' }
|
||||
|
||||
// The outermost content: only here does a text break see both its neighbours.
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
|
||||
const scan = freshScan(source, { definitions, path, 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.
|
||||
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 })
|
||||
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
|
||||
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
|
||||
const { openingSpellableLink } = scan
|
||||
return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink })
|
||||
}
|
||||
|
||||
// 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 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 freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'inLink' | 'offset' | 'spans'>): Scan {
|
||||
@@ -153,11 +173,12 @@ 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)
|
||||
pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' })
|
||||
return success(end)
|
||||
case 'delimiter-run':
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width })
|
||||
scan.pieces.push({ canClose: token.canClose, canOpen: token.canOpen, character: scan.source.charAt(index), kind: 'run', length: token.width, offset: scan.offset + index })
|
||||
return success(end)
|
||||
case 'entity':
|
||||
case 'escape':
|
||||
@@ -171,7 +192,7 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin
|
||||
return success(end)
|
||||
case 'highlight':
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ closes: token.closes, kind: 'highlight', opens: token.opens })
|
||||
scan.pieces.push({ closes: token.closes, kind: 'highlight', offset: scan.offset + index, opens: token.opens })
|
||||
return success(end)
|
||||
case 'line-ending':
|
||||
return success(readLineEnding(scan, index))
|
||||
@@ -255,9 +276,9 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan, index: number, inLink: boolean): Result<Resolved[] | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
const { claims, definitions, path, targets } = scan
|
||||
const { claims, definitions, path, reading, targets } = scan
|
||||
const offset = scan.offset + index + contentOffset(span)
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path, reading, targets }, { claims, container: undefined, inLink, offset, spans: span.spans }))
|
||||
if (!parsed.ok) return parsed
|
||||
return success(parsed.value.pieces)
|
||||
}
|
||||
@@ -399,6 +420,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)
|
||||
const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition())
|
||||
if (!closed.ok) return closed
|
||||
scan.pieces.length = open.index
|
||||
@@ -490,9 +512,12 @@ 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 })
|
||||
writeUnpaired(nodes, runs, pairings)
|
||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||
const paired = highlights ? pairedHighlights(pieces, nodes) : []
|
||||
for (const { closerOffset, openerOffset } of paired) scan.reading.highlights.set(openerOffset, closerOffset)
|
||||
markHighlights(pieces, nodes, paired)
|
||||
return success(nodes)
|
||||
}
|
||||
|
||||
@@ -538,7 +563,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 })
|
||||
if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length, offset: piece.offset })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
@@ -600,7 +625,7 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][
|
||||
const held = adfNodes(nodes[index] ?? [])
|
||||
const [holder] = held
|
||||
if (piece.kind === 'highlight' && holder !== undefined) {
|
||||
found.push({ closes: piece.closes, holder, index, opens: piece.opens, position, stretch })
|
||||
found.push({ closes: piece.closes, holder, index, offset: piece.offset, opens: piece.opens, position, stretch })
|
||||
position += highlightDelimiter.length
|
||||
continue
|
||||
}
|
||||
@@ -613,9 +638,9 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][
|
||||
}
|
||||
|
||||
// Each opener pairs with the next closer at least one character after it, and only when both sit in one stretch under the same marks.
|
||||
function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): { closer: number; opener: number }[] {
|
||||
function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightPair[] {
|
||||
const found = highlightDelimiters(pieces, nodes)
|
||||
const paired: { closer: number; opener: number }[] = []
|
||||
const paired: HighlightPair[] = []
|
||||
let closer = 0
|
||||
let resume = 0
|
||||
for (const opener of found) {
|
||||
@@ -628,14 +653,14 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]):
|
||||
}
|
||||
if (candidate === undefined) break
|
||||
if (candidate.stretch !== opener.stretch || !identicalMarks(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue
|
||||
paired.push({ closer: candidate.index, opener: opener.index })
|
||||
paired.push({ closer: candidate.index, closerOffset: candidate.offset, opener: opener.index, openerOffset: opener.offset })
|
||||
resume = candidate.position + highlightDelimiter.length
|
||||
}
|
||||
return paired
|
||||
}
|
||||
|
||||
// A node holds one highlight, and a rebuilt node would lose its attributes.
|
||||
function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly { closer: number; opener: number }[]): void {
|
||||
function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly HighlightPair[]): void {
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
if (piece.kind === 'highlight') nodes[index] = adfNodes(nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
|
||||
}
|
||||
@@ -2,10 +2,11 @@ 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, type SpellingMemo } from '../emit/spelling-questions.ts'
|
||||
import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts'
|
||||
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
|
||||
import { emptyKeys, nodeAttrs } from '../../adf/document.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts'
|
||||
@@ -14,7 +15,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 { parseInlineContent } from './inline-content.ts'
|
||||
import { readInlineContent, spellableLink } 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'
|
||||
@@ -257,3 +258,15 @@ export function contentNode(node: AdfNode, text: string, reading: Reading, path:
|
||||
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,11 +2,10 @@ 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, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
|
||||
import { containerNode, contentNode, listNode, parseInlineContent, 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 { parseInlineContent } from '../parse/inline-content.ts'
|
||||
import { pathTo, splitTaskList } from './task-list.ts'
|
||||
import { positioned, success } from '../../result.ts'
|
||||
import { withinEngineLimits } from '../../length-bound.ts'
|
||||
|
||||
@@ -13,18 +13,17 @@
|
||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 152 | 0.3.0 | | owner | **Mutation-test a chunk's changed lines in 15 minutes or less, with one command the README documents.** | 3 | 2 | 1 | 4 | 4 | code health by 3 | 13.1 |
|
||||
| 95 | 0.3.0 | | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 4 | 1 | 3 | 6 | 6 | code health by 7 | 10.2 |
|
||||
| 146 | 0.3.0 | | | **Open the README with what an app developer choosing an ADF converter needs: what the package converts, which call to take, and that it runs without React, Atlaskit or any runtime dependency, in Node, Forge, Deno, Bun and browsers.** | 2 | 2 | 1 | 3 | 3 | 3 by 6 | 9.5 |
|
||||
| 33 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
|
||||
| 109 | 0.3.0 | | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
|
||||
| 150 | 0.3.0 | | decision | **Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.** | 2 | 2 | 2 | 4 | 4 | 6 by 5 | 5.0 |
|
||||
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
|
||||
| 145 | 0.3.0 | | | **Widen the README's "Other ADF converters" table to every ADF converter a developer might pick: npm packages, other languages' libraries, Atlassian's REST APIs and MCP servers, `mdast-util-from-adf` among them.** | 2 | 2 | 1 | 3 | 4 | 3 by 4 | 4.2 |
|
||||
| 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
|
||||
| 91 | 0.3.0 | | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
|
||||
| 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 | 33, 91, 95, 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, 95, 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
|
||||
@@ -82,14 +81,6 @@ Ways to get there, to measure before choosing:
|
||||
|
||||
Done when a mutation run of a shipped chunk's changed lines, such as item 122's, finishes in 15 minutes or less under `~/.claude/bin/run-heavy`.
|
||||
|
||||
### 95. Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.
|
||||
|
||||
`escapeClosedRuns` works out which backtick strings an escape leaves, `unspellableRuns` rebuilds the
|
||||
reader's emphasis and highlight pairing, and `lastLinkClose` guesses which text `[` opens link
|
||||
text. Only the property tests catch drift between these and the reader, and it holds the project
|
||||
under the comprehension floor. Reading the line back costs one parse each time a line is tried. The
|
||||
run it carries may differ from today's, which changes lossless output (§The formats are API).
|
||||
|
||||
### 146. Open the README with what an app developer choosing an ADF converter needs: what the package converts, which call to take, and that it runs without React, Atlaskit or any runtime dependency, in Node, Forge, Deno, Bun and browsers.
|
||||
|
||||
Found 2026-10-28 surveying the Atlassian developer community: the most asked need is HTML or
|
||||
@@ -148,7 +139,7 @@ documentation, saying so in its row.
|
||||
|
||||
### 91. Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.
|
||||
|
||||
It needs item 95, which lets the joined spelling be tried and kept only where it reads back. No
|
||||
The writers read each line back, so a joined spelling can be kept only where it reads back. No
|
||||
reader marks a hard break, so `*One\` then `Two*` writes back as `_One_\` then `_Two_`, and a link
|
||||
typed across a line as two links. No reader gives those other nodes a mark Atlassian's schema
|
||||
withholds from them, and none gives code text a mark but a link. So `**Ping !adf:mention[@A]{id=x}
|
||||
@@ -452,8 +443,8 @@ bash footguns (`&&` chaining under `||`, `tee /dev/stderr`) the port removes.
|
||||
|
||||
### 34. Read emphasis flanking by the whole character beside an astral symbol.
|
||||
|
||||
Check whether `delimiterRunToken` (`inline-tokens.ts`), `emittedRuns` and `resolveEmphasis`
|
||||
(`line-escaping.ts`) read one UTF-16 unit beside an astral symbol. A lone surrogate is neither
|
||||
Check whether `delimiterRunToken` (`inline-tokens.ts`) and `resolveEmphasis` (`line-escaping.ts`)
|
||||
read one UTF-16 unit beside an astral symbol. A lone surrogate is neither
|
||||
punctuation nor symbol, but CommonMark reads `😀` as punctuation. Where they do, read the code
|
||||
point, with a fixture per direction.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user