5b1: the error's source position #44
@@ -117,6 +117,13 @@ begins on — the offset indexing the string the caller passed, the line counted
|
||||
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,9 +24,9 @@ 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
|
||||
@@ -35,8 +35,12 @@ isAdfDocument(v: unknown): v is AdfDocument
|
||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
||||
`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. Emitting reads no
|
||||
source, so an emit error carries no `position`.
|
||||
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
|
||||
|
||||
|
||||
@@ -206,7 +206,6 @@ for (const name of pairedNames(errorsRoot, '.md', '.error')) {
|
||||
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.ok(position !== undefined, 'the refusal names no position in the input')
|
||||
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>')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
markerInterruptsParagraph,
|
||||
openingCodeFence,
|
||||
openingHtmlBlock,
|
||||
replaceNullCharacters,
|
||||
setextHeadingLevel,
|
||||
} from '../commonmark-grammar.ts'
|
||||
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
||||
@@ -424,24 +425,26 @@ function currentBlocks(walk: Walk): Block[] {
|
||||
return walk.stack.at(-1)?.blocks ?? walk.blocks
|
||||
}
|
||||
|
||||
// The line ending stays as the input spells it: an offset indexes the string the caller passed.
|
||||
function sourceLines(markdown: string): { position: SourcePosition; text: string }[] {
|
||||
const source = markdown.replaceAll('\u0000', '\ufffd')
|
||||
const lines: { position: SourcePosition; text: string }[] = []
|
||||
let line = 1
|
||||
let start = 0
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source.charAt(index)
|
||||
for (let index = 0; index < markdown.length; index += 1) {
|
||||
const character = markdown.charAt(index)
|
||||
if (character !== '\n' && character !== '\r') continue
|
||||
lines.push({ position: { line, offset: start }, text: source.slice(start, index) })
|
||||
if (character === '\r' && source.charAt(index + 1) === '\n') index += 1
|
||||
lines.push(sourceLine(markdown, line, start, index))
|
||||
if (character === '\r' && markdown.charAt(index + 1) === '\n') index += 1
|
||||
line += 1
|
||||
start = index + 1
|
||||
}
|
||||
if (start < source.length) lines.push({ position: { line, offset: start }, text: source.slice(start) })
|
||||
if (start < markdown.length) lines.push(sourceLine(markdown, line, start, markdown.length))
|
||||
return lines
|
||||
}
|
||||
|
||||
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 {
|
||||
let columns = 0
|
||||
for (const character of line.text) {
|
||||
|
||||
@@ -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, SourcePosition } from '../../result.ts'
|
||||
import type { ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
@@ -23,9 +23,8 @@ function path(result: Result<AdfDocument>): readonly (number | string)[] {
|
||||
return result.ok ? ['built'] : result.error.path
|
||||
}
|
||||
|
||||
function position(result: Result<AdfDocument>): SourcePosition | string {
|
||||
if (result.ok) return 'built'
|
||||
return result.error.position ?? 'no position'
|
||||
function position(result: Result<AdfDocument, ParseError>): SourcePosition | string {
|
||||
return result.ok ? 'built' : result.error.position
|
||||
}
|
||||
|
||||
function text(value: string): AdfNode {
|
||||
@@ -459,6 +458,9 @@ test('names the line and the offset in the input a refusal sits at, the innermos
|
||||
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('gives up the link reference definitions a paragraph opens with', () => {
|
||||
|
||||
@@ -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, positioned, 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 })
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
+9
-9
@@ -19,16 +19,16 @@ export type ConvertErrorPath = readonly (number | string)[]
|
||||
|
||||
export type SourcePosition = { line: number; offset: number }
|
||||
|
||||
export type ConvertError = {
|
||||
export type ConvertFault = {
|
||||
code: ConvertErrorCode
|
||||
message: string
|
||||
path: ConvertErrorPath
|
||||
position?: SourcePosition
|
||||
}
|
||||
|
||||
export type ConvertFault = Omit<ConvertError, 'path' | 'position'>
|
||||
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 }
|
||||
@@ -38,11 +38,11 @@ export function faulted<T>(fault: ConvertFault, path: ConvertErrorPath): Result<
|
||||
return failure(fault.code, fault.message, path)
|
||||
}
|
||||
|
||||
export function positioned<T>(result: Result<T>, position: SourcePosition): Result<T> {
|
||||
if (result.ok || result.error.position !== undefined) return result
|
||||
return { error: { ...result.error, position }, ok: false }
|
||||
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): Result<T> {
|
||||
export function success<T>(value: T): { ok: true; value: T } {
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
@@ -167,7 +167,10 @@ The numbering is the order the work was planned in, not the order it ships.
|
||||
`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.
|
||||
`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