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:
@@ -73,7 +73,7 @@ In `docs/decisions.md`:
|
|||||||
- HTML writes a URL as the ADF holds it, whatever its scheme
|
- HTML writes a URL as the ADF holds it, whatever its scheme
|
||||||
- Nothing recurses unbounded
|
- Nothing recurses unbounded
|
||||||
- JSON text is read by `parseJsonText`
|
- 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
|
- Nothing spreads an unbounded array
|
||||||
- A retry loop checks its own termination
|
- A retry loop checks its own termination
|
||||||
- After 32 passes, a line carries or drops every em, strike and strong mark
|
- 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
|
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.
|
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
|
- 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.
|
unrepresentable.
|
||||||
- Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
|
- 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
|
tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 0.3.1
|
||||||
|
|
||||||
- The README opens with a one-line description, a list of the calls and the package's perks, and
|
- The README opens with a one-line description, a list of the calls and the package's perks, and
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# Migrating
|
# 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`
|
## From `0.2.0` to `0.3.0`
|
||||||
|
|
||||||
Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf`
|
Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf`
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ sets example documents beside each writer's output and says whether that output
|
|||||||
```ts
|
```ts
|
||||||
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
|
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
|
||||||
losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||||
isAdfDocument(v: unknown): v is AdfDocument
|
readAdfDocument(v: unknown): Result<AdfDocument>
|
||||||
|
|
||||||
commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>
|
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` |
|
| `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` |
|
| `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` |
|
| `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
|
`readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It reads data
|
||||||
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
|
properties and own enumerable keys alone, as `JSON.stringify` does, so an accessor property or a
|
||||||
only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no
|
Proxy that throws is `not-an-adf-document` and never a throw, and the three writers read their
|
||||||
object holding itself. It returns `true` for a document nesting past 500 levels; the converters
|
document the same way. It checks the shape the converters require: `type: 'doc'`, a finite
|
||||||
refuse that one with `unsupported-nesting-depth`.
|
`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:
|
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 |
|
| 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 |
|
| `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
|
### Either direction
|
||||||
@@ -662,6 +665,9 @@ Every call can raise these:
|
|||||||
`path` and message name.
|
`path` and message name.
|
||||||
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
|
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
|
||||||
This is a bug in this library: report it.
|
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**:
|
- **Past the engine's longest string**:
|
||||||
- a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every
|
- a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every
|
||||||
engine;
|
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 |
|
| `adfToPortableMarkdown` | function | ADF to portable markdown, lossy |
|
||||||
| `commonMarkToAdf` | function | strict CommonMark to ADF |
|
| `commonMarkToAdf` | function | strict CommonMark to ADF |
|
||||||
| `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF |
|
| `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF |
|
||||||
| `isAdfDocument` | function | type guard for `AdfDocument` |
|
|
||||||
| `losslessMarkdownToAdf` | function | the lossless flavour to ADF |
|
| `losslessMarkdownToAdf` | function | the lossless flavour to ADF |
|
||||||
| `portableMarkdownToAdf` | function | portable markdown to ADF |
|
| `portableMarkdownToAdf` | function | portable markdown to ADF |
|
||||||
|
| `readAdfDocument` | function | JSON you receive, checked and copied into an `AdfDocument` |
|
||||||
|
|
||||||
## Changing the source
|
## Changing the source
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
try {
|
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.
|
// 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)
|
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.
|
// 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 adf = JSON.parse(json)
|
||||||
const emitted = adfToLosslessMarkdown(adf)
|
const emitted = adfToLosslessMarkdown(adf)
|
||||||
const html = adfToHtml(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 }) => {
|
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||||
const adf = JSON.parse(json)
|
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) {
|
} catch (cause) {
|
||||||
|
|||||||
+13
-5
@@ -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`,
|
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.
|
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
|
`readAdfDocument` and the three writers copy the input once, before any check, reading property
|
||||||
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one. Goal
|
descriptors so no getter runs: an accessor property, or a Proxy trap that throws, is
|
||||||
9's size counts ADF as JSON, so a shared object costs once per place it is held.
|
`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
|
## Nothing spreads an unbounded array
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
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 portableEmitted: Result<string> = adfToPortableMarkdown(document)
|
||||||
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
|
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
|
||||||
const commonMarkParsed: Result<AdfDocument, ParseError> = commonMarkToAdf('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 htmlParsed: Result<AdfDocument, ParseError> = htmlToAdf('<p>x</p>\n')
|
||||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
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
@@ -5,13 +5,23 @@ import vm from 'node:vm'
|
|||||||
import type { AdfDocument, AdfNode } from './document.ts'
|
import type { AdfDocument, AdfNode } from './document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.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 { largestNesting } from '../nesting.ts'
|
||||||
|
import { mostValues } from './plain-copy.ts'
|
||||||
|
|
||||||
function fault(value: unknown): string {
|
function fault(value: unknown): string {
|
||||||
return adfDocumentFault(value)?.message ?? 'accepted'
|
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 {
|
function faultCode(value: unknown): string {
|
||||||
return adfDocumentFault(value)?.code ?? 'accepted'
|
return adfDocumentFault(value)?.code ?? 'accepted'
|
||||||
}
|
}
|
||||||
@@ -27,13 +37,13 @@ function withAttribute(value: JsonValue): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('accepts an editor-normal document', () => {
|
test('accepts an editor-normal document', () => {
|
||||||
assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
assert.equal(readable({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||||
assert.equal(isAdfDocument({ 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')
|
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', () => {
|
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(null), 'an ADF document is an object: found null')
|
||||||
assert.equal(fault(undefined), 'an ADF document is an object: found undefined')
|
assert.equal(fault(undefined), 'an ADF document is an object: found undefined')
|
||||||
assert.equal(fault([]), 'an ADF document is an object: found an array')
|
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', () => {
|
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: '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(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(readable({ content: [{ type: 1 }], type: 'doc', version: 1 }), false)
|
||||||
assert.equal(isAdfDocument({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false)
|
assert.equal(readable({ 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(readable({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||||
assert.equal(isAdfDocument({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
assert.equal(readable({ 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(readable({ 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(readable({ 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: [{ 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', () => {
|
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))), 'accepted')
|
||||||
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
||||||
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
|
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 })
|
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)), 'accepted')
|
||||||
assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
|
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', () => {
|
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(readable({ 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(readable({ 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(readable({ 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(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', () => {
|
test('refuses an attribute value that is an object JSON does not hold', () => {
|
||||||
class Point { x = 1 }
|
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() }]) {
|
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(readable({ 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: [{ 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", () => {
|
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 }
|
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')
|
assert.equal(fault(wide), 'accepted')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses an object holding itself, and accepts one shared', () => {
|
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' }
|
const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
|
||||||
paragraph.content.push(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 }
|
const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 }
|
||||||
document.content.push(document)
|
document.content.push(document)
|
||||||
assert.equal(fault(document), holdsItself)
|
assert.equal(readFault(document), holdsItself)
|
||||||
const attrs: Record<string, unknown> = {}
|
const attrs: Record<string, unknown> = {}
|
||||||
attrs['a'] = [attrs]
|
attrs['a'] = [attrs]
|
||||||
assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
|
assert.equal(readFault({ 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(readFault({ 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(readable({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||||
const bold = { type: 'strong' }
|
const bold = { type: 'strong' }
|
||||||
const text = { marks: [bold], text: 'x', type: 'text' }
|
const text = { marks: [bold], text: 'x', type: 'text' }
|
||||||
const shared = { content: [text, text], type: 'paragraph' }
|
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.equal(!refused.ok && refused.error.path.length, 2 * (depth + 1))
|
||||||
assert.deepEqual(!refused.ok && refused.error.path.slice(0, 4), ['content', 0, 'content', 0])
|
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
@@ -1,8 +1,9 @@
|
|||||||
import { blockNodeModel } from './block-nodes.ts'
|
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 { inlineNodeModel } from './inline-nodes.ts'
|
||||||
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
|
import { plainCopy } from './plain-copy.ts'
|
||||||
import { withinLongest } from '../length-bound.ts'
|
import { withinLongest } from '../length-bound.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
@@ -34,21 +35,34 @@ const markKeys = ['attrs', 'type']
|
|||||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||||
|
|
||||||
export function adfDocumentFault(value: unknown): ConvertFault | undefined {
|
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)}`)
|
if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`)
|
||||||
const extra = extraKey(value, documentKeys)
|
const extra = extraKey(value, documentKeys)
|
||||||
if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`)
|
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 (!('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')
|
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 (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`)
|
||||||
if (!('content' in value)) return undefined
|
if (!('content' in value)) return { value: { type, version } }
|
||||||
const held: unknown = value['content']
|
const content: unknown = value['content']
|
||||||
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
|
if (!Array.isArray(content)) return notADocument(`an ADF document's content is an array: found ${describe(content)}`)
|
||||||
const content: readonly unknown[] = held
|
const nodes: readonly unknown[] = content
|
||||||
if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself')
|
if (!isNodeArray(nodes)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
|
||||||
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
|
return { value: { content: nodes, type, version } }
|
||||||
return nestingFault(content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The checks every writer opens with; `undefined` where the document holds no `content` key, which each format spells its own way.
|
// 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
|
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 {
|
export function isAdfNode(value: unknown): value is AdfNode {
|
||||||
return isNodeArray([value])
|
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
|
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 isNodeArray(value: readonly unknown[]): value is AdfNode[] {
|
||||||
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[] {
|
|
||||||
const pending: unknown[] = [...value]
|
const pending: unknown[] = [...value]
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
const node = pending.pop()
|
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)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function notADocument(message: string): ConvertFault {
|
function notADocument(message: string): { fault: ConvertFault } {
|
||||||
return { code: 'not-an-adf-document', message }
|
return { fault: { code: 'not-an-adf-document', message } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function describe(value: unknown): string {
|
function describe(value: unknown): string {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfDocument } from '../adf/document.ts'
|
||||||
import { adfToHtml } from '../html/emit/adf-to-html.ts'
|
import { adfToHtml } from '../html/emit/adf-to-html.ts'
|
||||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { blockNodes } from '../adf/block-nodes.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
|
import { htmlToAdf } from '../html/parse/html-to-adf.ts'
|
||||||
import { inlineNodes } from '../adf/inline-nodes.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 { isJsonValue } from '../json-value.ts'
|
||||||
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { markVocabularies } from '../adf/mark-attributes.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 roundTripDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
|
||||||
const typesNoFetchedDocumentHolds = ['bodiedSyncBlock', 'decisionItem', 'decisionList', 'extensionFrame', 'fragment', 'multiBodiedExtension', 'syncBlock']
|
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[] {
|
function directoryNames(root: string): string[] {
|
||||||
return readdirSync(root, { withFileTypes: true })
|
return readdirSync(root, { withFileTypes: true })
|
||||||
.filter((entry) => entry.isDirectory())
|
.filter((entry) => entry.isDirectory())
|
||||||
@@ -84,8 +91,7 @@ for (const directory of roundTripDirectories) {
|
|||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
test(`${directory}/${name} emits its markdown byte for byte`, () => {
|
test(`${directory}/${name} emits its markdown byte for byte`, () => {
|
||||||
const parsed: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
|
const parsed = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
|
|
||||||
const result = adfToLosslessMarkdown(parsed)
|
const result = adfToLosslessMarkdown(parsed)
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
const expected = readFileSync(join(roundTripRoot, directory, `${name}.md`))
|
const expected = readFileSync(join(roundTripRoot, directory, `${name}.md`))
|
||||||
@@ -99,8 +105,7 @@ for (const directory of roundTripDirectories) {
|
|||||||
for (const directory of roundTripDirectories) {
|
for (const directory of roundTripDirectories) {
|
||||||
for (const name of fixtureNames(directory, '.md')) {
|
for (const name of fixtureNames(directory, '.md')) {
|
||||||
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
|
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
|
||||||
const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
|
const expected = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
|
||||||
const result = losslessMarkdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
const result = losslessMarkdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(result.value, expected)
|
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')) {
|
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
||||||
test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => {
|
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'))
|
const expected = documentIn(JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
|
||||||
const result = losslessMarkdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
const result = losslessMarkdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(result.value, expected)
|
assert.deepEqual(result.value, expected)
|
||||||
@@ -149,8 +153,7 @@ for (const root of [realPayloadsRoot, realWorldRoot]) {
|
|||||||
|
|
||||||
for (const name of names(root, '.json')) {
|
for (const name of names(root, '.json')) {
|
||||||
test(`${kind}/${name} emits markdown that reads back to it`, () => {
|
test(`${kind}/${name} emits markdown that reads back to it`, () => {
|
||||||
const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8'))
|
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
|
|
||||||
const emitted = adfToLosslessMarkdown(payload)
|
const emitted = adfToLosslessMarkdown(payload)
|
||||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const parsed = losslessMarkdownToAdf(emitted.value)
|
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`, () => {
|
test(`${kind}/${name} writes HTML that reads back to it`, () => {
|
||||||
const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8'))
|
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
|
|
||||||
const written = adfToHtml(payload)
|
const written = adfToHtml(payload)
|
||||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||||
assert.deepEqual(htmlToAdf(written.value), { ok: true, value: payload })
|
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 root of [realPayloadsRoot, realWorldRoot]) {
|
||||||
for (const name of names(root, '.json')) {
|
for (const name of names(root, '.json')) {
|
||||||
test(`${basename(root)}/${name} writes each block holding only its id as its element`, () => {
|
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'))
|
const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name)
|
||||||
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
|
|
||||||
assert.ok(adfToHtml(payload).ok)
|
assert.ok(adfToHtml(payload).ok)
|
||||||
for (const block of payload.content ?? []) {
|
for (const block of payload.content ?? []) {
|
||||||
const opening = Object.hasOwn(blockElements, block.type) ? blockElements[block.type] : undefined
|
const opening = Object.hasOwn(blockElements, block.type) ? blockElements[block.type] : undefined
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
|
|||||||
import { firstParagraphForm } from '../first-paragraph.ts'
|
import { firstParagraphForm } from '../first-paragraph.ts'
|
||||||
import { imageElement } from '../image-element.ts'
|
import { imageElement } from '../image-element.ts'
|
||||||
import { inlineHtml, writableText } from './inline.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 { languageClass } from '../language-class.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.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
|
type Pending = Block | Item | Row | string
|
||||||
|
|
||||||
export function adfToHtml(document: AdfDocument): Result<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.
|
// 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
@@ -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 { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||||
export type { JsonValue } from './json-value.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 { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
|
||||||
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
export { htmlToAdf } from './html/parse/html-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 { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
|
||||||
|
export { readAdfDocument } from './adf/document.ts'
|
||||||
|
|||||||
+1
-1
@@ -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 {}.
|
// 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
|
if (typeof value !== 'object' || value === null) return false
|
||||||
const prototype: unknown = Object.getPrototypeOf(value)
|
const prototype: unknown = Object.getPrototypeOf(value)
|
||||||
return prototype === null || (typeof prototype === 'object' && Object.getPrototypeOf(prototype) === null)
|
return prototype === null || (typeof prototype === 'object' && Object.getPrototypeOf(prototype) === null)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
|
|||||||
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
||||||
import { flavourClaims } from '../flavour.ts'
|
import { flavourClaims } from '../flavour.ts'
|
||||||
import { holdsNullCharacter, markerInterruptsParagraph } from '../commonmark/grammar.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 { languageSlot, type LanguageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { withinEngineLimits, withinLongest } from '../../length-bound.ts'
|
import { withinEngineLimits, withinLongest } from '../../length-bound.ts'
|
||||||
@@ -48,7 +48,10 @@ const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
|||||||
const orderedDelimiters: Delimiters = ['.', ')']
|
const orderedDelimiters: Delimiters = ['.', ')']
|
||||||
|
|
||||||
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
|
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> {
|
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 { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { linkAttrs } from '../../adf/mark-attributes.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 { portableWriting } from './block-spelling.ts'
|
||||||
import { splitTaskList, taskListItems } from './task-list.ts'
|
import { splitTaskList, taskListItems } from './task-list.ts'
|
||||||
import { taskMarker } from './conventions.ts'
|
import { taskMarker } from './conventions.ts'
|
||||||
@@ -53,7 +53,8 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
|||||||
|
|
||||||
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
|
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
|
||||||
return withinEngineLimits(() => {
|
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
|
return reduced.ok ? writeMarkdown(reduced.value, portableWriting) : reduced
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
`src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own
|
||||||
files, with its `ci.sh` probe, extends the decision.
|
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.
|
### 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.
|
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.
|
### 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 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
|
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
|
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
|
"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.
|
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.
|
### 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
|
The block carry indents two spaces a level, so about 1 MB of deeply nested arrays in a carried
|
||||||
|
|||||||
Reference in New Issue
Block a user