From 7da3c1fbbe76b87387442f07941cd2bc7898b73f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 00:59:46 +0200 Subject: [PATCH] Refuse an attribute value JSON does not hold, and push a folded callout's title leaves one at a time --- CHANGELOG.md | 2 ++ README.md | 2 +- src/adf/document.test.ts | 10 ++++++++++ src/json-value.ts | 9 ++++++++- src/markdown/parse/markdown-to-adf.ts | 2 +- .../parse/plain-markdown-to-adf.test.ts | 6 ++++++ todo.md | 19 ++----------------- 7 files changed, 30 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf8199..8cb2fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fix an attribute value JSON does not hold — a `Date`, `Map`, `Set`, typed array or class instance — + passing `isAdfDocument` and converting as `{}`: it is `not-an-adf-document`. - The README opens with what the package does and for whom, and `package.json` carries search keywords. - The source moves to [github.com/larvit/adf-codec](https://github.com/larvit/adf-codec), where diff --git a/README.md b/README.md index 2a96ceb..26d40c9 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: | Code | Fires when | What you can do | | --- | --- | --- | -| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused | +| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an attribute value JSON does not hold — a `Date`, `Map` or class instance —, an object in the document holding itself | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused | | `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change | Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 5b66c8f..59c3141 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -75,6 +75,16 @@ test('names the attribute nesting past the levels the parser reads one at, and s test('accepts the JSON values an attribute may hold', () => { assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(isAdfDocument({ content: [{ attrs: { a: Object.assign(Object.create(null), { b: 1 }) }, type: 'paragraph' }], type: 'doc', version: 1 }), true) +}) + +test('refuses an attribute value that is an object JSON does not hold', () => { + class Point { x = 1 } + for (const value of [new Date(0), new Map([['b', 1]]), new Set([1]), new Uint8Array([1, 2]), new Point(), [new Map()], { b: new Set() }]) { + assert.equal(isAdfDocument({ content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 }), false, String(value)) + } + assert.equal(isAdfDocument({ content: [{ attrs: new Map(), type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(isAdfDocument({ content: [{ marks: [{ attrs: { href: new URL('https://x.test') }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false) }) test("reads a node's siblings as a walk rather than as one call's arguments", () => { diff --git a/src/json-value.ts b/src/json-value.ts index 6448b4d..fd30061 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -13,12 +13,19 @@ export function isJsonValue(value: unknown): value is JsonValue { } // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. if (Array.isArray(item)) for (const child of [...item]) pending.push(child) - else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child) + else if (isPlainObject(item)) for (const child of Object.values(item)) pending.push(child) else return false } return true } +// A Date, Map or class instance passes typeof but JSON writes it as {} or a key per element. +function isPlainObject(value: unknown): value is object { + if (typeof value !== 'object' || value === null) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + export function nestingDepth(value: unknown): number { const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] let deepest = 0 diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 26907f8..0f96f64 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -154,7 +154,7 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro for (const [index, node] of title.value.nodes.entries()) { const held = node.text === undefined ? inlineLeaves([node], 'paragraph', [...path, 'content', index], depth) : success([node]) if (!held.ok) return held - leaves.push(...held.value) + for (const leaf of held.value) leaves.push(leaf) } const text = titleText(leaves) const type = reading.inExpand ? 'nestedExpand' : 'expand' diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts index af6aee2..99e4ba5 100644 --- a/src/markdown/parse/plain-markdown-to-adf.test.ts +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -124,6 +124,12 @@ test('reads a folded callout to an expand titled by the rest of its marker line, assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))]) }) +test("reads a folded callout's title leaves as a walk rather than as one call's arguments", () => { + const children = Array.from({ length: 130000 }, () => '{\\"text\\":\\"a\\",\\"type\\":\\"text\\"}').join(',') + const parsed = plainMarkdownToAdf(`> [!NOTE]- !adf:carry{json="{\\"content\\":[${children}],\\"type\\":\\"unknownInline\\"}"}\n`) + assert.equal(parsed.ok && parsed.value.content?.[0]?.attrs?.['title'], 'a'.repeat(130000)) +}) + test('reads a folded callout inside an expand to a nested expand', () => { const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n' assert.deepEqual(read(markdown), [node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))]) diff --git a/todo.md b/todo.md index da69f66..8c250b5 100644 --- a/todo.md +++ b/todo.md @@ -27,11 +27,9 @@ | 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 | -| 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 | | 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 | | 77 | 0.2.0 | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 4 | 4 | 5 | 9 | 1 | 23.0 | -| 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 | | 71 | 0.2.0 | | **Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.** | 1 | 3 | 6 | 7 | 3 | 21.2 | | 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 4, 5 | 20.3 | | 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 | @@ -75,7 +73,8 @@ Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs agai 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. The code the call returns follows `docs/decisions.md` §The code list. Found while checking the other -ADF converters for item 71, 2026-10-05. +ADF converters for item 71, 2026-10-05. No code reads true of it (`docs/decisions.md` §The code list): +the chunk asks the maintainer for one first, proposing `not-a-string`. ### 45. Replace `isAdfDocument` with a reader returning `Result`. @@ -84,13 +83,6 @@ boolean guard is the one export that does not, and it cannot say which branch re `not-an-adf-document`'s message already does. Breaking: `MIGRATION.md` shows the guard's replacement. -### 67. Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result. - -`quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into -`push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a -folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from -`plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04. - ### 6. Specify the HTML dialect. Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented @@ -120,13 +112,6 @@ still claim unbounded width, which the 500-level limit does not cover, so the co the values it reads. No standard sets that number: the chunk asks the maintainer for it first. Found by the technical-principles audit, 2026-10-04. -### 66. Refuse an attribute value that is not a plain object, array or JSON primitive. - -`isJsonValue` (`json-value.ts`) walks any object by `Object.values`, so a `Date`, `Map`, `Set` or -typed array in `attrs`, as `structuredClone` keeps it, passes the guard and is written as `{}` or a -key per element: `ok: true` with content lost. Accept an object whose prototype is -`Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04. - ### 71. Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour. Lands after items 7 and 46: the table names HTML and compares item 46's size figure. Its columns: