5b1: the error's source position #44
@@ -88,10 +88,11 @@ The emitted markdown and HTML are contracts. After 1.0: previously-emitted outpu
|
||||
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
|
||||
Pre-1.0, normal 0.x rules.
|
||||
|
||||
The error surface is a contract too. `ConvertError` is `{ code, message, path }` — the code from a
|
||||
closed list a consumer may switch exhaustively, the message free text, the path the node's position
|
||||
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
|
||||
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
||||
The error surface is a contract too. `ConvertError` is `{ code, message, path, position? }` — the
|
||||
code from a closed list a consumer may switch exhaustively, the message free text, the path the
|
||||
node's place from the document root, the position where a parse read the refusal in its input.
|
||||
Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause
|
||||
reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
||||
cause; where one cause recurs across node types or across directions, one code covers them all and
|
||||
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
|
||||
direction hits it. A claim code names the spelling claimed, never the node that spelling would have built:
|
||||
@@ -102,11 +103,27 @@ apart from a typo is what a consumer switches on when a later MINOR gives the na
|
||||
the grammar itself refuses stays a claim code, key order among it; a well-formed directive the
|
||||
node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its
|
||||
kind or its canonical spelling, an argument or a body its content model does not take — is
|
||||
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way. A
|
||||
refusal found before its position is known — the block walk's, a directive reader's — is a
|
||||
`ConvertFault`, the code and message without the path; the node walk attaches the path as it
|
||||
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
|
||||
across both directions for good, since the call site knows which direction it called and parting
|
||||
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
|
||||
converts no raw HTML, so at `0.3.0` the mapped elements stop erroring and the code stays for what
|
||||
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
|
||||
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
|
||||
descends, so a document reports its first error in document order.
|
||||
|
||||
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
|
||||
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
|
||||
begins on — the offset indexing the string the caller passed, the line counted from 1 — minted by
|
||||
the block walk and attached as results return, so the innermost block wins, the emitter's own
|
||||
refusals the parser re-enters for the CommonMark spelling included.
|
||||
|
||||
A parse names a position for every refusal it returns, so the type says so rather than the prose:
|
||||
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
|
||||
`Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction
|
||||
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
|
||||
`htmlToAdf` inherits this at `0.3.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
|
||||
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
|
||||
|
||||
## 9. Release automation
|
||||
|
||||
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
|
||||
|
||||
@@ -24,17 +24,23 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos
|
||||
|
||||
```ts
|
||||
adfToMarkdown(doc: AdfDocument): Result<string>
|
||||
markdownToAdf(markdown: string): Result<AdfDocument>
|
||||
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
adfToHtml(doc: AdfDocument): Result<string>
|
||||
htmlToAdf(html: string): Result<AdfDocument>
|
||||
htmlToAdf(html: string): Result<AdfDocument, ParseError>
|
||||
markdownToHtml(markdown: string): Result<string> // via ADF
|
||||
htmlToMarkdown(html: string): Result<string> // via ADF
|
||||
isAdfDocument(v: unknown): v is AdfDocument
|
||||
```
|
||||
|
||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
||||
`ConvertError` is `{ code, message, path }`: a code from a closed set, and the path of the node it
|
||||
names, from the document root.
|
||||
`ConvertError` is `{ code, message, path, position? }`: a code from a closed set, the path of the
|
||||
node it names from the document root, and — parsing — a `{ line, offset }` into the string passed
|
||||
in, at the start of the line the refused block begins on, `line` counted from 1.
|
||||
|
||||
Parsing a source always names where in it the refusal sits, so `markdownToAdf` and `htmlToAdf`
|
||||
return the narrowed `ParseError`, whose `position` is there to read without a guard. Every other
|
||||
direction emits, from a document with no source behind it, and carries `path` alone — including
|
||||
`markdownToHtml` and `htmlToMarkdown`, where half the refusals come from the emit half.
|
||||
|
||||
## The guarantees
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.9 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=97.95 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+12
-2
@@ -192,11 +192,21 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
||||
})
|
||||
}
|
||||
|
||||
// The position the input itself gives an offset, recomputed rather than trusted from the parser.
|
||||
function lineStarting(markdown: string, offset: number): { line: number; offset: number } | undefined {
|
||||
const before = markdown.slice(0, offset)
|
||||
if (offset !== 0 && !/(?:\r\n|[\n\r])$/.test(before)) return undefined
|
||||
return { line: before.split(/\r\n|[\n\r]/).length, offset }
|
||||
}
|
||||
|
||||
for (const name of pairedNames(errorsRoot, '.md', '.error')) {
|
||||
test(`errors/${name} is refused with the error it names`, () => {
|
||||
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8'))
|
||||
test(`errors/${name} is refused with the error it names, at a line of its own input`, () => {
|
||||
const markdown = readFileSync(join(errorsRoot, `${name}.md`), 'utf8')
|
||||
const result = markdownToAdf(markdown)
|
||||
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
|
||||
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
|
||||
const { position } = result.error
|
||||
assert.deepEqual(position, lineStarting(markdown, position.offset))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||
export type { ConvertError, ConvertErrorCode, Result } from './result.ts'
|
||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||
export type { JsonValue } from './json-value.ts'
|
||||
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { isAdfDocument } from './adf/document.ts'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readEntityReference } from './entity-references.ts'
|
||||
import { readEntityReference, replacementCharacter } from './entity-references.ts'
|
||||
|
||||
export type LinePosition = 'first' | 'later'
|
||||
|
||||
@@ -27,6 +27,7 @@ const bracketedAutolink = new RegExp(`<(?:${autolinkSource})>`, 'y')
|
||||
const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
|
||||
const htmlTag = new RegExp(htmlTagSource, 'y')
|
||||
const nullCharacter = new RegExp(nullCharacterSource)
|
||||
const nullCharacters = new RegExp(nullCharacterSource, 'g')
|
||||
const tagName = new RegExp(`^</?(${tagNameSource})[\\s\\S]*$`)
|
||||
// The opener's own match ends with the terminator where the construct is complete on its own (`<!-->`).
|
||||
const inlineHtmlConstructs = [
|
||||
@@ -137,6 +138,10 @@ export function holdsNullCharacter(text: string): boolean {
|
||||
return nullCharacter.test(text)
|
||||
}
|
||||
|
||||
export function replaceNullCharacters(text: string): string {
|
||||
return text.replaceAll(nullCharacters, replacementCharacter)
|
||||
}
|
||||
|
||||
function htmlTagName(text: string): string {
|
||||
return text.replace(tagName, '<$1>')
|
||||
}
|
||||
|
||||
@@ -25,12 +25,17 @@ function path(result: Result<string>): readonly (number | string)[] {
|
||||
return result.ok ? ['emitted'] : result.error.path
|
||||
}
|
||||
|
||||
test('names the node a refusal came from', () => {
|
||||
function position(result: Result<string>): unknown {
|
||||
return result.ok ? 'emitted' : result.error.position
|
||||
}
|
||||
|
||||
test('names the node a refusal came from, and no source the emitter never read', () => {
|
||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||
})
|
||||
|
||||
test('refuses a value that is not an ADF document', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ const anchoredEntityReference = new RegExp(`(?:${entityReferenceSource})`, 'y')
|
||||
const decimalReference = /^&#(\d+);/
|
||||
const hexadecimalReference = /^&#[Xx]([A-Fa-f0-9]+);/
|
||||
const largestCodePoint = 0x10ffff
|
||||
const replacementCharacter = '\ufffd'
|
||||
export const replacementCharacter = '\ufffd'
|
||||
const surrogates = { first: 0xd800, last: 0xdfff }
|
||||
|
||||
// HTML5's named character references (https://html.spec.whatwg.org/entities.json), the semicolon-terminated half
|
||||
|
||||
@@ -91,12 +91,15 @@ test('holds a directive container open until the fence that closes it', () => {
|
||||
{
|
||||
argument: 'info',
|
||||
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
|
||||
blocks: [{ kind: 'paragraph', text: 'Part.' }],
|
||||
blocks: [{ kind: 'paragraph', position: { line: 2, offset: 37 }, text: 'Part.' }],
|
||||
kind: 'directive',
|
||||
name: 'panel',
|
||||
position: { line: 1, offset: 0 },
|
||||
},
|
||||
])
|
||||
assert.deepEqual(parseBlocks('::rule\n').blocks, [{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule' }])
|
||||
assert.deepEqual(parseBlocks('::rule\n').blocks, [
|
||||
{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule', position: { line: 1, offset: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
test('names the directive fence a container does not sit longer than', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { ConvertFault, SourcePosition } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import {
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
markerInterruptsParagraph,
|
||||
openingCodeFence,
|
||||
openingHtmlBlock,
|
||||
replaceNullCharacters,
|
||||
setextHeadingLevel,
|
||||
} from '../commonmark-grammar.ts'
|
||||
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
||||
import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
|
||||
import { readLinkDefinitions } from './link-reference-definitions.ts'
|
||||
|
||||
export type Block =
|
||||
export type Block = { position: SourcePosition } & (
|
||||
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
|
||||
| { blocks: Block[]; kind: 'blockquote' }
|
||||
| { construct: string; kind: 'html' }
|
||||
@@ -30,6 +31,7 @@ export type Block =
|
||||
| { kind: 'paragraph'; text: string }
|
||||
| { kind: 'rule' }
|
||||
| { kind: 'table'; rows: string[][] }
|
||||
)
|
||||
|
||||
export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
|
||||
|
||||
@@ -37,26 +39,27 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
|
||||
|
||||
type ListBlock = Extract<Block, { items: Block[][] }>
|
||||
|
||||
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[] }
|
||||
type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'directive'; parent: Block[]; position: SourcePosition }
|
||||
|
||||
type OpenContainer =
|
||||
| Extract<Block, { kind: 'blockquote' }>
|
||||
| OpenDirective
|
||||
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
|
||||
|
||||
type OpenLeaf =
|
||||
type OpenLeaf = { position: SourcePosition } & (
|
||||
| { closer: RegExp | undefined; construct: string; kind: 'html' }
|
||||
| { held: string[]; kind: 'indented-code'; lines: string[] }
|
||||
| { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string }
|
||||
| { kind: 'paragraph'; lines: string[] }
|
||||
| { kind: 'paragraph'; lines: string[]; positions: SourcePosition[] }
|
||||
| { kind: 'pipe-table'; rows: [string[], ...string[][]] }
|
||||
)
|
||||
|
||||
type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; rest: Line }
|
||||
|
||||
// The line from an absolute column on: a tab a cut splits keeps the stop it is measured against.
|
||||
type Line = { column: number; text: string }
|
||||
|
||||
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; stack: OpenContainer[] }
|
||||
type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] }
|
||||
|
||||
const blankLine = /^[ \t]*$/
|
||||
const indentedCodeColumns = 4
|
||||
@@ -65,8 +68,11 @@ const leafColons = 2
|
||||
const tabStop = 4
|
||||
|
||||
export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, stack: [] }
|
||||
for (const text of normalizeInput(markdown).split('\n')) readLine(walk, { column: 0, text })
|
||||
const walk: Walk = { blocks: [], definitions: new Map(), leaf: undefined, position: { line: 1, offset: 0 }, stack: [] }
|
||||
for (const line of sourceLines(markdown)) {
|
||||
walk.position = line.position
|
||||
readLine(walk, { column: 0, text: line.text })
|
||||
}
|
||||
closeContainers(walk, 0)
|
||||
return { blocks: walk.blocks, definitions: walk.definitions }
|
||||
}
|
||||
@@ -127,7 +133,7 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
|
||||
let opened = false
|
||||
let rest = line
|
||||
while (leadingColumns(rest) < indentedCodeColumns) {
|
||||
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched)
|
||||
const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position)
|
||||
if (start === undefined) break
|
||||
if (!opened) closeContainers(walk, depth)
|
||||
opened = true
|
||||
@@ -137,15 +143,15 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n
|
||||
return { opened, rest }
|
||||
}
|
||||
|
||||
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined): ContainerStart | undefined {
|
||||
function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
|
||||
const opener = removeColumns(line, largestOpenerIndentation)
|
||||
const blockquote = blockquoteRest(opener)
|
||||
if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote }
|
||||
if (isThematicBreak(opener.text)) return undefined
|
||||
return itemStart(line, opener, paragraphOpen, enclosing)
|
||||
return itemStart(line, opener, paragraphOpen, enclosing, position)
|
||||
}
|
||||
|
||||
function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined): ContainerStart | undefined {
|
||||
function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined {
|
||||
const marker = listMarker(opener.text)
|
||||
if (marker === undefined) return undefined
|
||||
const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) }
|
||||
@@ -159,19 +165,19 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing:
|
||||
fresh: !continued,
|
||||
indentation: leadingColumns(line) + marker.width + padding,
|
||||
kind: 'item',
|
||||
list: continued ? enclosing.list : openList(marker.start),
|
||||
list: continued ? enclosing.list : openList(marker.start, position),
|
||||
rest: blank ? after : removeColumns(after, padding),
|
||||
}
|
||||
}
|
||||
|
||||
function openList(start: number | undefined): ListBlock {
|
||||
return start === undefined ? { items: [], kind: 'bulletList' } : { items: [], kind: 'orderedList', start }
|
||||
function openList(start: number | undefined, position: SourcePosition): ListBlock {
|
||||
return start === undefined ? { items: [], kind: 'bulletList', position } : { items: [], kind: 'orderedList', position, start }
|
||||
}
|
||||
|
||||
function openContainer(walk: Walk, start: ContainerStart): void {
|
||||
const blocks: Block[] = []
|
||||
if (start.kind === 'blockquote') {
|
||||
const blockquote: OpenContainer = { blocks, kind: 'blockquote' }
|
||||
const blockquote: OpenContainer = { blocks, kind: 'blockquote', position: walk.position }
|
||||
currentBlocks(walk).push(blockquote)
|
||||
walk.stack.push(blockquote)
|
||||
return
|
||||
@@ -195,7 +201,11 @@ function closeContainers(walk: Walk, depth: number): void {
|
||||
closeLeaf(walk)
|
||||
for (const container of walk.stack.slice(depth)) {
|
||||
if (container.kind !== 'directive') continue
|
||||
container.parent[container.index] = { fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`), kind: 'fault' }
|
||||
container.parent[container.index] = {
|
||||
fault: malformedDirective(`a container fenced with ${container.colons} colons is unclosed`),
|
||||
kind: 'fault',
|
||||
position: container.position,
|
||||
}
|
||||
}
|
||||
dropContainers(walk, depth)
|
||||
}
|
||||
@@ -211,10 +221,12 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'he
|
||||
blocks: directive.colons > leafColons ? [] : undefined,
|
||||
kind: 'directive',
|
||||
name: directive.name,
|
||||
position: walk.position,
|
||||
}
|
||||
const parent = currentBlocks(walk)
|
||||
parent.push(block)
|
||||
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent })
|
||||
const { position } = block
|
||||
if (block.blocks !== undefined) walk.stack.push({ blocks: block.blocks, colons: directive.colons, index: parent.length - 1, kind: 'directive', parent, position })
|
||||
}
|
||||
|
||||
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
|
||||
@@ -251,7 +263,7 @@ function innermostDirective(walk: Walk): { container: OpenDirective; depth: numb
|
||||
}
|
||||
|
||||
function pushFault(walk: Walk, fault: ConvertFault): void {
|
||||
currentBlocks(walk).push({ fault, kind: 'fault' })
|
||||
currentBlocks(walk).push({ fault, kind: 'fault', position: walk.position })
|
||||
}
|
||||
|
||||
// A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md).
|
||||
@@ -291,7 +303,7 @@ function readBlockLine(walk: Walk, line: Line): void {
|
||||
return
|
||||
}
|
||||
if (walk.leaf === undefined && leadingColumns(line) >= indentedCodeColumns) {
|
||||
walk.leaf = { held: [], kind: 'indented-code', lines: [removeColumns(line, indentedCodeColumns).text] }
|
||||
walk.leaf = { held: [], kind: 'indented-code', lines: [removeColumns(line, indentedCodeColumns).text], position: walk.position }
|
||||
return
|
||||
}
|
||||
openLeaf(walk, line)
|
||||
@@ -320,14 +332,14 @@ function openLeaf(walk: Walk, line: Line): void {
|
||||
const cells = pipeCells(opener)
|
||||
if (cells !== undefined) {
|
||||
closeLeaf(walk)
|
||||
walk.leaf = { kind: 'pipe-table', rows: [cells] }
|
||||
walk.leaf = { kind: 'pipe-table', position: walk.position, rows: [cells] }
|
||||
return
|
||||
}
|
||||
if (readLineBlock(walk, opener)) return
|
||||
const fence = openingCodeFence(opener)
|
||||
if (fence !== undefined) {
|
||||
closeLeaf(walk)
|
||||
walk.leaf = { indentation: leadingColumns(line), info: fence.info, kind: 'fenced-code', lines: [], marker: fence.marker }
|
||||
walk.leaf = { indentation: leadingColumns(line), info: fence.info, kind: 'fenced-code', lines: [], marker: fence.marker, position: walk.position }
|
||||
return
|
||||
}
|
||||
const html = openingHtmlBlock(opener, walk.leaf?.kind === 'paragraph')
|
||||
@@ -336,7 +348,7 @@ function openLeaf(walk: Walk, line: Line): void {
|
||||
return
|
||||
}
|
||||
closeLeaf(walk)
|
||||
walk.leaf = { closer: html.closer, construct: html.construct, kind: 'html' }
|
||||
walk.leaf = { closer: html.closer, construct: html.construct, kind: 'html', position: walk.position }
|
||||
if (html.closer?.test(line.text) === true) closeLeaf(walk)
|
||||
}
|
||||
|
||||
@@ -344,79 +356,98 @@ function openLeaf(walk: Walk, line: Line): void {
|
||||
function readLineBlock(walk: Walk, opener: string): boolean {
|
||||
const level = walk.leaf?.kind === 'paragraph' ? setextHeadingLevel(opener) : undefined
|
||||
if (level !== undefined) {
|
||||
const text = takeParagraph(walk)
|
||||
if (text !== undefined) {
|
||||
currentBlocks(walk).push({ kind: 'heading', level, text })
|
||||
const paragraph = takeParagraph(walk)
|
||||
if (paragraph !== undefined) {
|
||||
currentBlocks(walk).push({ kind: 'heading', level, position: paragraph.position, text: paragraph.text })
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (isThematicBreak(opener)) {
|
||||
closeLeaf(walk)
|
||||
currentBlocks(walk).push({ kind: 'rule' })
|
||||
currentBlocks(walk).push({ kind: 'rule', position: walk.position })
|
||||
return true
|
||||
}
|
||||
const heading = atxHeading(opener)
|
||||
if (heading === undefined) return false
|
||||
closeLeaf(walk)
|
||||
currentBlocks(walk).push({ kind: 'heading', level: heading.level, text: heading.text })
|
||||
currentBlocks(walk).push({ kind: 'heading', level: heading.level, position: walk.position, text: heading.text })
|
||||
return true
|
||||
}
|
||||
|
||||
function appendParagraph(walk: Walk, line: string): void {
|
||||
const leaf = walk.leaf
|
||||
const text = line.replace(/^[ \t]+/, '')
|
||||
if (leaf?.kind === 'paragraph') leaf.lines.push(text)
|
||||
else walk.leaf = { kind: 'paragraph', lines: [text] }
|
||||
if (leaf?.kind !== 'paragraph') {
|
||||
walk.leaf = { kind: 'paragraph', lines: [text], position: walk.position, positions: [walk.position] }
|
||||
return
|
||||
}
|
||||
leaf.lines.push(text)
|
||||
leaf.positions.push(walk.position)
|
||||
}
|
||||
|
||||
function closeLeaf(walk: Walk): void {
|
||||
const leaf = walk.leaf
|
||||
if (leaf === undefined) return
|
||||
if (leaf.kind === 'paragraph') {
|
||||
const text = takeParagraph(walk)
|
||||
if (text !== undefined) currentBlocks(walk).push({ kind: 'paragraph', text })
|
||||
const paragraph = takeParagraph(walk)
|
||||
if (paragraph !== undefined) currentBlocks(walk).push(paragraph)
|
||||
return
|
||||
}
|
||||
walk.leaf = undefined
|
||||
if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html' })
|
||||
else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows))
|
||||
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', text: leaf.lines.join('\n') })
|
||||
const { position } = leaf
|
||||
if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html', position })
|
||||
else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows, position))
|
||||
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', position, text: leaf.lines.join('\n') })
|
||||
}
|
||||
|
||||
function pipeTableBlock(rows: readonly [string[], ...string[][]]): Block {
|
||||
function pipeTableBlock(rows: readonly [string[], ...string[][]], position: SourcePosition): Block {
|
||||
const [header, delimiter, ...body] = rows
|
||||
if (delimiter !== undefined && delimiter.some(isPipeAlignment)) {
|
||||
return faultedBlock('a pipe table carries no column alignment ADF could hold')
|
||||
return faultedBlock('a pipe table carries no column alignment ADF could hold', position)
|
||||
}
|
||||
if (delimiter === undefined || !delimiter.every(isPipeDelimiter)) {
|
||||
return faultedBlock('a pipe table underlines its header with a row of `-` runs')
|
||||
return faultedBlock('a pipe table underlines its header with a row of `-` runs', position)
|
||||
}
|
||||
const ragged = [delimiter, ...body].find((row) => row.length !== header.length)
|
||||
if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`)
|
||||
return { kind: 'table', rows: [header, ...body] }
|
||||
if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`, position)
|
||||
return { kind: 'table', position, rows: [header, ...body] }
|
||||
}
|
||||
|
||||
function faultedBlock(message: string): Block {
|
||||
return { fault: malformedPipeTable(message), kind: 'fault' }
|
||||
function faultedBlock(message: string, position: SourcePosition): Block {
|
||||
return { fault: malformedPipeTable(message), kind: 'fault', position }
|
||||
}
|
||||
|
||||
function takeParagraph(walk: Walk): string | undefined {
|
||||
// The definitions a paragraph gives up are whole lines, so what is left starts at one this held.
|
||||
function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | undefined {
|
||||
const leaf = walk.leaf
|
||||
if (leaf?.kind !== 'paragraph') return undefined
|
||||
walk.leaf = undefined
|
||||
const text = readLinkDefinitions(walk.definitions, leaf.lines.join('\n'))
|
||||
return text === '' ? undefined : text
|
||||
if (text === '') return undefined
|
||||
const kept = leaf.positions[leaf.lines.length - text.split('\n').length]
|
||||
return { kind: 'paragraph', position: kept ?? leaf.position, text }
|
||||
}
|
||||
|
||||
function currentBlocks(walk: Walk): Block[] {
|
||||
return walk.stack.at(-1)?.blocks ?? walk.blocks
|
||||
}
|
||||
|
||||
function normalizeInput(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replaceAll('\u0000', '\ufffd')
|
||||
.replace(/\n$/, '')
|
||||
function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
|
||||
let line = 1
|
||||
let start = 0
|
||||
for (let index = 0; index < markdown.length; index += 1) {
|
||||
const character = markdown.charAt(index)
|
||||
if (character !== '\n' && character !== '\r') continue
|
||||
yield sourceLine(markdown, line, start, index)
|
||||
if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
|
||||
line += 1
|
||||
start = index + 1
|
||||
}
|
||||
if (start < markdown.length) yield sourceLine(markdown, line, start, markdown.length)
|
||||
}
|
||||
|
||||
function sourceLine(markdown: string, line: number, start: number, end: number): { position: SourcePosition; text: string } {
|
||||
return { position: { line, offset: start }, text: replaceNullCharacters(markdown.slice(start, end)) }
|
||||
}
|
||||
|
||||
function leadingColumns(line: Line): number {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import type { ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
@@ -23,6 +23,10 @@ function path(result: Result<AdfDocument>): readonly (number | string)[] {
|
||||
return result.ok ? ['built'] : result.error.path
|
||||
}
|
||||
|
||||
function position(result: Result<AdfDocument, ParseError>): SourcePosition | string {
|
||||
return result.ok ? 'built' : result.error.position
|
||||
}
|
||||
|
||||
function text(value: string): AdfNode {
|
||||
return { text: value, type: 'text' }
|
||||
}
|
||||
@@ -443,6 +447,30 @@ test('swallows an HTML block ahead of the claim a line inside it would make', ()
|
||||
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
|
||||
})
|
||||
|
||||
test('names the line and the offset in the input a refusal sits at, the innermost block winning', () => {
|
||||
assert.deepEqual(position(markdownToAdf('<div>\n')), { line: 1, offset: 0 })
|
||||
assert.deepEqual(position(markdownToAdf('Part.\n\n<div>\n')), { line: 3, offset: 7 })
|
||||
assert.deepEqual(position(markdownToAdf('> Part.\n>\n> a <span>b</span>\n')), { line: 3, offset: 10 })
|
||||
assert.deepEqual(position(markdownToAdf('- Part.\n- a <span>b</span>\n')), { line: 2, offset: 8 })
|
||||
assert.deepEqual(position(markdownToAdf('Part.\n\n:::panel info\nMore.\n')), { line: 3, offset: 7 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\na <span>b</span>\n===\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n```adf\n{\n```\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
|
||||
assert.deepEqual(position(markdownToAdf('a\nb <span>c</span>\n')), { line: 1, offset: 0 })
|
||||
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
|
||||
assert.deepEqual(position(markdownToAdf('a\u0000b\n\n<div>\n')), { line: 3, offset: 5 })
|
||||
assert.deepEqual(position(markdownToAdf(':::caption\na <span>b</span>\n:::\n')), { line: 2, offset: 11 })
|
||||
assert.deepEqual(position(markdownToAdf('x\n\n:::caption\n- a\n:::\n')), { line: 3, offset: 3 })
|
||||
})
|
||||
|
||||
test('names the line the text a paragraph keeps starts on, never a definition line it gave up', () => {
|
||||
assert.deepEqual(position(markdownToAdf('[a]: /url\n<span>b</span>\n')), { line: 2, offset: 10 })
|
||||
assert.deepEqual(position(markdownToAdf('[a]: /a\n[b]: /b\n[c]: /c\n[d]: /d\n<span>x</span>\n')), { line: 5, offset: 32 })
|
||||
assert.deepEqual(position(markdownToAdf('[a]:\n<the url>\n"Title"\n<span>b</span>\n')), { line: 4, offset: 23 })
|
||||
assert.deepEqual(position(markdownToAdf('> [a]: /url\n> <span>b</span>\n')), { line: 2, offset: 12 })
|
||||
assert.deepEqual(position(markdownToAdf(':::caption\n[a]: /url\n<span>b</span>\n:::\n')), { line: 3, offset: 21 })
|
||||
})
|
||||
|
||||
test('gives up the link reference definitions a paragraph opens with', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n')), [])
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')])
|
||||
|
||||
@@ -4,16 +4,18 @@ import type { BlockDirectiveNode } from './directive-nodes.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
|
||||
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
const documentStart: SourcePosition = { line: 1, offset: 0 }
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
const content = blockNodes(parsed.blocks, parsed.definitions, [], 0)
|
||||
const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0), documentStart)
|
||||
if (!content.ok) return content
|
||||
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
|
||||
}
|
||||
@@ -22,7 +24,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
const node = blockNode(block, definitions, [...path, 'content', index], depth)
|
||||
const node = positioned(blockNode(block, definitions, [...path, 'content', index], depth), block.position)
|
||||
if (!node.ok) return node
|
||||
content.push(node.value)
|
||||
}
|
||||
@@ -111,7 +113,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li
|
||||
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
|
||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body`, path)
|
||||
return contentNode(node, only.text, definitions, path)
|
||||
return positioned(contentNode(node, only.text, definitions, path), only.position)
|
||||
}
|
||||
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
|
||||
+13
-5
@@ -17,15 +17,18 @@ export type ConvertErrorCode =
|
||||
|
||||
export type ConvertErrorPath = readonly (number | string)[]
|
||||
|
||||
export type ConvertError = {
|
||||
export type SourcePosition = { line: number; offset: number }
|
||||
|
||||
export type ConvertFault = {
|
||||
code: ConvertErrorCode
|
||||
message: string
|
||||
path: ConvertErrorPath
|
||||
}
|
||||
|
||||
export type ConvertFault = Omit<ConvertError, 'path'>
|
||||
export type ConvertError = ConvertFault & { path: ConvertErrorPath; position?: SourcePosition }
|
||||
|
||||
export type Result<T> = { error: ConvertError; ok: false } | { ok: true; value: T }
|
||||
export type ParseError = ConvertError & { position: SourcePosition }
|
||||
|
||||
export type Result<T, E extends ConvertError = ConvertError> = { error: E; ok: false } | { ok: true; value: T }
|
||||
|
||||
export function failure<T>(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result<T> {
|
||||
return { error: { code, message, path }, ok: false }
|
||||
@@ -35,6 +38,11 @@ export function faulted<T>(fault: ConvertFault, path: ConvertErrorPath): Result<
|
||||
return failure(fault.code, fault.message, path)
|
||||
}
|
||||
|
||||
export function success<T>(value: T): Result<T> {
|
||||
export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T, ParseError> {
|
||||
if (result.ok) return result
|
||||
return { error: { ...result.error, position: result.error.position ?? position }, ok: false }
|
||||
}
|
||||
|
||||
export function success<T>(value: T): { ok: true; value: T } {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
@@ -392,3 +392,23 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
||||
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
|
||||
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
|
||||
hub, not the formats around it.
|
||||
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
|
||||
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
|
||||
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
|
||||
optional `position` the parser carries to every parse-side mint, and the README's published
|
||||
shape gains it.
|
||||
**Settled** (the maintainer, 2026-09-03): the position is the parse side's alone — an
|
||||
emitter has no source string to point into, so emit-side errors keep `path` unchanged. The
|
||||
representation and where the position is captured are implementation judgment.
|
||||
The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
|
||||
and at the inline body a directive holds — so the innermost block wins and the emitter's
|
||||
own refusals, which the parser re-enters for the CommonMark spelling, get an input
|
||||
coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
|
||||
`Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
|
||||
anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
|
||||
kept text starts on, never a link reference definition it gave up. Line endings stay as
|
||||
the input spells them, so an offset indexes the string the caller passed rather than a
|
||||
normalized copy of it. §8 records the framings the review settled beside it:
|
||||
`unsupported-node-shape` stays one code across the two directions, `unmappable-html` names
|
||||
the version rather than the element, and a direction that reads a source returns the
|
||||
narrowed error type.
|
||||
|
||||
@@ -5,7 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
|
||||
|
||||
## Milestones
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 → `0.3.0`.
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 →
|
||||
`0.3.0`.
|
||||
The numbering is the order the work was planned in, not the order it ships.
|
||||
|
||||
- [x] **0 — Scaffold.**
|
||||
@@ -139,6 +140,37 @@ The numbering is the order the work was planned in, not the order it ships.
|
||||
`0.1.0` keeps — 3e names three shapes that parse and then refuse — so the release narrows
|
||||
that sentence or lists them.
|
||||
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
||||
- [ ] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
|
||||
found the error result legible to the library and opaque to the consumer holding it, and the
|
||||
README documenting no part of it. The sub-items are that read's answers, and they land before
|
||||
5 because §8 freezes the code list at `0.1.0` and 5b3's table is what reads the list before
|
||||
the freeze closes it.
|
||||
- [x] **5b1 — The error's source position.**
|
||||
- [ ] **5b2 — The error messages.** Most state the rule and leave the violation to be inferred —
|
||||
`a text node holds text` for a node holding none — so `rule: violation` becomes house style
|
||||
across the sites that do. `not-an-adf-document` gives one sentence of eight words to `null`,
|
||||
a string, a missing `version`, a `type` that is not `doc` and a REST envelope around the
|
||||
document; naming the check that failed makes the highest-frequency integrator mistake
|
||||
self-diagnosing without the library naming a REST shape (§7). The three carve-out claim
|
||||
messages name the escape that unclaims the line — `\|`, `\~~`, `\:::` — which today only
|
||||
`spec/flavour.md` holds. `unmappable-html` reads as §8 now frames it: this version converts
|
||||
no raw HTML, never a permanent judgment on the element.
|
||||
- [ ] **5b3 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
|
||||
code name appears in the README, so it gains a table — code, when it fires, what the
|
||||
consumer does — grouped by direction; drafting it is the audit that reads the fifteen names
|
||||
before 5 freezes them. Four things a reader who has not opened the code cannot know: raw
|
||||
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
|
||||
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
|
||||
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
|
||||
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
|
||||
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — two adjacent
|
||||
`bulletList` nodes are an error result — which the viewer persona needs told along with
|
||||
what to do about it; and GFM past tables and strikethrough is literal text, task lists
|
||||
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
|
||||
`message` is free text. The type-level surface freezes at the same moment and gets the same
|
||||
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
|
||||
where a direction reads a source, and `ConvertFault` staying internal — the README table
|
||||
names the shapes a consumer switches on, so the two audits are one.
|
||||
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
|
||||
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
|
||||
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
|
||||
|
||||
Reference in New Issue
Block a user