3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Replace isAdfDocument with readAdfDocument, reading the input once into a plain copy

This commit is contained in:
2026-11-09 07:14:07 +01:00
committed by Lillem4n
parent 899b854d53
commit ca6016b4b6
17 changed files with 332 additions and 134 deletions
+2 -2
View File
@@ -73,7 +73,7 @@ In `docs/decisions.md`:
- HTML writes a URL as the ADF holds it, whatever its scheme
- Nothing recurses unbounded
- JSON text is read by `parseJsonText`
- No value `JSON.parse` or `structuredClone` builds loops a call
- A call reads its input once into a plain copy
- Nothing spreads an unbounded array
- A retry loop checks its own termination
- After 32 passes, a line carries or drops every em, strike and strong mark
@@ -135,7 +135,7 @@ parenthesized value set reading `string`; fenced examples are skipped. Keep pros
keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
value imports, so moving or renaming a module reorders nothing.
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
fields it claims (`readAdfDocument`); past it everything is typed. Make invalid states
unrepresentable.
- Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## Unreleased
- **Breaking:** `readAdfDocument` replaces `isAdfDocument`: it returns a `Result<AdfDocument>` whose
error names the branch that refused, and `MIGRATION.md` shows the change.
- `readAdfDocument`, `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown` read the
document once into a plain copy, so a document holding an accessor property, or a Proxy that
throws, returns `not-an-adf-document` where it ran the getter, looped or threw. A document of
more than 10,000,000 JSON values returns `unsupported-node-shape`.
## 0.3.1
- The README opens with a one-line description, a list of the calls and the package's perks, and
+19
View File
@@ -1,5 +1,24 @@
# Migrating
## From `0.3.1` to `0.4.0`
`isAdfDocument` is gone. `readAdfDocument` takes its place and returns a `Result<AdfDocument>`; the
`message` of its error names the branch that refused.
| `0.3.1` | `0.4.0` |
| --- | --- |
| `if (isAdfDocument(value)) convert(value)` | `const read = readAdfDocument(value); if (read.ok) convert(read.value)` |
| `const valid = isAdfDocument(value)` | `const valid = readAdfDocument(value).ok` |
Convert `read.value`: it is a copy that shares no object with `value`. The copy keeps own
enumerable keys, as `JSON.stringify` does, so an inherited or non-enumerable key drops. A document
nesting past 500 levels still reads; the converters refuse it as `unsupported-nesting-depth`.
`readAdfDocument`, `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown` read a document
once into that copy. A document holding an accessor property, or a Proxy that throws, is
`not-an-adf-document` in `0.4.0`, where `0.3.1` ran the getter or threw. A document holding more
than 10,000,000 JSON values is `unsupported-node-shape`.
## From `0.2.0` to `0.3.0`
Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf`
+14 -8
View File
@@ -311,7 +311,7 @@ sets example documents beside each writer's output and says whether that output
```ts
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
isAdfDocument(v: unknown): v is AdfDocument
readAdfDocument(v: unknown): Result<AdfDocument>
commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>
@@ -333,12 +333,15 @@ Each call's name says which format it reads or writes:
| `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `readAdfDocument` | reads ADF as JSON, a value you hold from a parser or a store, into an `AdfDocument`, and writes nothing | `not-an-adf-document`, `unsupported-node-shape` |
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no
object holding itself. It returns `true` for a document nesting past 500 levels; the converters
refuse that one with `unsupported-nesting-depth`.
`readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It reads data
properties and own enumerable keys alone, as `JSON.stringify` does, so an accessor property or a
Proxy that throws is `not-an-adf-document` and never a throw, and the three writers read their
document the same way. It checks the shape the converters require: `type: 'doc'`, a finite
`version` (the converters take only 1), nodes holding only `attrs`, `content`, `marks`, `text` and
`type`, attribute values that are JSON values, and no object holding itself. It reads a document
nesting past 500 levels; the converters refuse that one with `unsupported-nesting-depth`.
The types:
@@ -612,7 +615,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r
| 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, an attribute value that is not a plain object, array or JSON primitive, such as a `Date` or `Map` | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused. Build an attribute value as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`, `{ ...instance }` |
| `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, an accessor property, a Proxy that throws, an attribute value that is not a plain object, array or JSON primitive, such as a `Date` or `Map` | read the boundary you receive JSON at with `readAdfDocument`; the message names the branch that refused. Build an attribute value as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`, `{ ...instance }` |
| `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
@@ -662,6 +665,9 @@ Every call can raise these:
`path` and message name.
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
This is a bug in this library: report it.
- **Reading a document, in `readAdfDocument` and the three writers**: the document holds more than
10,000,000 JSON values, each object, array, string, number, boolean and null counting one. Keep
the ADF and pass the document over or show it read-only; where you built the ADF, split it.
- **Past the engine's longest string**:
- a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every
engine;
@@ -772,9 +778,9 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
| `commonMarkToAdf` | function | strict CommonMark to ADF |
| `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF |
| `isAdfDocument` | function | type guard for `AdfDocument` |
| `losslessMarkdownToAdf` | function | the lossless flavour to ADF |
| `portableMarkdownToAdf` | function | portable markdown to ADF |
| `readAdfDocument` | function | JSON you receive, checked and copied into an `AdfDocument` |
## Changing the source
+3 -3
View File
@@ -1,5 +1,5 @@
try {
const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js')
const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, losslessMarkdownToAdf, readAdfDocument } = await import('/dist/index.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
// A viewer/editor app posts the page's innerHTML back, so htmlToAdf must read what the browser writes.
@@ -18,11 +18,11 @@ try {
const adf = JSON.parse(json)
const emitted = adfToLosslessMarkdown(adf)
const html = adfToHtml(adf)
return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, innerHtml: throughInnerHtml(adf), isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, innerHtml: throughInnerHtml(adf), isDocument: readAdfDocument(adf).ok, name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
}),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json)
return { emitted: adfToLosslessMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(losslessMarkdownToAdf(markdown)) }
return { emitted: adfToLosslessMarkdown(adf), isDocument: readAdfDocument(adf).ok, name, parsed: spelled(losslessMarkdownToAdf(markdown)) }
}),
})
} catch (cause) {
+13 -5
View File
@@ -963,13 +963,21 @@ can read an escaped key as another key it read before, and the round-trip then r
output. A test fails on `JSON.parse(` in a shipped file. The tests' own oracles keep `JSON.parse`,
since a misread there fails the gate and never hides a bug.
## No value `JSON.parse` or `structuredClone` builds loops a call
## A call reads its input once into a plain copy
2026-10-04, the maintainer. Goals 1 and 9. Valid until `todo.md` item 77 lands.
2026-10-04, the maintainer; the 10 million 2026-10-05; the code and the name 2026-11-09, applying
§Which code a cause takes and the entry for the long output. Goals 1 and 9. Valid while a persona
can hand in a value with accessors or a Proxy.
The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one. Goal
9's size counts ADF as JSON, so a shared object costs once per place it is held.
`readAdfDocument` and the three writers copy the input once, before any check, reading property
descriptors so no getter runs: an accessor property, or a Proxy trap that throws, is
`not-an-adf-document`. The copy keeps own enumerable string keys, as `JSON.stringify` does, so an
inherited or non-enumerable key drops, and reads an array by index, so no iterator runs. It refuses
a cycle by the objects above the one it copies. Every later step reads the copy. The copy takes at
most 10 million JSON values, each object, array, string, number, boolean and null counting one,
past which the document is `unsupported-node-shape`, since the document does not convert; an array
claiming more elements than the values left is refused before it is read. Goal 9's size counts ADF
as JSON, so a shared object costs once per place it is held.
## Nothing spreads an unbounded array
+3 -3
View File
@@ -1,4 +1,4 @@
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, htmlToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, htmlToAdf, losslessMarkdownToAdf, portableMarkdownToAdf, readAdfDocument, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
@@ -8,9 +8,9 @@ const parsed: Result<AdfDocument, ParseError> = losslessMarkdownToAdf('x\n')
const portableEmitted: Result<string> = adfToPortableMarkdown(document)
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
const commonMarkParsed: Result<AdfDocument, ParseError> = commonMarkToAdf('x\n')
const guarded: boolean = isAdfDocument(document)
const read: Result<AdfDocument> = readAdfDocument(document)
const htmlParsed: Result<AdfDocument, ParseError> = htmlToAdf('<p>x</p>\n')
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
export const surface = { code, commonMarkParsed, guarded, htmlEmitted, htmlParsed, line, portableEmitted, portableParsed }
export const surface = { code, commonMarkParsed, htmlEmitted, htmlParsed, line, portableEmitted, portableParsed, read }
+131 -27
View File
@@ -5,13 +5,23 @@ import vm from 'node:vm'
import type { AdfDocument, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.ts'
import { adfDocumentFault, isAdfDocument, writableContent } from './document.ts'
import { adfDocumentFault, readAdfDocument, writableContent } from './document.ts'
import { largestNesting } from '../nesting.ts'
import { mostValues } from './plain-copy.ts'
function fault(value: unknown): string {
return adfDocumentFault(value)?.message ?? 'accepted'
}
function readable(value: unknown): boolean {
return readAdfDocument(value).ok
}
function readFault(value: unknown): string {
const result = readAdfDocument(value)
return result.ok ? 'accepted' : `${result.error.code}: ${result.error.message}`
}
function faultCode(value: unknown): string {
return adfDocumentFault(value)?.code ?? 'accepted'
}
@@ -27,13 +37,13 @@ function withAttribute(value: JsonValue): unknown {
}
test('accepts an editor-normal document', () => {
assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true)
assert.equal(readable({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(readable({ type: 'doc', version: 1 }), true)
assert.equal(fault({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted')
})
test('names the check anything that is not a doc node failed', () => {
assert.equal(isAdfDocument(null), false)
assert.equal(readable(null), false)
assert.equal(fault(null), 'an ADF document is an object: found null')
assert.equal(fault(undefined), 'an ADF document is an object: found undefined')
assert.equal(fault([]), 'an ADF document is an object: found an array')
@@ -54,13 +64,13 @@ test('names the check anything that is not a doc node failed', () => {
test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
assert.equal(fault({ content: {}, type: 'doc', version: 1 }), "an ADF document's content is an array: found an object")
assert.equal(fault({ content: [{ type: 1 }], type: 'doc', version: 1 }), "an ADF document's content holds ADF nodes: one of them is not")
assert.equal(isAdfDocument({ content: [{ type: 1 }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ marks: [{ type: 1 }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ attrs: { a: Number.POSITIVE_INFINITY }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ type: 1 }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ marks: [{ type: 1 }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ attrs: { a: Number.POSITIVE_INFINITY }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
})
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
@@ -68,48 +78,48 @@ test('names the attribute nesting past the levels the parser reads one at, and s
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
assert.equal(readable(withAttribute(nested(largestNesting + 1))), true)
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
assert.equal(fault(marked(largestNesting)), 'accepted')
assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
assert.equal(readable(marked(largestNesting + 1)), true)
})
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)
assert.equal(isAdfDocument(vm.runInNewContext("({ content: [{ attrs: { a: { b: [1] } }, type: 'paragraph' }], type: 'doc', version: 1 })")), true)
assert.equal(readable({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(readable({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ content: [{ attrs: { a: Object.assign(Object.create(null), { b: 1 }) }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
assert.equal(readable(vm.runInNewContext("({ content: [{ attrs: { a: { 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(readable({ 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)
assert.equal(readable({ content: [{ attrs: new Map(), type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readable({ 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", () => {
const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 }
assert.equal(isAdfDocument(wide), true)
assert.equal(readable(wide), true)
assert.equal(fault(wide), 'accepted')
})
test('refuses an object holding itself, and accepts one shared', () => {
const holdsItself = 'an ADF document is a tree: an object in it holds itself'
const holdsItself = 'not-an-adf-document: an ADF document is a tree: an object in it holds itself'
const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
paragraph.content.push(paragraph)
assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself)
assert.equal(readFault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself)
const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 }
document.content.push(document)
assert.equal(fault(document), holdsItself)
assert.equal(readFault(document), holdsItself)
const attrs: Record<string, unknown> = {}
attrs['a'] = [attrs]
assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(isAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
assert.equal(readFault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(readFault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(readable({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
const bold = { type: 'strong' }
const text = { marks: [bold], text: 'x', type: 'text' }
const shared = { content: [text, text], type: 'paragraph' }
@@ -217,3 +227,97 @@ test('names a link-marked card at any depth by its path alone, with no step quad
assert.equal(!refused.ok && refused.error.path.length, 2 * (depth + 1))
assert.deepEqual(!refused.ok && refused.error.path.slice(0, 4), ['content', 0, 'content', 0])
})
const writers = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]
function documentOf(attrs: unknown): unknown {
return { content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }
}
test('reads a document once into a copy that shares nothing with the original', () => {
const original = { content: [{ attrs: { a: [1, { b: 'x' }] }, content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const result = readAdfDocument(original)
assert.ok(result.ok)
assert.deepEqual(result.value, original)
assert.notEqual(result.value, original)
original.content[0]?.content.push({ text: 'y', type: 'text' })
assert.equal(result.value.content?.[0]?.content?.length, 1)
})
test('refuses a value holding an accessor property, whichever object or array holds it', () => {
const refused = 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter'
assert.equal(readFault({ get content() { return [] }, type: 'doc', version: 1 }), refused)
assert.equal(readFault({ content: [{ get type() { return 'rule' } }], type: 'doc', version: 1 }), refused)
assert.equal(readFault(documentOf({ set a(value: number) { void value } })), refused)
assert.equal(readFault(documentOf({ a: Object.defineProperty([], 0, { enumerable: true, get: () => 1 }) })), refused)
})
test('refuses a getter minting a fresh child per access without walking it', () => {
const endless = (): unknown => ({ get next() { return endless() } })
assert.equal(readFault(documentOf(endless())), 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter')
})
test('refuses a Proxy that throws, and a revoked one, wherever they stand', () => {
const unreadable = 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw'
const throwing = (target: object, trap: string): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } })
for (const trap of ['getOwnPropertyDescriptor', 'getPrototypeOf', 'ownKeys']) {
assert.equal(readFault(throwing({ type: 'doc', version: 1 }, trap)), unreadable, trap)
assert.equal(readFault(documentOf(throwing({ b: 1 }, trap))), unreadable, trap)
}
assert.equal(readFault({ content: throwing([1], 'getOwnPropertyDescriptor'), type: 'doc', version: 1 }), unreadable)
assert.equal(readFault({ content: throwing([], 'get'), type: 'doc', version: 1 }), unreadable)
const revoked = Proxy.revocable({}, {})
revoked.revoke()
assert.equal(readFault(revoked.proxy), unreadable)
assert.equal(readFault(documentOf(revoked.proxy)), unreadable)
})
test('copies own enumerable string keys alone, as JSON.stringify does', () => {
const keyed = Object.defineProperty({ own: 2, [Symbol('symbol')]: 4 }, 'hidden', { enumerable: false, value: 3 })
const result = readAdfDocument(documentOf(keyed))
assert.ok(result.ok)
assert.deepEqual(result.value.content?.[0]?.attrs, { own: 2 })
assert.equal(readable({ content: [Object.create({ type: 'rule' })], type: 'doc', version: 1 }), false)
const proxied = new Proxy({ b: 1 }, { getOwnPropertyDescriptor: () => undefined })
const dropped = readAdfDocument(documentOf(proxied))
assert.ok(dropped.ok)
assert.deepEqual(dropped.value.content?.[0]?.attrs, {})
})
test('copies a key named __proto__ as a key, leaving the copy a plain object', () => {
const result = readAdfDocument(documentOf(JSON.parse('{"__proto__":{"polluted":1}}')))
assert.ok(result.ok)
const attrs = result.value.content?.[0]?.attrs
assert.deepEqual(Object.keys(attrs ?? {}), ['__proto__'])
assert.equal(Object.getPrototypeOf(attrs), Object.prototype)
assert.equal(Reflect.get(attrs ?? {}, 'polluted'), undefined)
})
test('reads an array by index, so no iterator runs and a hole is no JSON value', () => {
const iterated = Object.assign([1, 2], { [Symbol.iterator]: () => { throw new Error('iterated') } })
const result = readAdfDocument(documentOf({ a: iterated }))
assert.ok(result.ok)
assert.deepEqual(result.value.content?.[0]?.attrs, { a: [1, 2] })
assert.equal(readable(documentOf({ a: [1, , 3] })), false)
})
test('refuses an array claiming more elements than the values left, before it reads any', () => {
const claimed = `unsupported-node-shape: an ADF document holds at most ${mostValues} JSON values: this one holds more`
const claiming = (length: unknown): object => new Proxy([], { get: (target, key) => (key === 'length' ? length : Reflect.get(target, key)) })
assert.equal(readFault({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), claimed)
assert.equal(readFault({ content: new Array(mostValues - 1), type: 'doc', version: 1 }), claimed)
assert.equal(readFault({ content: claiming('many'), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw')
assert.equal(readFault({ content: claiming(-1), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw')
})
test('returns a result for a hostile document from every writer', () => {
const throwing = new Proxy({ type: 'rule' }, { ownKeys: () => { throw new Error('trap') } })
const hostile: AdfDocument[] = [{ get content() { return [] }, type: 'doc', version: 1 }, { content: [throwing], type: 'doc', version: 1 }]
for (const write of writers) {
for (const document of hostile) {
const result = write(document)
assert.ok(!result.ok)
assert.equal(result.error.code, 'not-an-adf-document')
}
}
})
+27 -36
View File
@@ -1,8 +1,9 @@
import { blockNodeModel } from './block-nodes.ts'
import { failure, faulted, success, type ConvertError, type ConvertFault, type Result } from '../result.ts'
import { failure, faulted, success, type ConvertError, type ConvertFault, type Read, type Result } from '../result.ts'
import { inlineNodeModel } from './inline-nodes.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { plainCopy } from './plain-copy.ts'
import { withinLongest } from '../length-bound.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
@@ -34,21 +35,34 @@ const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
export function adfDocumentFault(value: unknown): ConvertFault | undefined {
const shaped = shapedDocument(value)
return shaped.fault ?? nestingFault(shaped.value.content ?? [])
}
// Reads `value` once into a plain copy and checks the copy's shape. Depth is the walks' business, not the shape's: a deep document reads as blocks and marks do.
export function readAdfDocument(value: unknown): Result<AdfDocument> {
const copy = plainCopy(value)
if (copy.fault !== undefined) return faulted(copy.fault, [])
const shaped = shapedDocument(copy.value)
return shaped.fault === undefined ? success(shaped.value) : faulted(shaped.fault, [])
}
function shapedDocument(value: unknown): Read<AdfDocument> {
if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`)
const extra = extraKey(value, documentKeys)
if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`)
if (!('type' in value)) return notADocument('an ADF document holds type "doc": found no type field')
if (value['type'] !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(value['type'])}`)
const type: unknown = value['type']
if (type !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(type)}`)
if (!('version' in value)) return notADocument('an ADF document holds a version number: found no version field')
const version = value['version']
const version: unknown = value['version']
if (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`)
if (!('content' in value)) return undefined
const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
const content: readonly unknown[] = held
if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself')
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content)
if (!('content' in value)) return { value: { type, version } }
const content: unknown = value['content']
if (!Array.isArray(content)) return notADocument(`an ADF document's content is an array: found ${describe(content)}`)
const nodes: readonly unknown[] = content
if (!isNodeArray(nodes)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return { value: { content: nodes, type, version } }
}
// The checks every writer opens with; `undefined` where the document holds no `content` key, which each format spells its own way.
@@ -126,12 +140,6 @@ export function mergedTextRuns(nodes: readonly AdfNode[], joins: (previous: AdfN
return merged
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument {
const fault = adfDocumentFault(value)
return fault === undefined || fault.code === 'unsupported-nesting-depth'
}
export function isAdfNode(value: unknown): value is AdfNode {
return isNodeArray([value])
}
@@ -184,24 +192,7 @@ function shapeRefusal(node: AdfNode): string | undefined {
return model.contentModel === 'none' && holdsContent ? `a node of type ${node.type} holds no content: this one holds some` : undefined
}
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
function holdsItself(root: object): boolean {
const pending = [{ depth: 0, item: root }]
const ancestors: object[] = []
const onPath = new Set<unknown>()
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
while (ancestors.length > entry.depth) onPath.delete(ancestors.pop())
if (onPath.has(entry.item)) return true
ancestors.push(entry.item)
onPath.add(entry.item)
for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child })
}
return false
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
function isNodeArray(value: readonly unknown[]): value is AdfNode[] {
const pending: unknown[] = [...value]
while (pending.length > 0) {
const node = pending.pop()
@@ -265,8 +256,8 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function notADocument(message: string): ConvertFault {
return { code: 'not-an-adf-document', message }
function notADocument(message: string): { fault: ConvertFault } {
return { fault: { code: 'not-an-adf-document', message } }
}
function describe(value: unknown): string {
+80
View File
@@ -0,0 +1,80 @@
import type { ConvertFault, Read } from '../result.ts'
import { isPlainObject } from '../json-value.ts'
// Each object, array, string, number, boolean and null counts one: far past any real page, and it stops a Proxy claiming unbounded width.
export const mostValues = 10_000_000
// An object that is no plain object is copied with this prototype, so `isJsonValue` refuses the copy as it refuses the original.
const unplain = {}
type Step = { depth: number; key: number | string; source: unknown; target: object }
type Held = { entries: [number | string, unknown][]; kind: 'array' | 'object' | 'unplain' }
const holdsItself: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is a tree: an object in it holds itself' }
const tooMany: ConvertFault = { code: 'unsupported-node-shape', message: `an ADF document holds at most ${mostValues} JSON values: this one holds more` }
const unreadable: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: reading one of its objects threw' }
const withAccessor: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: one of its objects holds a getter or a setter' }
// Every later step reads the copy, never the caller's object: a getter, a Proxy trap or an iterator of theirs runs at most once here, and never an accessor.
export function plainCopy(value: unknown): Read<unknown> {
const root: unknown[] = []
const pending: Step[] = [{ depth: 0, key: 0, source: value, target: root }]
const ancestors: object[] = []
const onPath = new Set<unknown>()
let left = mostValues - 1
for (let step = pending.pop(); step !== undefined; step = pending.pop()) {
const source = step.source
if (typeof source !== 'object' || source === null) {
place(step.target, step.key, source)
continue
}
// A pre-order walk: a step at depth d has the d objects above it as its ancestors, and the stack pops it before its later siblings.
while (ancestors.length > step.depth) onPath.delete(ancestors.pop())
if (onPath.has(source)) return { fault: holdsItself }
ancestors.push(source)
onPath.add(source)
const read = heldBy(source, left)
if (read.fault !== undefined) return read
const { entries, kind } = read.value
left -= entries.length
const copy = kind === 'array' ? [] : kind === 'object' ? {} : Object.create(unplain)
place(step.target, step.key, copy)
for (const [key, held] of entries.reverse()) pending.push({ depth: step.depth + 1, key, source: held, target: copy })
}
return { value: root[0] }
}
// The only reads of the caller's object, so the only `try`: a throw there is the caller's Proxy.
function heldBy(source: object, left: number): Read<Held> {
try {
const entries: [number | string, unknown][] = []
if (Array.isArray(source)) {
const length: unknown = source.length
if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) return { fault: unreadable }
if (length > left) return { fault: tooMany }
for (let index = 0; index < length; index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(source, index)
if (descriptor !== undefined && !('value' in descriptor)) return { fault: withAccessor }
entries.push([index, descriptor?.value])
}
return { value: { entries, kind: 'array' } }
}
const keys = Reflect.ownKeys(source)
if (keys.length > left) return { fault: tooMany }
for (const key of keys) {
if (typeof key !== 'string') continue
const descriptor = Object.getOwnPropertyDescriptor(source, key)
if (descriptor === undefined || descriptor.enumerable !== true) continue
if (!('value' in descriptor)) return { fault: withAccessor }
entries.push([key, descriptor.value])
}
return { value: { entries, kind: isPlainObject(source) ? 'object' : 'unplain' } }
} catch {
return { fault: unreadable }
}
}
function place(target: object, key: number | string, copy: unknown): void {
if (key === '__proto__') Object.defineProperty(target, key, { configurable: true, enumerable: true, value: copy, writable: true })
else Reflect.set(target, key, copy)
}
+14 -13
View File
@@ -4,12 +4,13 @@ import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
import type { AdfDocument } from '../adf/document.ts'
import { adfToHtml } from '../html/emit/adf-to-html.ts'
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { isAdfDocument, isRecord } from '../adf/document.ts'
import { isRecord, readAdfDocument } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts'
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { markVocabularies } from '../adf/mark-attributes.ts'
@@ -25,6 +26,12 @@ const roundTripRoot = join(corpusRoot, 'round-trip')
const roundTripDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
const typesNoFetchedDocumentHolds = ['bodiedSyncBlock', 'decisionItem', 'decisionList', 'extensionFrame', 'fragment', 'multiBodiedExtension', 'syncBlock']
function documentIn(json: unknown, name: string): AdfDocument {
const read = readAdfDocument(json)
assert.ok(read.ok, `${name}.json is not an ADF document`)
return read.value
}
function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
@@ -84,8 +91,7 @@ for (const directory of roundTripDirectories) {
for (const name of names) {
test(`${directory}/${name} emits its markdown byte for byte`, () => {
const parsed: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
const parsed = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name)
const result = adfToLosslessMarkdown(parsed)
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
const expected = readFileSync(join(roundTripRoot, directory, `${name}.md`))
@@ -99,8 +105,7 @@ for (const directory of roundTripDirectories) {
for (const directory of roundTripDirectories) {
for (const name of fixtureNames(directory, '.md')) {
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const expected = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name)
const result = losslessMarkdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
@@ -127,8 +132,7 @@ test('no round-trip fixture repeats the document another holds', () => {
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => {
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const expected = documentIn(JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')), name)
const result = losslessMarkdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(result.value, expected)
@@ -149,8 +153,7 @@ for (const root of [realPayloadsRoot, realWorldRoot]) {
for (const name of names(root, '.json')) {
test(`${kind}/${name} emits markdown that reads back to it`, () => {
const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
const emitted = adfToLosslessMarkdown(payload)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const parsed = losslessMarkdownToAdf(emitted.value)
@@ -159,8 +162,7 @@ for (const root of [realPayloadsRoot, realWorldRoot]) {
})
test(`${kind}/${name} writes HTML that reads back to it`, () => {
const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
const written = adfToHtml(payload)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
assert.deepEqual(htmlToAdf(written.value), { ok: true, value: payload })
@@ -174,8 +176,7 @@ const blockElements: Readonly<Record<string, string>> = { blockquote: '<blockquo
for (const root of [realPayloadsRoot, realWorldRoot]) {
for (const name of names(root, '.json')) {
test(`${basename(root)}/${name} writes each block holding only its id as its element`, () => {
const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
assert.ok(adfToHtml(payload).ok)
for (const block of payload.content ?? []) {
const opening = Object.hasOwn(blockElements, block.type) ? blockElements[block.type] : undefined
+5 -2
View File
@@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
import { firstParagraphForm } from '../first-paragraph.ts'
import { imageElement } from '../image-element.ts'
import { inlineHtml, writableText } from './inline.ts'
import { isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { isUnmarkedSpellableText, nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../../adf/document.ts'
import { languageClass } from '../language-class.ts'
import { largestNesting } from '../../nesting.ts'
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts'
@@ -24,7 +24,10 @@ type Row = { depth: number; kind: 'row'; path: ConvertErrorPath; pieces: Pending
type Pending = Block | Item | Row | string
export function adfToHtml(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeHtml(document))
return withinEngineLimits(() => {
const read = readAdfDocument(document)
return read.ok ? writeHtml(read.value) : read
})
}
// A stack rather than recursion, since each level would cost several frames: a block returns its pieces in document order, and only this loop pushes them.
+2 -2
View File
@@ -1,4 +1,4 @@
// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs.
// Export only the conversions, their types, readAdfDocument and what a guarantee or a persona needs.
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts'
@@ -7,5 +7,5 @@ export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { htmlToAdf } from './html/parse/html-to-adf.ts'
export { isAdfDocument } from './adf/document.ts'
export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
export { readAdfDocument } from './adf/document.ts'
+1 -1
View File
@@ -20,7 +20,7 @@ export function isJsonValue(value: unknown): value is JsonValue {
}
// Any realm's Object.prototype has a null prototype; serializeCanonicalJson writes a Date or Map as {}.
function isPlainObject(value: unknown): value is object {
export function isPlainObject(value: unknown): value is object {
if (typeof value !== 'object' || value === null) return false
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null || (typeof prototype === 'object' && Object.getPrototypeOf(prototype) === null)
+5 -2
View File
@@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { flavourClaims } from '../flavour.ts'
import { holdsNullCharacter, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts'
import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../../adf/document.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { withinEngineLimits, withinLongest } from '../../length-bound.ts'
@@ -48,7 +48,10 @@ const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
const orderedDelimiters: Delimiters = ['.', ')']
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeMarkdown(document, { flavour: 'lossless' }))
return withinEngineLimits(() => {
const read = readAdfDocument(document)
return read.ok ? writeMarkdown(read.value, { flavour: 'lossless' }) : read
})
}
export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Result<string> {
@@ -10,7 +10,7 @@ import { isBlockNodeType, oneLine } from '../../adf/shown-text.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { linkAttrs } from '../../adf/mark-attributes.ts'
import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/document.ts'
import { nodeAttrs, nodeContent, nodeMarks, readAdfDocument, writableContent } from '../../adf/document.ts'
import { portableWriting } from './block-spelling.ts'
import { splitTaskList, taskListItems } from './task-list.ts'
import { taskMarker } from './conventions.ts'
@@ -53,7 +53,8 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
return withinEngineLimits(() => {
const reduced = reduceToPortable(document)
const read = readAdfDocument(document)
const reduced = read.ok ? reduceToPortable(read.value) : read
return reduced.ok ? writeMarkdown(reduced.value, portableWriting) : reduced
})
}
+1 -28
View File
@@ -13,7 +13,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
| 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 |
| 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 |
| 144 | 0.4.0 | | | **Give every container `docker-runner.sh` and `publish.sh` start a memory limit and an equal swap limit, sized from the largest a gate leg measurably needs.** | 1 | 1 | 1 | 1 | 2 | code health by 1 | 8.7 |
@@ -35,7 +34,6 @@
| 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
| 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 |
| 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.8 |
| 77 | 0.4.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.** | 3 | 1 | 3 | 5 | 4 | | 0.0 |
| 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 |
| 170 | 0.4.0 | | owner | **Bundle the packed tarball with webpack, esbuild, Rollup and Bun in the gate, failing on any bundler warning, and run each bundle's round-trip in Node.** | 3 | 1 | 1 | 3 | 5 | | 0.0 |
| 42 | 0.5.0 | | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 1 | 2 | 2 | 2 | 9 by 3 | 26.2 |
@@ -70,13 +68,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded).
`src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own
files, with its `ci.sh` probe, extends the decision.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the
boolean guard is the one export that does not, and it cannot say which branch refused, where
`not-an-adf-document`'s message already does. Breaking: `MIGRATION.md` shows the guard's
replacement.
### 127. Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.
Technical principle "Assert the outcome", which bans scanning the source with `ast` or a regex.
@@ -152,7 +143,7 @@ the short row and the line holding no `|` for `losslessMarkdownToAdf` alone. The
### 80. Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.
Lands with items 45 and 77, whose reader walks every node. Today every refused node, such as one
Rides on `readAdfDocument`, whose copy walks every node. Today every refused node, such as one
with a `Date` in an attribute, reads `an ADF document's content holds ADF nodes: one of them is not`
with an empty `path`. An export tool told this about one document in thousands then searches that
document's whole tree. Give the node's path, and name its type and the attribute key as
@@ -287,24 +278,6 @@ first failing reader stops the test and the failure never names the reader. Writ
"No loops or branches in a test body; parametrize the cases". Found by the principles audit of the
release review of 0.3.0, 2026-11-08.
### 77. 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.
Lands with item 45, in one chunk: item 45's reader returns the copy. The maintainer's call,
2026-10-04: Goal 1 promises a result for any value, getters and Proxies included. Today the guards
and the conversions read the caller's object many times, so a throwing getter, a revoked Proxy or a
getter minting a fresh child per access throws or walks forever. Item 45's reader copies the
document once inside one tight `try/catch`, reading property descriptors so no getter runs: an
accessor property, or a Proxy trap that throws, is `not-an-adf-document`. The copy keeps own
enumerable keys, as `JSON.stringify` does, so an inherited or non-enumerable key drops. The reader
reads arrays by index, so no custom iterator runs. Every later step works on the copy. A Proxy can
still claim unbounded width, which the 500-level limit does not cover, so the copy reads at most 10
million JSON values, each object, array, string, number, boolean and null counting one (the
maintainer, 2026-10-05): roughly 50 to 100 MB of JSON, far past any real page, where 10 000 would
refuse a 500-row table. Before reading an array, the reader checks its `length` against the values
left, so a Proxy claiming 2³² elements is refused at once. The chunk asks whether that refusal
shares the 500-level guard's `unsupported-nesting-depth` or takes another code. Found by the
technical-principles audit, 2026-10-04.
### 107. Spell the block carry's JSON so its length stays linear in the node's JSON.
The block carry indents two spaces a level, so about 1 MB of deeply nested arrays in a carried