Compare commits

...

7 Commits

16 changed files with 177 additions and 120 deletions
+2 -1
View File
@@ -22,7 +22,8 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) { function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored) const parsed = markdownToAdf010(stored)
return parsed.ok ? adfToMarkdown(parsed.value) : parsed // 0.1.0's reader was editor-normal, so a document with no content key always meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
} }
``` ```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
```adf:\\
{}
```
+4 -4
View File
@@ -38,10 +38,10 @@ shape the editor would not.
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an "Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side. empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain flavour is CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
lossy and stays editor-normal: its reduction reads and writes ADF with adjacent text nodes of builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
identical marks and no attributes merged, `-0` read as `0`, and an empty `attrs`, `content` or writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0`
`marks` the absent key. as `0`, and an empty `attrs`, `content` or `marks` the absent key.
## `!adf:textBreak{}` parts text CommonMark would join ## `!adf:textBreak{}` parts text CommonMark would join
+16 -2
View File
@@ -68,8 +68,22 @@ export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right]) return marksKey([left]) === marksKey([right])
} }
export function identicalMarks(left: AdfNode, right: AdfNode): boolean { export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
return marksKey(nodeMarks(left)) === marksKey(nodeMarks(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.
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && joins(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
} }
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
+7 -28
View File
@@ -1,34 +1,25 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts' import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue } type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] } type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean { export function sameMark(left: AdfMark, right: AdfMark): boolean {
return markKey(candidate) === markKey(mark) return identicalMark(normalMark(left), normalMark(right))
} }
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] { export function joinsNormally(previous: AdfNode, node: AdfNode): boolean {
const merged: AdfNode[] = [] if (!mergesText(previous) || !mergesText(node)) return false
for (const node of nodes) { return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
const previous = merged[merged.length - 1]
if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
} }
export function toEditorNormal(document: AdfDocument): AdfDocument { export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version } const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }] const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) { for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source)) const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally)
if (content.length === 0) continue if (content.length === 0) continue
entry.holder.content = content.map((source) => { entry.holder.content = content.map((source) => {
const holder = normalNode(source) const holder = normalNode(source)
@@ -76,15 +67,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
function mergesText(node: AdfNode): boolean { function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
} }
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
}
function marksKey(marks: readonly AdfMark[]): string {
return marks.map(markKey).join('\n')
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(normalAttributes(nodeAttrs(mark)) ?? {}, 'compact')}`
}
+3 -2
View File
@@ -10,8 +10,9 @@ 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 { inlineNodes } from '../adf/inline-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.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/editor-normal.ts' import { mergeAdjacentText } from '../adf/document.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
@@ -92,7 +93,7 @@ function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
} }
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> { function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes))) return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsNormally)))
} }
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
+3 -3
View File
@@ -3,9 +3,9 @@ import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from './line-escaping.ts' import type { MarkRun } from './line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinsNormally, sameMark } from '../../adf/editor-normal.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts' import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
@@ -193,7 +193,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
for (let current = leaves; ; ) { for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current)) const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally))
let changed = false let changed = false
const trimmed: AdfNode[] = [] const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) { for (const [index, leaf] of merged.entries()) {
@@ -64,6 +64,7 @@ test('refuses what the document guard refuses, and nothing else', () => {
let deep: AdfNode = said('x') let deep: AdfNode = said('x')
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' } for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/) assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
assert.match(plainDocument(document(paragraph(text('a'), text('b')), text('c'), text('d'), deep)), /^unsupported-nesting-depth at \/content\/3(\/content\/0)+$/)
let deepInline: AdfNode = text('x') let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' } for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /) assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
+2 -2
View File
@@ -51,8 +51,8 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
// docs/decisions.md §Equality is deep: the plain flavour reads and writes editor-normal ADF. // A refusal's path indexes the caller's document, so only the reduction's output normalizes (docs/decisions.md §Equality is deep).
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks
} }
+3
View File
@@ -33,6 +33,9 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
} }
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)) {
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON`) }
}
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type) const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) } return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) }
+90 -58
View File
@@ -10,7 +10,7 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.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, highlightFlanking } from '../plain-conventions.ts' import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
import { identicalMarks, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
@@ -23,6 +23,13 @@ import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
// 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
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition> export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
@@ -34,10 +41,10 @@ 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. // 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: 'break' }
| { kind: 'carry'; node: AdfNode } | { kind: 'carry'; node: AdfNode }
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] } | { kind: 'nodes'; nodes: Inline[] }
| TextBreak
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean } | { closes: boolean; kind: 'highlight'; opens: boolean }
@@ -45,14 +52,14 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = { type Scan = {
// Nodes a carry piece restored: they never merge, and only identity tells a carried textBreak from the leaf.
carried: Set<AdfNode>
container: LineContainer | undefined container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed. // Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number deactivatedBefore: number
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[]
@@ -60,7 +67,7 @@ type Scan = {
spans: NestedSpans spans: NestedSpans
} }
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: Inline[] }
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
@@ -68,13 +75,26 @@ const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>' const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
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> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, { carried: new Set(), container, definitions, highlights: flavour === 'plain', path, spans: noSpans }) const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights: flavour === 'plain', openingSpellableLink: false, ownMarks: new Set(), path, pending: '', pieces: [], source, spans: noSpans }
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
const nodes = partText(scanned.value.nodes, scan)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
return success({ carry: scanned.value.carry, nodes: nodes.value })
} }
function parseInline(source: string, context: Pick<Scan, 'carried' | 'container' | 'definitions' | 'highlights' | 'path' | 'spans'>): Result<InlineContent> { function scanInline(scan: Scan): Result<Scanned> {
const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source } const { source } = scan
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
@@ -211,7 +231,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
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(carryPiece(scan, carried.value))
} }
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success({ kind: 'break' }) : 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)
if (text?.fault !== undefined) return faulted(text.fault, scan.path) if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
@@ -219,13 +239,15 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (!slot.ok) return slot if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path) const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) const content = slot.value?.nodes
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 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] } : carryPiece(scan, node.value))
} }
function carryPiece(scan: Scan, node: AdfNode): Piece { function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.carried.add(node) scan.ownMarks.add(node)
return { kind: 'carry', node } return { kind: 'carry', node }
} }
@@ -240,11 +262,11 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
} }
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter. // spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined { function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece> | undefined {
const href = linkHref(nodeAttrs(mark)) const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path) if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path) if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true scan.openingSpellableLink = true
return undefined return undefined
@@ -252,7 +274,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
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 parsed = parseInline(span.content, { ...scan, container: undefined, highlights: false, spans: span.spans }) const parsed = scanInline({ ...scan, container: undefined, deactivatedBefore: 0, highlights: false, openingSpellableLink: false, pending: '', pieces: [], source: span.content, 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)
@@ -268,40 +290,44 @@ function pushNode(scan: Scan, node: AdfNode): void {
scan.pieces.push({ kind: 'nodes', nodes: [node] }) scan.pieces.push({ kind: 'nodes', nodes: [node] })
} }
function assemble(scan: Scan): Result<InlineContent> { function assemble(scan: Scan): Result<Scanned> {
const only = scan.pieces[0] const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const resolved = resolveNodes(scan.pieces, scan, true) const nodes = resolveNodes(scan.pieces, scan, true)
const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, scan.path)
}
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
} }
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it. // spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
function partText(nodes: readonly AdfNode[], scan: Scan): Result<AdfNode[]> { function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = [] const parted: AdfNode[] = []
for (const [index, node] of nodes.entries()) { for (const [index, item] of items.entries()) {
if (!isTextBreak(node, scan.carried)) { if (!isTextBreak(item)) {
parted.push(node) parted.push(item)
continue continue
} }
const previous = nodes[index - 1] const previous = items[index - 1]
const next = nodes[index + 1] const next = items[index + 1]
if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(next) || !readsAsOne(previous, next)) { if (previous === undefined || next === undefined || isTextBreak(previous) || isTextBreak(next) || !readJoins(scan)(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 isTextBreak(node: AdfNode, carried: ReadonlySet<AdfNode>): boolean { function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean {
return node.type === textBreakName && !carried.has(node) return (previous, node) => !scan.ownMarks.has(previous) && !scan.ownMarks.has(node) && readsAsOne(previous, node)
}
function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item
}
function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = []
for (const item of items) if (!isTextBreak(item)) nodes.push(item)
return nodes
} }
function holdsCarry(pieces: readonly Piece[]): boolean { function holdsCarry(pieces: readonly Piece[]): boolean {
@@ -317,8 +343,8 @@ function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes)) return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
} }
function marksLink(nodes: readonly AdfNode[]): boolean { function marksLink(nodes: readonly Inline[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link')) return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
} }
function readDelimiterRun(scan: Scan, index: number): number { function readDelimiterRun(scan: Scan, index: number): number {
@@ -455,8 +481,8 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> { function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner, scan, false) const nodes = resolveNodes(inner, scan, false)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (nodes.value.some((node) => isTextBreak(node, scan.carried))) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path) if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
return success(nodes.value.map(altText).join('')) return success(adfNodes(nodes.value).map(altText).join(''))
} }
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included. // spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
@@ -468,35 +494,37 @@ function altText(node: AdfNode): string {
} }
// An image's alt text is plain, so `highlights` is off there and every `==` stays text. // An image's alt text is plain, so `highlights` is off there and every `==` stays text.
function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<AdfNode[]> { function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<Inline[]> {
const nodes = pieces.map(pieceNodes) const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces) const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs) const pairings = matchEmphasis(runs)
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(), scan.carried)) return success(mergeText(nodes.flat(), readJoins(scan)))
} }
function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet<AdfNode>): AdfNode[] { // A text break is a wall: the text on either side joins only its own side.
const merged: AdfNode[] = [] function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] {
for (const node of nodes) { const merged: Inline[] = []
const previous = merged[merged.length - 1] let run: AdfNode[] = []
if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) { for (const item of items) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } if (!isTextBreak(item)) {
run.push(item)
continue continue
} }
merged.push(node) for (const node of mergeAdjacentText(run, joins)) merged.push(node)
merged.push(item)
run = []
} }
for (const node of mergeAdjacentText(run, joins)) merged.push(node)
return merged return merged
} }
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it. // A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): AdfNode[] { function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) { switch (piece.kind) {
case 'break':
return [{ type: textBreakName }]
case 'carry': case 'carry':
return [piece.node] return [piece.node]
case 'highlight': case 'highlight':
@@ -509,6 +537,8 @@ function pieceNodes(piece: Piece): AdfNode[] {
return bracketNodes(piece) return bracketNodes(piece)
case 'run': case 'run':
return [] return []
case 'textBreak':
return [piece]
} }
} }
@@ -521,7 +551,7 @@ function delimiterRuns(pieces: readonly Piece[]): Run[] {
} }
// A run gives its delimiters up from the head closing and the tail opening; what is left between them is text. // A run gives its delimiters up from the head closing and the tail opening; what is left between them is text.
function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: readonly Pairing[]): void { function writeUnpaired(nodes: Inline[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
const heads = new Map<Run, number>() const heads = new Map<Run, number>()
const tails = new Map<Run, number>() const tails = new Map<Run, number>()
for (const pairing of pairings) { for (const pairing of pairings) {
@@ -536,7 +566,7 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado
} }
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks). // Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean { function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: readonly Pairing[]): boolean {
for (const pairing of pairings) { for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) } const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) { for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
@@ -547,12 +577,12 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true return true
} }
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] { function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] {
const found: HighlightDelimiter[] = [] const found: HighlightDelimiter[] = []
let line = 0 let line = 0
let position = 0 let position = 0
for (const [index, piece] of pieces.entries()) { for (const [index, piece] of pieces.entries()) {
const held = nodes[index] ?? [] const held = adfNodes(nodes[index] ?? [])
const [holder] = held const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) { if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position }) found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
@@ -568,7 +598,7 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[]
} }
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks. // Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] { function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): { closer: number; opener: number }[] {
const found = highlightDelimiters(pieces, nodes) const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = [] const paired: { closer: number; opener: number }[] = []
let closer = 0 let closer = 0
@@ -579,7 +609,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
let candidate = found[closer] let candidate = found[closer]
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)] while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
if (candidate === undefined) break if (candidate === undefined) break
if (candidate.line !== opener.line || !identicalMarks(opener.holder, candidate.holder)) continue if (candidate.line !== opener.line || !identicalMarks(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue
paired.push({ closer: candidate.index, opener: opener.index }) paired.push({ closer: candidate.index, opener: opener.index })
resume = candidate.position + highlightDelimiter.length resume = candidate.position + highlightDelimiter.length
} }
@@ -587,9 +617,9 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
} }
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes. // Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void { function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly { closer: number; opener: number }[]): void {
for (const [index, piece] of pieces.entries()) { for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter })) if (piece.kind === 'highlight') nodes[index] = adfNodes(nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
} }
for (const { closer, opener } of paired) { for (const { closer, opener } of paired) {
nodes[opener] = [] nodes[opener] = []
@@ -598,7 +628,8 @@ function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: re
} }
} }
function highlighted(node: AdfNode): AdfNode { function highlighted(node: Inline): Inline {
if (isTextBreak(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] }
@@ -610,8 +641,9 @@ 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 AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => { return nodes.map((node) => {
if (isTextBreak(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] }
}) })
@@ -375,6 +375,8 @@ test('reads the carry fence back to the node its info string names and its JSON
) )
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")
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)
}) })
test('reads the inline carry back to the node its json attribute holds', () => { test('reads the inline carry back to the node its json attribute holds', () => {
@@ -1076,3 +1078,17 @@ test('names the directive mark left without the content it wraps', () => {
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named) assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
assert.equal(content(markdownToAdf('!adf:underline{}\n')), named) assert.equal(content(markdownToAdf('!adf:underline{}\n')), named)
}) })
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else'
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
}
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text')
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
})
@@ -6,7 +6,6 @@ import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts' import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
const code: AdfMark = { type: 'code' } const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' } const em: AdfMark = { type: 'em' }
@@ -18,7 +17,7 @@ const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
function read(markdown: string): readonly AdfNode[] | string { function read(markdown: string): readonly AdfNode[] | string {
const parsed = plainMarkdownToAdf(markdown) const parsed = plainMarkdownToAdf(markdown)
if (!parsed.ok) return parsed.error.code if (!parsed.ok) return parsed.error.code
const blocks = toEditorNormal(parsed.value).content ?? [] const blocks = parsed.value.content ?? []
const pending = [...blocks] const pending = [...blocks]
for (let block = pending.pop(); block !== undefined; block = pending.pop()) { for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
for (const child of block.content ?? []) pending.push(child) for (const child of block.content ?? []) pending.push(child)
@@ -31,8 +30,8 @@ function read(markdown: string): readonly AdfNode[] | string {
return blocks return blocks
} }
function normal(...blocks: AdfNode[]): readonly AdfNode[] { function blocks(...content: AdfNode[]): readonly AdfNode[] {
return toEditorNormal(document(...blocks)).content ?? [] return content
} }
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
@@ -57,7 +56,7 @@ function bare(type: string, ...content: AdfNode[]): AdfNode {
} }
function paragraph(...content: AdfNode[]): AdfNode { function paragraph(...content: AdfNode[]): AdfNode {
return bare('paragraph', ...content) return content.length === 0 ? { type: 'paragraph' } : bare('paragraph', ...content)
} }
function said(value: string): AdfNode { function said(value: string): AdfNode {
@@ -101,7 +100,7 @@ test('reads the rest of an alert marker line as the panel first body paragraph,
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))]) assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))]) assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))]) assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph()))) assert.deepEqual(read('> [!NOTE]\n'), blocks(panel('info', paragraph())))
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))]) assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
}) })
@@ -120,27 +119,27 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))), node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
]) ])
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))]) assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph()))) assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), blocks(node('expand', { title: 'See x (http://y)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph()))) assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), blocks(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), normal(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph()))) assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, paragraph()))) assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), blocks(node('expand', { title: 'a\nb (u)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), normal(node('expand', { title: '@M (u) http://y' }, paragraph()))) assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), blocks(node('expand', { title: '@M (u) http://y' }, paragraph())))
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph()))) assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), blocks(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))]) assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
}) })
test('reads a folded callout inside an expand to a nested expand', () => { test('reads a folded callout inside an expand to a nested expand', () => {
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n' const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph()))))) assert.deepEqual(read(markdown), blocks(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph()))))) assert.deepEqual(read('- > [!NOTE]-\n'), blocks(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph())))) assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), blocks(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
}) })
test('reads a bullet list whose every item leads with a task marker to a task list', () => { test('reads a bullet list whose every item leads with a task marker to a task list', () => {
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [ assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))), bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
]) ])
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after'))))) assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), blocks(bare('taskList', task('DONE'), task('TODO', text('after')))))
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n') const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [ assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
node( node(
@@ -202,7 +201,7 @@ test('reads what the reduction wrote back to the node it reduced, less the attri
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType) assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
} }
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.'))) const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph()))) assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), blocks(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))]) assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare( const tasks = bare(
'taskList', 'taskList',
+2 -2
View File
@@ -1,5 +1,5 @@
import type { AdfNode } from '../adf/document.ts' import type { AdfNode } from '../adf/document.ts'
import { identicalMarks } from '../adf/document.ts' import { identicalMarks, 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,7 +8,7 @@ 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(previous, node) return spelledAsText(previous) && spelledAsText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
} }
function spelledAsText(node: AdfNode): boolean { function spelledAsText(node: AdfNode): boolean {
+8 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 54` `Next ID = 55`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -31,6 +31,7 @@
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 | | 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 | | 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
| 54 | 0.2.0 | | **Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.** | 2 | 2 | 2 | 5 | 1 | 11.5 |
| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 | | 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 | | 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 | | 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
@@ -95,6 +96,12 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves `MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
the README's "Four CommonMark spellings" bullet, which counts one fewer. the README's "Four CommonMark spellings" bullet, which counts one fewer.
### 54. Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.
The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesDirective` from
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either
the entry names it with its reason, or the reader stops importing it.
### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick. ### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick.
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as