From 2c4fadbd8b091fc447e7b29422123223134755bc Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 09:19:19 +0200 Subject: [PATCH] Refuse a markdown input that is not a string with not-a-string --- CHANGELOG.md | 4 ++++ MIGRATION.md | 2 ++ README.md | 4 +++- docs/decisions.md | 3 +++ src/adf/document.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 14 +++++++++++++- src/markdown/parse/markdown-to-adf.ts | 3 ++- src/result.ts | 1 + todo.md | 13 ------------- 9 files changed, 29 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ccb93..d976265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` refuse a value that is not a string with + the new code `not-a-string`, at position `{ line: 1, offset: 0 }`. Before, `null` and `undefined` + threw a `TypeError` and a number read as an empty document. A `switch` over `ConvertErrorCode` + naming every code needs the new case. - **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a `Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own diff --git a/MIGRATION.md b/MIGRATION.md index e003bd6..661d748 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -65,6 +65,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`: a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now converts; markdown the parser refused with one of them now refuses with the code the table gives. +`not-a-string` joins it: a `switch` naming every code needs its case. The markdown readers return +it for a value that is not a string, which threw or read as an empty document before. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | diff --git a/README.md b/README.md index 8519615..64e2e50 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,8 @@ the identical error: fix the input, or set the document aside. `position` is `{ line, offset }` into the string passed in: `line` counted from 1, `offset` a UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte offset. It points at or before the refusal — currently the start of the line the enclosing block begins on; a later -minor may narrow that, never widen it. +minor may narrow that, never widen it. `not-a-string`, having no string to point into, names +`{ line: 1, offset: 0 }`. Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`: @@ -186,6 +187,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0` | --- | --- | --- | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | +| `not-a-string` | the value handed in is not a string: `null`, `undefined`, a number, an object | pass the markdown as a string; the message names what was passed | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour | | `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title | diff --git a/docs/decisions.md b/docs/decisions.md index b48fd6d..1fd4fc5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -383,6 +383,9 @@ handles one cause alike whichever node, attribute or direction raised it. knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23). - A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` emitting — no document holds one, so no round-trip crosses them (2026-09-23). +- A value handed to a markdown reader that is not a string is `not-a-string`, its position + `{ line: 1, offset: 0 }` and its path empty: no code read true of it, and `not-an-adf-document` + names the other direction's input (2026-10-05). ## `message` and `path` diff --git a/src/adf/document.ts b/src/adf/document.ts index 5945edb..2d120bb 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -208,7 +208,7 @@ function notADocument(message: string): ConvertFault { return { code: 'not-an-adf-document', message } } -function describe(value: unknown): string { +export function describe(value: unknown): string { if (typeof value === 'string') return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value) if (typeof value === 'function') return 'a function' if (typeof value === 'object' && value !== null) return Array.isArray(value) ? 'an array' : 'an object' diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 6672d37..d6e5d20 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -4,7 +4,7 @@ import test from 'node:test' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { ParseError, Result, SourcePosition } from '../../result.ts' import { largestNesting } from '../../nesting.ts' -import { markdownToAdf } from './markdown-to-adf.ts' +import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts' const em: AdfMark = { type: 'em' } const strike: AdfMark = { type: 'strike' } @@ -84,6 +84,18 @@ function table(...rows: AdfNode[]): AdfNode { return { content: rows, type: 'table' } } +test('refuses a value that is not a string, in both readers, at the start of the input', () => { + const found: [unknown, string][] = [[null, 'null'], [undefined, 'undefined'], [42, '42'], [{}, 'an object'], [['x'], 'an array']] + for (const read of [markdownToAdf, plainMarkdownToAdf]) { + for (const [value, described] of found) { + assert.deepEqual(Reflect.apply(read, undefined, [value]), { + error: { code: 'not-a-string', message: `markdown is a string: found ${described}`, path: [], position: { line: 1, offset: 0 } }, + ok: false, + }) + } + } +}) + test('builds an empty document from input holding no block, and one holding no content key from the doc directive alone', () => { assert.deepEqual(markdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } }) assert.deepEqual(markdownToAdf('\n \n\t\n'), { ok: true, value: { content: [], type: 'doc', version: 1 } }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 0f96f64..3f80ac5 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -8,7 +8,7 @@ import type { LinkDefinitions } from './inline-content.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' -import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { describe, emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { inlineLeaves } from '../plain/inline-reduction.ts' import { languageSlot } from '../code-language.ts' @@ -39,6 +39,7 @@ export function plainMarkdownToAdf(markdown: string): Result { + if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${describe(markdown)}`, []), documentStart) const parsed = parseBlocks(markdown) const [only] = parsed.blocks if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) { diff --git a/src/result.ts b/src/result.ts index 8c86450..162c7e2 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,6 +1,7 @@ export type ConvertErrorCode = | 'malformed-directive' | 'malformed-pipe-table' + | 'not-a-string' | 'not-an-adf-document' | 'unknown-directive-name' | 'unmappable-html' diff --git a/todo.md b/todo.md index ac755f3..b1ef9ce 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| | 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 | -| 78 | 0.2.0 | defect | **Return a result when `markdownToAdf` or `plainMarkdownToAdf` is handed a value that is not a string.** | 2 | 2 | 5 | 9 | 1 | 25.5 | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 | | 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 8 | 23.0 | @@ -68,18 +67,6 @@ Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against `markdownToHtml`. The README documents HTML as it documents markdown. -### 78. Return a result when `markdownToAdf` or `plainMarkdownToAdf` is handed a value that is not a string. - -Today `null` and `undefined` throw a `TypeError` reading `length`, and `42` reads as an empty -document. A JavaScript caller, or a TypeScript one past an untyped boundary, can pass any of these. -No existing code reads true of a non-string input (`docs/decisions.md` §The code list). The -maintainer chose a new one on 2026-10-05: `not-a-string`, with position `{ line: 1, offset: 0 }` -and an empty path. The chunk adds it to `ConvertErrorCode` and the README's parsing table, rewords -the README's `position` paragraph, which assumes a string was passed in, and records under -`docs/decisions.md` §Which code a cause takes that a non-string input takes `not-a-string`. -Breaking (§The code list): the changelog entry says so. Found while checking the other ADF -converters for item 71. - ### 45. Replace `isAdfDocument` with a reader returning `Result`. Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the