Compare commits

..

12 Commits

Author SHA1 Message Date
lilleman b9760d0af2 Name plainMarkdownToAdf in the empty-markdown changelog entry
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 14:46:29 +02:00
lilleman d7ba8e6aae Keep format knowledge out of mergeAdjacentText's comment 2026-10-03 14:46:29 +02:00
lilleman bba2fa409e Drop empty keys in the property harness through emptyKeys 2026-10-03 14:46:29 +02:00
lilleman c3bc2eb572 Read a carry fence's type in one function 2026-10-03 14:45:48 +02:00
lilleman 2700184e56 Spell the doc directive's content=none through spellAttributes from one constant 2026-10-03 14:45:28 +02:00
lilleman d9c3e4b349 Derive a block directive's empty content from its node, and let readEmptyKeys own the attrs=empty-stands-alone rule 2026-10-03 14:45:12 +02:00
lilleman 2d02165702 Keep the browser leg on dist/index.js: tag -0 in the JSON it hands back 2026-10-03 14:44:14 +02:00
lilleman a565d49c24 Spell 'a text node holding its text and marks alone' once, as isPlainText 2026-10-03 14:44:00 +02:00
lilleman ade3997cfd Hold a carried node's own marks on the item, not in a side set copies fall out of 2026-10-03 14:43:03 +02:00
lilleman 31576bb824 Take the raw run's refusal path only where its code matches the editor-normal refusal 2026-10-03 14:41:42 +02:00
lilleman 723c0e31ef Name the adf: fence among the carve-outs, and the way a fence stays code in its errors 2026-10-03 14:41:30 +02:00
lilleman a5238e4fe5 Cite item 43 as what ends the CommonMark carve-outs and the adf: fence reservation 2026-10-03 14:40:31 +02:00
25 changed files with 152 additions and 119 deletions
+2 -2
View File
@@ -8,8 +8,8 @@
fence whose info string `adf:<type>` names the node's type, its body the node's JSON without
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` reads markdown holding no block as a document whose `content` is
empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
`content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would
join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled
+4 -3
View File
@@ -175,7 +175,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or wrap a code fence whose info string opens `adf:` in `!adf:codeBlock {language="adf:…"}` with a bare fence |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
@@ -212,8 +212,9 @@ Serves Goals 1, 3 and 4.
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough
syntax, and a code fence whose info string opens `adf:`, is claimed (escapable — `spec/flavour.md`)
— and one gap: a CommonMark image fits only as
its own title-less paragraph; mid-text and titled images are error results, save an image inside
another's description, which flattens into the alt text. Converting back yields the library's
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
+2 -3
View File
@@ -1,8 +1,7 @@
try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
const { serializeCanonicalJson } = await import('/dist/canonical-json.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as its canonical spelling.
const spelled = (result) => (result.ok ? { ok: true, value: `${serializeCanonicalJson(result.value, 'two-space')}\n` } : result)
// 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)
window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
+8 -3
View File
@@ -49,6 +49,11 @@ function fixtureNames(kind, extension) {
.sort()
}
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
function revived(text) {
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
}
function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
}
@@ -106,7 +111,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, json)
assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
})
}
@@ -114,7 +119,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index]
checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, fixture(name, '.json'))
assert.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
})
}
@@ -124,7 +129,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, json)
assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
})
}
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
+6 -2
View File
@@ -113,7 +113,9 @@ node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string ca
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`.
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`. The
reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its own
reader.
## A code block is a fence per text node
@@ -171,7 +173,9 @@ opener nests by itself and leaf versus container falls out of the node's content
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
claims, giving CommonMark its own reader.
## Tables
+4 -3
View File
@@ -1,9 +1,10 @@
# The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
string opens `adf:` is the opaque carry (The opaque carry says how to keep it code) — and one gap: a CommonMark
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
grammar in the sections below.
+6 -1
View File
@@ -64,6 +64,11 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
return keys
}
// A text node holding its text and marks alone: a format spells it as text, where anything more rides a carry.
export function isPlainText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right])
}
@@ -72,7 +77,7 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
return marksKey(left) === marksKey(right)
}
// `joins` is the caller's: a reader joins what CommonMark reads as one, editor-normal ADF what the editor would.
// Callers say when two nodes join.
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
+4 -6
View File
@@ -2,17 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process'
import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsNormally } from '../adf/editor-normal.ts'
import { markAttributes } from '../adf/mark-attributes.ts'
import { mergeAdjacentText } from '../adf/document.ts'
type Positions = { block: AdfNode; inline: AdfNode }
@@ -85,10 +85,8 @@ function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>)
}
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept = { ...held }
if (kept.attrs !== undefined && Object.keys(kept.attrs).length === 0) delete kept.attrs
if ('content' in kept && kept.content?.length === 0) delete kept.content
if ('marks' in kept && kept.marks?.length === 0) delete kept.marks
const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
for (const key of emptyKeys(held)) delete kept[key]
return kept
}
+4 -2
View File
@@ -4,7 +4,7 @@ import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts'
import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map(
Object.entries({
@@ -17,7 +17,9 @@ const argumentByType = new Map(
export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentSpelling = spellDirectiveOpener(documentName, undefined, '{content=none}')
export const documentAttribute = { key: 'content', value: 'none' } as const
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak'
+2 -2
View File
@@ -1,5 +1,5 @@
import type { JsonValue } from '../json-value.ts'
import { carryFencePrefix } from './opaque-carry.ts'
import { carryFenceType } from './opaque-carry.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
@@ -7,6 +7,6 @@ export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute'
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language.startsWith(carryFencePrefix) || !infoStringCarries(language)) return { kind: 'attribute' }
if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' }
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { adfDocumentFault, carriesOnly, isPlainText, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
@@ -278,7 +278,7 @@ function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertEr
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
if (node.content === undefined) return success([''])
if (node.content.some((child) => child.type !== 'text' || child.attrs !== undefined || child.content !== undefined || child.marks !== undefined)) return success(undefined)
if (node.content.some((child) => !isPlainText(child) || child.marks !== undefined)) return success(undefined)
const texts: string[] = []
for (const [index, child] of node.content.entries()) {
const childPath = [...path, 'content', index]
+5 -5
View File
@@ -6,7 +6,7 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment,
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { emptyKeys, identicalMark, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { identicalMark, isPlainText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.ts'
@@ -268,9 +268,9 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
}
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (node.attrs !== undefined || emptyKeys(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 (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (!isPlainText(node)) 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 (/\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)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
@@ -323,10 +323,10 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
const spans: string[] = []
for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1 || node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: range })
if (node.type === 'text' && nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (!isPlainText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
const { text } = node
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
+2 -2
View File
@@ -54,9 +54,9 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
// Merging text renumbers siblings, so the caller's document names the refusal's path.
// Merging text renumbers siblings, so the caller's document names the path of the refusal the editor-normal one meets.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return raw.ok ? blocks : raw
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
}
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 })
}
+3 -1
View File
@@ -16,7 +16,8 @@ export function spellsEmpty(value: DirectiveValue | undefined): boolean {
return value?.spelling === emptyValue
}
export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly EmptyKey[]): Read<EmptyKeysRead> {
// `held` is whether the directive spells an attribute outside {attrs}: an argument or a content slot.
export function readEmptyKeys(type: string, attributes: DirectiveAttributes, keys: readonly EmptyKey[], held: boolean): Read<EmptyKeysRead> {
const rest = new Map(attributes)
const empty = new Set<EmptyKey>()
for (const key of keys) {
@@ -26,5 +27,6 @@ export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly Em
empty.add(key)
rest.delete(key)
}
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside an attribute it holds`) }
return { value: { empty, rest } }
}
+15 -4
View File
@@ -8,7 +8,7 @@ import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:'
@@ -32,6 +32,11 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
}
// The type a fence's info string names, `undefined` where the info string opens no carry.
export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) {
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON`) }
@@ -58,8 +63,9 @@ function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, pat
// `type` is the one the fence's info string names, `undefined` for the inline carry.
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
if (parsed === undefined) return { fault: malformedDirective(`the opaque carry holds invalid JSON; ${wayOut}`) }
const { value } = parsed
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
@@ -70,12 +76,17 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, ty
}
if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) }
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}; ${wayOut}`) }
}
if (!isAdfNode(held)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }
if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) }
return { value: held }
}
// A code fence the carry claims stays code under the directive, whose language rides the attribute.
function fenceEscape(type: string): string {
return `${spellDirectiveOpener('codeBlock', undefined, spellAttributes([['language', spellStringAttribute(`${carryFencePrefix}${type}`)]]))} around a bare fence keeps it a code block`
}
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) }
+2 -2
View File
@@ -13,11 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
if (spelling === undefined) return undefined
const markdown = markdownForm(spelling)
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
const empty = readEmptyKeys(attributes, ['attrs'])
const empty = readEmptyKeys(name, attributes, ['attrs'], false)
if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
if (!attrs.ok) return attrs
if (empty.value.empty.has('attrs')) return Object.keys(attrs.value).length === 0 ? success({ attrs: {}, type: name }) : failure('unsupported-node-shape', `${name} spells attrs=empty beside an attribute it holds`, path)
if (empty.value.empty.has('attrs')) return success({ attrs: {}, type: name })
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
}
+10 -15
View File
@@ -3,7 +3,7 @@ import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { attributeNestingMessage, isPlainText } from '../../adf/document.ts'
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
@@ -17,8 +17,7 @@ import { slotLineEndingFault } from '../directive-syntax.ts'
import { textBreakName } from '../text-break.ts'
import { textDirectiveName } from '../text-directive.ts'
// `emptyContent` is whether the directive spells content=empty, which holds no body.
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode }
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
export function readBlockDirectiveNode(
name: string,
@@ -33,7 +32,7 @@ export function readBlockDirectiveNode(
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name)
const spelled = attributes.get(marksAttribute)
const empty = readEmptyKeys(attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'])
const empty = readEmptyKeys(name, attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'], argument !== undefined)
if (empty.fault !== undefined) return faulted(empty.fault, path)
const { rest } = empty.value
rest.delete(marksAttribute)
@@ -46,9 +45,7 @@ export function readBlockDirectiveNode(
}
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks
const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path)
if (!node.ok) return node
return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value })
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value, empty.value.empty) })
}
export function readInlineDirectiveNode(
@@ -62,7 +59,7 @@ export function readInlineDirectiveNode(
const slot = model.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
const empty = readEmptyKeys(attributes, ['attrs', 'content', 'marks'])
const empty = readEmptyKeys(name, attributes, ['attrs', 'content', 'marks'], slot !== undefined && content !== undefined)
if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs
@@ -75,7 +72,7 @@ export function readInlineDirectiveNode(
if (spans !== undefined) return faulted(spans, path)
attrs.value[slot] = text
}
return namedNode(name, attrs.value, undefined, empty.value.empty, path)
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
}
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
@@ -95,7 +92,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined
if (only?.type !== 'text' || nodeMarks(only).length > 0 || Object.keys(nodeAttrs(only)).length > 0 || nodeContent(only).length > 0 || typeof only.text !== 'string') return undefined
if (only === undefined || !isPlainText(only) || only.marks !== undefined || typeof only.text !== 'string') return undefined
return only.text
}
@@ -109,11 +106,9 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
return success(marks)
}
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>, path: ConvertErrorPath): Result<AdfNode> {
const held = Object.keys(attrs).length > 0
if (held && empty.has('attrs')) return failure('unsupported-node-shape', `${type} spells attrs=empty beside an attribute it holds`, path)
const node: AdfNode = held || empty.has('attrs') ? { attrs, type } : { type }
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>): AdfNode {
const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
if (empty.has('content')) node.content = []
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
return success(node)
return node
}
+30 -31
View File
@@ -26,7 +26,10 @@ export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefin
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
type TextBreak = { kind: 'textBreak' }
type Inline = AdfNode | TextBreak
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
type Carried = { kind: 'carry'; node: AdfNode }
type Inline = AdfNode | Carried | TextBreak
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
@@ -38,10 +41,9 @@ type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; lin
type Pairing = EmphasisPairing<Run>
// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty.
type Piece =
| Bracket
| { kind: 'carry'; node: AdfNode }
| Carried
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: Inline[] }
| TextBreak
@@ -58,8 +60,6 @@ type Scan = {
definitions: LinkDefinitions
highlights: boolean
openingSpellableLink: boolean
// The nodes carry pieces hold: none joins its neighbour.
ownMarks: Set<AdfNode>
path: ConvertErrorPath
pending: string
pieces: Piece[]
@@ -68,7 +68,7 @@ type Scan = {
}
// What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'ownMarks' | 'path'>
type SharedScan = Pick<Scan, 'definitions' | 'path'>
type SlotContent = { carry: boolean; nodes: Inline[] }
@@ -82,7 +82,7 @@ const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const scan = freshScan(source, { definitions, ownMarks: new Set(), path }, { container, highlights: flavour === 'plain', spans: noSpans })
const scan = freshScan(source, { definitions, path }, { container, highlights: flavour === 'plain', spans: noSpans })
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
@@ -236,7 +236,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success(carryPiece(scan, carried.value))
return success({ kind: 'carry', node: carried.value })
}
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
const text = readTextDirective(span)
@@ -250,12 +250,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (!node.ok) return node
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value))
}
function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.ownMarks.add(node)
return { kind: 'carry', node }
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'carry', node: node.value })
}
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
@@ -281,8 +276,8 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined)
const { definitions, ownMarks, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, ownMarks, path }, { container: undefined, highlights: false, spans: span.spans }))
const { definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, path }, { container: undefined, highlights: false, spans: span.spans }))
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value)
@@ -312,29 +307,33 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = []
for (const [index, item] of items.entries()) {
if (!isTextBreak(item)) {
parted.push(item)
parted.push(isNode(item) ? item : item.node)
continue
}
const previous = items[index - 1]
const next = items[index + 1]
if (previous === undefined || next === undefined || isTextBreak(previous) || isTextBreak(next) || !readJoins(scan)(previous, next)) {
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) {
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
}
}
return success(parted)
}
function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean {
return (previous, node) => !scan.ownMarks.has(previous) && !scan.ownMarks.has(node) && readsAsOne(previous, node)
function isNode(item: Inline): item is AdfNode {
return !('kind' in item)
}
function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item
return 'kind' in item && item.kind === 'textBreak'
}
// The nodes the items hold, carried ones unwrapped and text breaks dropped.
function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = []
for (const item of items) if (!isTextBreak(item)) nodes.push(item)
for (const item of items) {
if (isNode(item)) nodes.push(item)
else if (!isTextBreak(item)) nodes.push(item.node)
}
return nodes
}
@@ -509,23 +508,23 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeText(nodes.flat(), readJoins(scan)))
return success(mergeText(nodes.flat()))
}
// A text break is a wall: the text on either side joins only its own side.
function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] {
// A text break and a carried node are walls: the text on either side joins only its own side.
function mergeText(items: readonly Inline[]): Inline[] {
const merged: Inline[] = []
let run: AdfNode[] = []
for (const item of items) {
if (!isTextBreak(item)) {
if (isNode(item)) {
run.push(item)
continue
}
for (const node of mergeAdjacentText(run, joins)) merged.push(node)
for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
merged.push(item)
run = []
}
for (const node of mergeAdjacentText(run, joins)) merged.push(node)
for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
return merged
}
@@ -534,7 +533,7 @@ function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: Ad
function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) {
case 'carry':
return [piece.node]
return [piece]
case 'highlight':
return [{ text: '', type: 'text' }]
case 'image':
@@ -637,7 +636,7 @@ function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: rea
}
function highlighted(node: Inline): Inline {
if (isTextBreak(node)) return node
if (!isNode(node)) return node
const marks = nodeMarks(node)
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
return { ...node, marks: [editorHighlight, ...marks] }
@@ -651,7 +650,7 @@ function markType(character: string, used: number): string {
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => {
if (isTextBreak(node)) return node
if (!isNode(node)) return node
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
+20 -15
View File
@@ -374,7 +374,7 @@ test('reads the carry fence back to the node its info string names and its JSON
"unsupported-node-shape: the adf:blockCard fence names its node's type: this JSON holds a type as well",
)
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard')
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node")
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON'
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
})
@@ -385,26 +385,31 @@ test('reads the inline carry back to the node its json attribute holds', () => {
])
})
test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
test('names the invalid JSON no opaque carry holds, and the spelling that keeps it literal', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON; '
const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), fence)
assert.equal(content(markdownToAdf('```adf:x\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), `${invalid}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), `${invalid}\\!adf: keeps the prefix literal`)
})
test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
assert.equal(content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
const literal = '; \\!adf: keeps the prefix literal'
assert.equal(
content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')),
`${canonically}two-space indent, keys sorted; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block`,
)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted${literal}`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted${literal}`)
})
test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node"
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; "
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), `${node}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), `${node}\\!adf: keeps the prefix literal`)
})
test('names the shape the inline carry reads alone', () => {
@@ -421,7 +426,7 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal(
content(markdownToAdf(fence('', largestNesting + 1))),
"unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted",
'unsupported-node-shape: the opaque carry spells its node\'s JSON canonically: two-space indent, keys sorted; !adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block',
)
assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1))
})
+10 -8
View File
@@ -5,9 +5,9 @@ import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryFencePrefix, readCarriedBlock } from '../opaque-carry.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { inlineLeaves } from '../emit/plain-inline.ts'
import { languageSlot } from '../code-language.ts'
@@ -83,8 +83,8 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
}
function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const content = block.attributes.get('content')
if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined
const spelled = block.attributes.get(documentAttribute.key)
if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
}
@@ -229,9 +229,10 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
}
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, emptyContent, node } = read
const { contentModel, node } = read
if (blocks === undefined) return success(node)
if (emptyContent) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path)
// A directive builds a content key only from content=empty, which holds no body.
if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth)
@@ -302,8 +303,9 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
}
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language.startsWith(carryFencePrefix)) {
const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth)
const carriedType = carryFenceType(language)
if (carriedType !== undefined) {
const carried = readCarriedBlock(carriedType, text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value)
return success(carried.value)
@@ -265,3 +265,8 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
})
test('joins a carried text node to no neighbour, inside a highlight too', () => {
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
assert.deepEqual(read(`==a${carried}==\n`), blocks(paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })))
})
+2 -6
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks, nodeMarks } from '../adf/document.ts'
import { identicalMarks, isPlainText, nodeMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak'
@@ -8,9 +8,5 @@ export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes).
export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
return spelledAsText(previous) && spelledAsText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
function spelledAsText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
return isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
+2 -1
View File
@@ -68,7 +68,8 @@ HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three
### 43. Give each markdown input its own reader, strict to its own standard.
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text. A
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text, and
a code fence whose info string opens `adf:` becomes the block carry where CommonMark reads code. A
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.