diff --git a/AGENTS.md b/AGENTS.md index c4df2f5..504f9a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, and a direction reading a source returns +`Result` — `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`, 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 diff --git a/README.md b/README.md index 38fda45..cdfb90f 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compos ```ts adfToMarkdown(doc: AdfDocument): Result -markdownToAdf(markdown: string): Result +markdownToAdf(markdown: string): Result adfToHtml(doc: AdfDocument): Result -htmlToAdf(html: string): Result +htmlToAdf(html: string): Result markdownToHtml(markdown: string): Result // via ADF htmlToMarkdown(html: string): Result // via ADF isAdfDocument(v: unknown): v is AdfDocument @@ -35,8 +35,12 @@ isAdfDocument(v: unknown): v is AdfDocument `Result` 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 diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 4cb9f01..2e72235 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -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)) }) } diff --git a/src/index.ts b/src/index.ts index 4210fb3..d761f20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 7c4e074..0fbefe1 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.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(`^`). 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>') } diff --git a/src/markdown/entity-references.ts b/src/markdown/entity-references.ts index 8354f9a..d048864 100644 --- a/src/markdown/entity-references.ts +++ b/src/markdown/entity-references.ts @@ -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 diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 887c761..8e93651 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -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) { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 97c8308..073f0cd 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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): readonly (number | string)[] { return result.ok ? ['built'] : result.error.path } -function position(result: Result): SourcePosition | string { - if (result.ok) return 'built' - return result.error.position ?? 'no position' +function position(result: Result): 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 c\n')), { line: 1, offset: 0 }) assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 }) + assert.deepEqual(position(markdownToAdf('a\u0000b\n\n
\n')), { line: 3, offset: 5 }) + assert.deepEqual(position(markdownToAdf(':::caption\na b\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', () => { diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 82a1bc2..2fe0bf5 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 { +const documentStart: SourcePosition = { line: 1, offset: 0 } + +export function markdownToAdf(markdown: string): Result { 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 { diff --git a/src/result.ts b/src/result.ts index d6413cf..62ba6e8 100644 --- a/src/result.ts +++ b/src/result.ts @@ -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 +export type ConvertError = ConvertFault & { path: ConvertErrorPath; position?: SourcePosition } -export type Result = { error: ConvertError; ok: false } | { ok: true; value: T } +export type ParseError = ConvertError & { position: SourcePosition } + +export type Result = { error: E; ok: false } | { ok: true; value: T } export function failure(code: ConvertErrorCode, message: string, path: ConvertErrorPath): Result { return { error: { code, message, path }, ok: false } @@ -38,11 +38,11 @@ export function faulted(fault: ConvertFault, path: ConvertErrorPath): Result< return failure(fault.code, fault.message, path) } -export function positioned(result: Result, position: SourcePosition): Result { - if (result.ok || result.error.position !== undefined) return result - return { error: { ...result.error, position }, ok: false } +export function positioned(result: Result, position: SourcePosition): Result { + if (result.ok) return result + return { error: { ...result.error, position: result.error.position ?? position }, ok: false } } -export function success(value: T): Result { +export function success(value: T): { ok: true; value: T } { return { ok: true, value } } diff --git a/todo.md b/todo.md index a01b1f1..0171392 100644 --- a/todo.md +++ b/todo.md @@ -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` /