4.1: editor-normal and the node accessors #69

Merged
lilleman merged 5 commits from editor-normal into main 2026-09-14 20:22:59 +02:00
23 changed files with 349 additions and 102 deletions
+2 -2
View File
@@ -23,8 +23,8 @@ backticks read back as a fence — so a parse succeeding does not imply a spella
`corpus/commonmark-spec/exceptions.json` names those. `corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks "Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
merged, JSON number semantics, an empty attrs object, marks array or content array the absent and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
key — the only domain markdown can restore. array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose. Round-trip equality is a property tested over a corpus, not a claim made in prose.
+3 -2
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { extname, join } from 'node:path' import { extname, join } from 'node:path'
import { toEditorNormal } from '../dist/adf/editor-normal.js'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
const driver = 'http://127.0.0.1:4444' const driver = 'http://127.0.0.1:4444'
@@ -105,7 +106,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown) assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(result.parsed.value, JSON.parse(json)) assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
}) })
} }
@@ -113,7 +114,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index] const result = results.normalization[index]
checking(name, result, () => { checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json'))) assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json')))
}) })
} }
@@ -0,0 +1,57 @@
{
"content": [
{
"content": [
{
"attrs": {
"localId": "01a0a067-68cf-78af-abd6-c660ec0d189b"
},
"text": "Owner",
"type": "text"
},
{
"text": " signs off.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Signed by ",
"type": "text"
},
{
"attrs": {
"localId": "01a0a067-68d2-787e-afc5-2459776aa029"
},
"text": "the owner",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"localId": "01a0a067-68d5-7372-9962-29a039654056"
},
"text": "One ",
"type": "text"
},
{
"attrs": {
"localId": "01a0a067-68d5-7372-9962-29a039654056"
},
"text": "anchor",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,5 @@
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68cf-78af-abd6-c660ec0d189b\"},\"text\":\"Owner\",\"type\":\"text\"}"} signs off.
Signed by :adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d2-787e-afc5-2459776aa029\"},\"text\":\"the owner\",\"type\":\"text\"}"}
:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"One \",\"type\":\"text\"}"}:adf{json="{\"attrs\":{\"localId\":\"01a0a067-68d5-7372-9962-29a039654056\"},\"text\":\"anchor\",\"type\":\"text\"}"}
+8 -8
View File
@@ -404,10 +404,10 @@ Right.
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first — parsing to anything but one unmarked text node — adjacent text nodes with identical marks and no
is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach attributes merged first — is a named error, and so is a `text` key in `{attrs}`. An enclosing mark
into the slot. The rest take no content, `:text` included; content on a node that takes none is a spelling does not reach into the slot. The rest take no content, `:text` included; content on a
named error. node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -434,10 +434,10 @@ CommonMark strips or refuses one — a block's inline content edges, either side
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
`:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar `:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
(AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and tabs, or one attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — is a named tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
error. is a named error.
``` ```
:text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines. :text{text=" "}Two leading spaces held, and one text node split:text{text="\n"}over two lines.
+21 -9
View File
@@ -52,8 +52,8 @@ export function attributeNestingMessage(key: string, type: string, levels: numbe
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false if (nodeMarks(node).length > 0 || node.text !== undefined) return false
return holdsOnly(node.attrs ?? {}, attributes) return holdsOnly(nodeAttrs(node), attributes)
} }
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
@@ -72,6 +72,18 @@ export function isAdfMark(value: unknown): value is AdfMark {
return !('attrs' in value) || isAttributes(value['attrs']) return !('attrs' in value) || isAttributes(value['attrs'])
} }
export function nodeAttrs(node: { attrs?: AdfAttributes }): Readonly<AdfAttributes> {
return node.attrs ?? {}
}
export function nodeContent(node: { content?: AdfNode[] }): readonly AdfNode[] {
return node.content ?? []
}
export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? []
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { 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) {
@@ -95,23 +107,23 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
while (pending.length > 0) { while (pending.length > 0) {
const node = pending.pop() const node = pending.pop()
if (node === undefined) continue if (node === undefined) continue
const fault = attributesFault(node.attrs, node.type) ?? marksFault(node.marks) const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node))
if (fault !== undefined) return fault if (fault !== undefined) return fault
pending.push(...(node.content ?? [])) pending.push(...nodeContent(node))
} }
return undefined return undefined
} }
function marksFault(marks: readonly AdfMark[] | undefined): ConvertFault | undefined { function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks ?? []) { for (const mark of marks) {
const fault = attributesFault(mark.attrs, mark.type, markAttributeNesting) const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
if (fault !== undefined) return fault if (fault !== undefined) return fault
} }
return undefined return undefined
} }
function attributesFault(attrs: AdfAttributes | undefined, type: string, levels: number = largestNesting): ConvertFault | undefined { function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs ?? {})) { for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) } if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
} }
return undefined return undefined
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => {
const content: AdfNode[] = [
{ text: 'a', type: 'text' },
{ text: 'b', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'c', type: 'text' },
{ marks: [{ attrs: {}, type: 'strong' }], text: 'd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'g', type: 'text' },
{ attrs: {}, text: 'h', type: 'text' },
]
assert.deepEqual(toEditorNormal({ content: [{ attrs: { panelType: 'info' }, content: [{ content, type: 'paragraph' }], type: 'panel' }], type: 'doc', version: 1 }), {
content: [
{
attrs: { panelType: 'info' },
content: [
{
content: [
{ text: 'ab', type: 'text' },
{ marks: [{ type: 'strong' }], text: 'cd', type: 'text' },
{ type: 'hardBreak' },
{ text: 'e', type: 'text' },
{ attrs: { localId: '01a0a06b-5281-7f27-9022-8d3a74b0ab0d' }, text: 'f', type: 'text' },
{ text: 'gh', type: 'text' },
],
type: 'paragraph',
},
],
type: 'panel',
},
],
type: 'doc',
version: 1,
})
})
test('reads negative zero as zero, as JSON does', () => {
const marks = [{ attrs: { size: -0 }, type: 'border' }]
assert.deepEqual(
toEditorNormal({ content: [{ attrs: { a: -0, b: [{ c: -0 }, null, 'd'] }, content: [{ marks, text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: -0 }),
{ content: [{ attrs: { a: 0, b: [{ c: 0 }, null, 'd'] }, content: [{ marks: [{ attrs: { size: 0 }, type: 'border' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 0 },
)
})
test('reads an empty attrs object, marks array or content array as the absent key', () => {
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
type: 'doc',
version: 1,
})
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
})
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
const levels = 100000
let node: AdfNode = { content: [], type: 'paragraph' }
for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' }
let normal = toEditorNormal({ content: [node], type: 'doc', version: 1 }).content?.[0]
let depth = 0
for (; normal?.content !== undefined; depth += 1) normal = normal.content[0]
assert.equal(depth, levels)
assert.deepEqual(normal, { type: 'paragraph' })
let deep: JsonValue = 1
for (let level = 0; level < 2 * levels; level += 1) deep = [deep]
const marks = [{ attrs: { deep }, type: 'textColor' }]
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
})
+63 -5
View File
@@ -1,16 +1,21 @@
import type { AdfMark, AdfNode } from './document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean { export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark) return markKey(candidate) === markKey(mark)
} }
// AGENTS.md §2: adjacent text nodes carrying identical marks are one node.
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = [] const merged: AdfNode[] = []
for (const node of nodes) { for (const node of nodes) {
const previous = merged[merged.length - 1] const previous = merged[merged.length - 1]
if (previous !== undefined && previous.type === 'text' && node.type === 'text' && sameMarks(previous, node)) { if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue continue
} }
@@ -19,8 +24,61 @@ export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
return merged return merged
} }
export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source))
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
pending.push({ holder, source })
return holder
})
}
return normal
}
function normalNode(node: AdfNode): AdfNode {
const normal: AdfNode = { type: node.type }
const attrs = normalAttributes(nodeAttrs(node))
if (attrs !== undefined) normal.attrs = attrs
const marks = nodeMarks(node).map(normalMark)
if (marks.length > 0) normal.marks = marks
if (node.text !== undefined) normal.text = node.text
return normal
}
function normalMark(mark: AdfMark): AdfMark {
const attrs = normalAttributes(nodeAttrs(mark))
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
}
function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
if (Object.keys(attrs).length === 0) return undefined
const normal = { ...attrs }
const pending: JsonContainer[] = [normal]
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
}
return normal
}
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
if (Object.is(value, -0)) return 0
if (value === null || typeof value !== 'object') return value
const copy = Array.isArray(value) ? [...value] : { ...value }
pending.push(copy)
return copy
}
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
function sameMarks(previous: AdfNode, node: AdfNode): boolean { function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(previous.marks ?? []) === marksKey(node.marks ?? []) return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
} }
function marksKey(marks: readonly AdfMark[]): string { function marksKey(marks: readonly AdfMark[]): string {
@@ -28,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string {
} }
function markKey(mark: AdfMark): string { function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(mark.attrs ?? {}, 'compact')}` return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}`
} }
+13
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { JsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
test('sorts object keys recursively', () => { test('sorts object keys recursively', () => {
@@ -39,3 +40,15 @@ test('leaves non-ASCII raw', () => {
test('spells scalars in canonical JSON', () => { test('spells scalars in canonical JSON', () => {
assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]') assert.equal(serializeCanonicalJson([null, true, false, 0, -1.5, 'a"b'], 'compact'), '[null,true,false,0,-1.5,"a\\"b"]')
}) })
test('spells a value nesting far past the levels a recursive walk survives', () => {
const levels = 200000
let array: JsonValue = 1
let object: JsonValue = 1
for (let level = 0; level < levels; level += 1) {
array = [array]
object = { a: object }
}
assert.equal(serializeCanonicalJson(array, 'compact'), `${'['.repeat(levels)}1${']'.repeat(levels)}`)
assert.equal(serializeCanonicalJson(object, 'compact'), `${'{"a":'.repeat(levels)}1${'}'.repeat(levels)}`)
})
+32 -18
View File
@@ -2,28 +2,42 @@ import type { JsonValue } from './json-value.ts'
export type JsonSpelling = 'compact' | 'two-space' export type JsonSpelling = 'compact' | 'two-space'
type Member = { label: string; value: JsonValue }
type Pending = string | { depth: number; value: JsonValue }
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
return serialize(value, spelling === 'compact' ? '' : ' ', 0) const indent = spelling === 'compact' ? '' : ' '
const text: string[] = []
const pending: Pending[] = [{ depth: 0, value }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === 'string') {
text.push(next)
continue
}
const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held))
}
return text.join('')
} }
function serialize(value: JsonValue, indent: string, depth: number): string { function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] {
if (Array.isArray(value)) {
if (value.length === 0) return '[]'
const items = value.map((item) => serialize(item, indent, depth + 1))
return `[${join(items, indent, depth)}]`
}
if (value !== null && typeof value === 'object') {
const keys = Object.keys(value).sort()
if (keys.length === 0) return '{}'
const separator = indent === '' ? ':' : ': ' const separator = indent === '' ? ':' : ': '
const entries = keys.map((key) => `${JSON.stringify(key)}${separator}${serialize(value[key] ?? null, indent, depth + 1)}`) return Object.keys(value)
return `{${join(entries, indent, depth)}}` .sort()
} .map((key) => ({ label: `${JSON.stringify(key)}${separator}`, value: value[key] ?? null }))
return JSON.stringify(value)
} }
function join(parts: readonly string[], indent: string, depth: number): string { function schedule(pending: Pending[], open: string, members: readonly Member[], close: string, indent: string, depth: number): void {
if (indent === '') return parts.join(',') if (members.length === 0) {
const inner = `\n${indent.repeat(depth + 1)}` pending.push(`${open}${close}`)
return `${inner}${parts.join(`,${inner}`)}\n${indent.repeat(depth)}` return
}
const inner = indent === '' ? '' : `\n${indent.repeat(depth + 1)}`
const scheduled: Pending[] = []
for (const [index, member] of members.entries()) scheduled.push(`${index === 0 ? open : ','}${inner}${member.label}`, { depth: depth + 1, value: member.value })
scheduled.push(indent === '' ? close : `\n${indent.repeat(depth)}${close}`)
for (const item of scheduled.reverse()) pending.push(item)
} }
+4 -3
View File
@@ -9,6 +9,7 @@ import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from './json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { toEditorNormal } from './adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors') const errorsRoot = join(corpusRoot, 'errors')
@@ -95,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) const result = markdownToAdf(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(toEditorNormal(result.value), expected)
}) })
} }
} }
@@ -184,12 +185,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) const result = markdownToAdf(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(toEditorNormal(result.value), expected)
const emitted = adfToMarkdown(result.value) const emitted = adfToMarkdown(result.value)
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 again = markdownToAdf(emitted.value) const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(again.value, expected) assert.deepEqual(toEditorNormal(again.value), expected)
}) })
} }
+2 -2
View File
@@ -1,13 +1,13 @@
import type { AdfMark } from '../adf/document.ts' import type { AdfMark } from '../adf/document.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { isAdfMark } from '../adf/document.ts' import { isAdfMark, nodeAttrs } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export const marksAttribute = 'marks' export const marksAttribute = 'marks'
export function markValues(marks: readonly AdfMark[]): JsonValue { export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => { return marks.map((mark) => {
const attrs = mark.attrs ?? {} const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
}) })
} }
+4 -1
View File
@@ -6,6 +6,7 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts' import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
@@ -353,7 +354,9 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
const roundTrips = (node: AdfNode): void => { const roundTrips = (node: AdfNode): void => {
const spelled = adfToMarkdown(document(node)) const spelled = adfToMarkdown(document(node))
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
assert.deepEqual(markdownToAdf(spelled.value), { ok: true, value: document(node) }) const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node))
} }
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3)) assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
+19 -19
View File
@@ -1,6 +1,6 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import { adfDocumentFault, carriesOnly } from '../../adf/document.ts' import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
@@ -26,7 +26,7 @@ export function adfToMarkdown(document: AdfDocument): Result<string> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], 'document', [], 0) const blocks = emitBlocks(nodeContent(document), 'document', [], 0)
if (!blocks.ok) return blocks if (!blocks.ok) return blocks
return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`) return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
} }
@@ -63,8 +63,8 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
} }
function interruptsParagraph(node: AdfNode): boolean { function interruptsParagraph(node: AdfNode): boolean {
const items = node.content ?? [] const items = nodeContent(node)
const empty = (items[0]?.content ?? []).length === 0 const empty = items[0] === undefined || nodeContent(items[0]).length === 0
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty) if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty) return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
} }
@@ -110,7 +110,7 @@ function commonMarkText(text: string): EmittedBlock {
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
const content = node.content ?? [] const content = nodeContent(node)
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
const header = spellDirectiveHeader(node, directive) const header = spellDirectiveHeader(node, directive)
@@ -134,7 +134,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!carriesOnly(node, [])) return undefined
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) const inner = emitBlocks(nodeContent(node), 'document', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = inner.value.text const text = inner.value.text
.split('\n') .split('\n')
@@ -145,7 +145,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(node.attrs?.['language']) const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text if (!text.ok) return text
@@ -153,7 +153,7 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
} }
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(node.attrs?.['language']) const slot = languageSlot(nodeAttrs(node)['language'])
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language']) const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (header === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
@@ -164,15 +164,15 @@ function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: Conve
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = '' let text = ''
for (const [index, child] of (node.content ?? []).entries()) { for (const [index, child] of nodeContent(node).entries()) {
const childPath = [...path, 'content', index] const childPath = [...path, 'content', index]
if ( if (
child.type !== 'text' || child.type !== 'text' ||
typeof child.text !== 'string' || typeof child.text !== 'string' ||
child.text === '' || child.text === '' ||
(child.content ?? []).length > 0 || nodeContent(child).length > 0 ||
(child.marks ?? []).length > 0 || nodeMarks(child).length > 0 ||
Object.keys(child.attrs ?? {}).length > 0 Object.keys(nodeAttrs(child)).length > 0
) { ) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath) return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
} }
@@ -185,10 +185,10 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined if (!carriesOnly(node, ['level'])) return undefined
const level = node.attrs?.['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
const content = node.content ?? [] const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes)) if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path) const line = emitInlineLine(content, 'heading', path)
if (!line.ok) return line if (!line.ok) return line
@@ -198,7 +198,7 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = node.content ?? [] const items = nodeContent(node)
const start = listStart(node, items.length) const start = listStart(node, items.length)
if (start === undefined || items.length === 0) return undefined if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
@@ -216,13 +216,13 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
function listStart(node: AdfNode, items: number): number | undefined { function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0 if (node.type !== 'orderedList') return 0
const start = node.attrs?.['order'] const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined { function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1) const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
const indent = ' '.repeat(marker.length) const indent = ' '.repeat(marker.length)
@@ -232,7 +232,7 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const content = node.content ?? [] const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line if (!line.ok) return line
@@ -240,6 +240,6 @@ function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
} }
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined { function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
return success(commonMarkText('---')) return success(commonMarkText('---'))
} }
@@ -3,6 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts' import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
@@ -10,17 +11,17 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s
const argument = spellArgument(node, argumentAttribute) const argument = spellArgument(node, argumentAttribute)
if (argument === undefined) return undefined if (argument === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = node.marks ?? [] const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs) const attributes = spellAttributes(spelledPairs)
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
} }
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute] const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
if (value === undefined) return '' if (value === undefined) return ''
if (typeof value !== 'string' || !isBareToken(value)) return undefined if (typeof value !== 'string' || !isBareToken(value)) return undefined
return ` ${value}` return ` ${value}`
+5 -5
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from '../../adf/document.ts' import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { ConvertErrorPath } from '../../result.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts' import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts' import { tryImageLine } from './inline-line.ts'
@@ -14,11 +14,11 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
} }
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = node.content ?? [] const content = nodeContent(node)
const media = content[0] const media = content[0]
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = media.attrs ?? {} const attrs = nodeAttrs(media)
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
@@ -1,9 +1,10 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { InlineDirective } from '../../adf/inline-directives.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute]) const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
} }
+12 -11
View File
@@ -9,6 +9,7 @@ import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts'
import { spellDestination, spellTitle } from '../link-syntax.ts' import { spellDestination, spellTitle } from '../link-syntax.ts'
@@ -127,7 +128,7 @@ function syntax(text: string): InlineSegment {
} }
function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> { function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result<null> {
const holdsContent = (node.content ?? []).length > 0 const holdsContent = nodeContent(node).length > 0
if (holdsContent || node.text !== undefined) { if (holdsContent || node.text !== undefined) {
const held = holdsContent ? 'content' : 'text' const held = holdsContent ? 'content' : 'text'
return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path) return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, path)
@@ -156,7 +157,7 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
for (const [offset, node] of nodes.entries()) { for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset const index = firstIndex + offset
// spec/flavour.md, Marks. // spec/flavour.md, Marks.
const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth] const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
if (mark === undefined) { if (mark === undefined) {
runs.push({ index, kind: 'plain', node }) runs.push({ index, kind: 'plain', node })
continue continue
@@ -184,7 +185,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
if (!carried.ok) return carried if (!carried.ok) return carried
return success({ segments: [syntax(carried.value)] }) return success({ segments: [syntax(carried.value)] })
} }
const types = (node.marks ?? []).map((mark) => mark.type) const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const directive = inlineDirective(node.type) const directive = inlineDirective(node.type)
if (directive === undefined) return emitText(node, context, index, path) if (directive === undefined) return emitText(node, context, index, path)
@@ -206,7 +207,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
if (!empty.ok) return empty if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute] const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot) const spans = slotLineEndingFault(node.type, slot)
@@ -217,9 +218,9 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
} }
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> { function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } }) if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path) if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path) if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash' const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
@@ -260,9 +261,9 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = '' let text = ''
for (const node of nodes) { for (const node of nodes) {
if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range }) if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text text += node.text
} }
if (/[\n\r]/.test(text)) return success({ carry: range }) if (/[\n\r]/.test(text)) return success({ carry: range })
@@ -278,11 +279,11 @@ function needsPadding(text: string): boolean {
} }
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> { function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
const href = mark.attrs?.['href'] const href = nodeAttrs(mark)['href']
const title = mark.attrs?.['title'] const title = nodeAttrs(mark)['title']
if (typeof href !== 'string') return success({ carry: range }) if (typeof href !== 'string') return success({ carry: range })
const node = nodes[0] const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1 const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const destination = spellDestination(href, path) const destination = spellDestination(href, path)
if (!destination.ok) return destination if (!destination.ok) return destination
+6 -6
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from '../../adf/document.ts' import { carriesOnly, nodeContent } from '../../adf/document.ts'
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts' import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.ts' import { tryPipeCell } from './inline-line.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { ConvertErrorPath } from '../../result.ts'
@@ -11,7 +11,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
for (const [rowIndex, row] of rows.entries()) { for (const [rowIndex, row] of rows.entries()) {
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { for (const [cellIndex, paragraph] of row.entries()) {
const content = paragraph.content ?? [] const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (line === undefined) return undefined if (line === undefined) return undefined
cells.push(line) cells.push(line)
@@ -23,12 +23,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
} }
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? [] const rows = nodeContent(node)
const columns = (rows[0]?.content ?? []).length const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
if (!carriesOnly(node, []) || columns === 0) return undefined if (!carriesOnly(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const cells = row.content ?? [] const cells = nodeContent(row)
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell' const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
@@ -43,7 +43,7 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
} }
function plainParagraph(cell: AdfNode): AdfNode | undefined { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = cell.content ?? [] const content = nodeContent(cell)
const paragraph = content[0] const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
return paragraph return paragraph
+2 -1
View File
@@ -2,6 +2,7 @@ import type { AdfMark } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts' import type { MarkType } from '../adf/mark-attributes.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs } from '../adf/document.ts'
import { spellAttributes, spellVocabulary } from './directive-syntax.ts' import { spellAttributes, spellVocabulary } from './directive-syntax.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
@@ -30,6 +31,6 @@ export function markSpelling(type: string): MarkSpelling | undefined {
} }
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, []) const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import type { BlockDirective } from '../../adf/block-directives.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts' import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage } from '../../adf/document.ts' import { attributeNestingMessage, nodeMarks } from '../../adf/document.ts'
import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { attributeValue, directiveLineEscape, inlineDirectiveEscape, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument } from '../block-directive-arguments.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
@@ -89,7 +89,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
function slotText(content: readonly AdfNode[]): string | undefined { function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return '' if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined const only = content.length === 1 ? content[0] : undefined
if (only?.type !== 'text' || (only.marks ?? []).length > 0 || typeof only.text !== 'string') return undefined if (only?.type !== 'text' || nodeMarks(only).length > 0 || typeof only.text !== 'string') return undefined
return only.text return only.text
} }
+3 -2
View File
@@ -8,6 +8,7 @@ import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.t
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readCarriedInline } from '../opaque-carry.ts' import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts' import { readDirectiveMark } from './directive-marks.ts'
@@ -337,7 +338,7 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
function altText(node: AdfNode): string { function altText(node: AdfNode): string {
if (node.type === 'hardBreak') return ' ' if (node.type === 'hardBreak') return ' '
const slot = inlineDirective(node.type)?.textAttribute const slot = inlineDirective(node.type)?.textAttribute
const spelled = slot === undefined ? undefined : node.attrs?.[slot] const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
return typeof spelled === 'string' ? spelled : (node.text ?? '') return typeof spelled === 'string' ? spelled : (node.text ?? '')
} }
@@ -409,7 +410,7 @@ function markType(character: string, used: number): string {
// A node cannot carry one mark type twice (AGENTS.md §14). // A node cannot carry one mark type twice (AGENTS.md §14).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
return nodes.map((node) => { return nodes.map((node) => {
const marks = node.marks ?? [] const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] } return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
}) })
} }
+2 -1
View File
@@ -9,6 +9,7 @@ import { failure, faulted, positioned, success, type ConvertErrorPath, type Pars
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts' import { listBreakName, listBreakSpelling } from '../list-break.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
@@ -106,7 +107,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> { function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path) if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
const attribute = node.attrs?.['language'] const attribute = nodeAttrs(node)['language']
const fromFence = only.language !== '' const fromFence = only.language !== ''
const slot = languageSlot(fromFence ? only.language : attribute) const slot = languageSlot(fromFence ? only.language : attribute)
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {