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 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 `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`. 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 - **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
`content` key. `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would - `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 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 | | 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 | | `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 | | `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 | | `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 `0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception. name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - 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 names, with four 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 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 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 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 canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
+2 -3
View File
@@ -1,8 +1,7 @@
try { try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') 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 JSON text with -0 tagged; run.js revives it.
// WebDriver's JSON reads -0 back as 0, so a document crosses as its canonical spelling. const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
const spelled = (result) => (result.ok ? { ok: true, value: `${serializeCanonicalJson(result.value, 'two-space')}\n` } : result)
window.convertCorpus = (corpus) => ({ window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(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() .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) { function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}` 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.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.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] 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.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.isDocument, `${name}.json is no ADF document`)
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.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) 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 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 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 `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 ## 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. 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 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 ## Tables
+4 -3
View File
@@ -1,9 +1,10 @@
# The markdown flavour # The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain 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 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, and a matched `~~` pair 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 one gap: a CommonMark 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 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 emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
grammar in the sections below. grammar in the sections below.
+6 -1
View File
@@ -64,6 +64,11 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
return keys 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 { export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right]) return marksKey([left]) === marksKey([right])
} }
@@ -72,7 +77,7 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
return marksKey(left) === marksKey(right) 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[] { export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = [] const merged: AdfNode[] = []
for (const node of nodes) { 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 { env } from 'node:process'
import fc from 'fast-check' 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 { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts' import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsNormally } from '../adf/editor-normal.ts' import { joinsNormally } from '../adf/editor-normal.ts'
import { markAttributes } from '../adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { mergeAdjacentText } from '../adf/document.ts'
type Positions = { block: AdfNode; inline: AdfNode } 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 { function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept = { ...held } const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
if (kept.attrs !== undefined && Object.keys(kept.attrs).length === 0) delete kept.attrs for (const key of emptyKeys(held)) delete kept[key]
if ('content' in kept && kept.content?.length === 0) delete kept.content
if ('marks' in kept && kept.marks?.length === 0) delete kept.marks
return kept 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 { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark } from '../adf/document.ts' import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts' import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map( const argumentByType = new Map(
Object.entries({ Object.entries({
@@ -17,7 +17,9 @@ const argumentByType = new Map(
export const documentName = 'doc' export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell. // 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' export const listBreakName = 'listBreak'
+2 -2
View File
@@ -1,5 +1,5 @@
import type { JsonValue } from '../json-value.ts' 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' import { infoStringCarries } from './commonmark/grammar.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } 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. // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot { export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' } 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' } return { info: language, kind: 'fence' }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.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 { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts' import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.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. // 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> { function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
if (node.content === undefined) return success(['']) 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[] = [] const texts: string[] = []
for (const [index, child] of node.content.entries()) { for (const [index, child] of node.content.entries()) {
const childPath = [...path, 'content', index] 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 { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.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 { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.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> { 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 (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 (/\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'
@@ -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> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
const spans: string[] = [] const spans: string[] = []
for (const node of nodes) { 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 const { text } = node
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) 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 (/[\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) if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1) 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. // 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: [] }) const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) { 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: [] }) 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 }) 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 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 rest = new Map(attributes)
const empty = new Set<EmptyKey>() const empty = new Set<EmptyKey>()
for (const key of keys) { for (const key of keys) {
@@ -26,5 +27,6 @@ export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly Em
empty.add(key) empty.add(key)
rest.delete(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 } } 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 { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.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' import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:' 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)]]))) 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> { export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) { 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`) } 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. // `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> { function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
const parsed = parseJsonText(raw) 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 const { value } = parsed
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
const typed = type === undefined || type === '' ? { value } : typedValue(value, type) 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) { if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' 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 } 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> { function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value } 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`) } 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 if (spelling === undefined) return undefined
const markdown = markdownForm(spelling) const markdown = markdownForm(spelling)
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path) 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) if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path) const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
if (!attrs.ok) return attrs 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 }) 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 { 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, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { attributeNestingMessage, isPlainText } from '../../adf/document.ts'
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts' import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
import { blockNodeModel } from '../../adf/block-nodes.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 { textBreakName } from '../text-break.ts'
import { textDirectiveName } from '../text-directive.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']; node: AdfNode }
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode }
export function readBlockDirectiveNode( export function readBlockDirectiveNode(
name: string, name: string,
@@ -33,7 +32,7 @@ export function readBlockDirectiveNode(
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name) const argumentKey = blockArgument(name)
const spelled = attributes.get(marksAttribute) 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) if (empty.fault !== undefined) return faulted(empty.fault, path)
const { rest } = empty.value const { rest } = empty.value
rest.delete(marksAttribute) 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) const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks if (!marks.ok) return marks
const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path) return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value, empty.value.empty) })
if (!node.ok) return node
return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value })
} }
export function readInlineDirectiveNode( export function readInlineDirectiveNode(
@@ -62,7 +59,7 @@ export function readInlineDirectiveNode(
const slot = model.textAttribute const slot = model.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) 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 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) if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path) const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
@@ -75,7 +72,7 @@ export function readInlineDirectiveNode(
if (spans !== undefined) return faulted(spans, path) if (spans !== undefined) return faulted(spans, path)
attrs.value[slot] = text 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). // 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 { 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' || 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 return only.text
} }
@@ -109,11 +106,9 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
return success(marks) return success(marks)
} }
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>, path: ConvertErrorPath): Result<AdfNode> { function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>): AdfNode {
const held = Object.keys(attrs).length > 0 const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
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 }
if (empty.has('content')) node.content = [] if (empty.has('content')) node.content = []
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])] 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. // 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 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[] } 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> 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 = type Piece =
| Bracket | Bracket
| { kind: 'carry'; node: AdfNode } | Carried
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: Inline[] } | { kind: 'nodes'; nodes: Inline[] }
| TextBreak | TextBreak
@@ -58,8 +60,6 @@ type Scan = {
definitions: LinkDefinitions definitions: LinkDefinitions
highlights: boolean highlights: boolean
openingSpellableLink: boolean openingSpellableLink: boolean
// The nodes carry pieces hold: none joins its neighbour.
ownMarks: Set<AdfNode>
path: ConvertErrorPath path: ConvertErrorPath
pending: string pending: string
pieces: Piece[] pieces: Piece[]
@@ -68,7 +68,7 @@ type Scan = {
} }
// What a directive's content slot inherits from the content holding it. // 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[] } 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. // 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> { 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) const scanned = scanInline(scan)
if (!scanned.ok) return scanned if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image }) 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) const carried = readCarriedInline(span)
if (carried !== undefined) { if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path) 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) 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) 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) 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) const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (!node.ok) return node if (!node.ok) return node
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value)) return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'carry', node: node.value })
}
function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.ownMarks.add(node)
return { kind: 'carry', node }
} }
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> { 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> { function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined) if (span.content === undefined) return success(undefined)
const { definitions, ownMarks, path } = scan const { definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, ownMarks, path }, { container: undefined, highlights: false, spans: span.spans })) const parsed = scanInline(freshScan(span.content, { definitions, path }, { container: undefined, highlights: false, spans: span.spans }))
if (!parsed.ok) return parsed if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value) return success(parsed.value)
@@ -312,29 +307,33 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = [] const parted: AdfNode[] = []
for (const [index, item] of items.entries()) { for (const [index, item] of items.entries()) {
if (!isTextBreak(item)) { if (!isTextBreak(item)) {
parted.push(item) parted.push(isNode(item) ? item : item.node)
continue continue
} }
const previous = items[index - 1] const previous = items[index - 1]
const next = 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 failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
} }
} }
return success(parted) return success(parted)
} }
function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean { function isNode(item: Inline): item is AdfNode {
return (previous, node) => !scan.ownMarks.has(previous) && !scan.ownMarks.has(node) && readsAsOne(previous, node) return !('kind' in item)
} }
function isTextBreak(item: Inline): item is TextBreak { 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[] { function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: 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 return nodes
} }
@@ -509,23 +508,23 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
writeUnpaired(nodes, runs, pairings) writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : []) 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. // A text break and a carried node are walls: the text on either side joins only its own side.
function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] { function mergeText(items: readonly Inline[]): Inline[] {
const merged: Inline[] = [] const merged: Inline[] = []
let run: AdfNode[] = [] let run: AdfNode[] = []
for (const item of items) { for (const item of items) {
if (!isTextBreak(item)) { if (isNode(item)) {
run.push(item) run.push(item)
continue continue
} }
for (const node of mergeAdjacentText(run, joins)) merged.push(node) for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
merged.push(item) merged.push(item)
run = [] run = []
} }
for (const node of mergeAdjacentText(run, joins)) merged.push(node) for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
return merged return merged
} }
@@ -534,7 +533,7 @@ function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: Ad
function pieceNodes(piece: Piece): Inline[] { function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) { switch (piece.kind) {
case 'carry': case 'carry':
return [piece.node] return [piece]
case 'highlight': case 'highlight':
return [{ text: '', type: 'text' }] return [{ text: '', type: 'text' }]
case 'image': case 'image':
@@ -637,7 +636,7 @@ function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: rea
} }
function highlighted(node: Inline): Inline { function highlighted(node: Inline): Inline {
if (isTextBreak(node)) return node if (!isNode(node)) return node
const marks = nodeMarks(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 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] } 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). // A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] { function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => { return nodes.map((node) => {
if (isTextBreak(node)) return node if (!isNode(node)) return node
const marks = nodeMarks(node) 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] }
}) })
+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", "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 "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' 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) 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', () => { 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 invalid = 'malformed-directive: the opaque carry holds invalid JSON; '
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid) const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`
assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid) assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid) assert.equal(content(markdownToAdf('```adf:x\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid) 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', () => { test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " 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`) const literal = '; \\!adf: keeps the prefix literal'
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`) assert.equal(
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`) 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', () => { 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" 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: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) 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) 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', () => { 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(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal( assert.equal(
content(markdownToAdf(fence('', largestNesting + 1))), 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)) 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 { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.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 { 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 { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { inlineLeaves } from '../emit/plain-inline.ts' import { inlineLeaves } from '../emit/plain-inline.ts'
import { languageSlot } from '../code-language.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 { function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const content = block.attributes.get('content') const spelled = block.attributes.get(documentAttribute.key)
if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined 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`) 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> { 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 (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 === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth) 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> { function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language.startsWith(carryFencePrefix)) { const carriedType = carryFenceType(language)
const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth) if (carriedType !== undefined) {
const carried = readCarriedBlock(carriedType, text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path) if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value) reading.carried.add(carried.value)
return success(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}` for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object') 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 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' import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak' 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). // 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 { export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
return spelledAsText(previous) && spelledAsText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node)) return isPlainText(previous) && isPlainText(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
} }
+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. ### 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 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, 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. read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.