Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3da45a9da7 | |||
| 0bc19238b4 | |||
| 1458b09449 | |||
| eab28ae8ff | |||
| afb53ca594 | |||
| 70e2639982 | |||
| fdbf43398c |
+2
-1
@@ -22,7 +22,8 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
|
||||
|
||||
function migrateMarkdown(stored: string) {
|
||||
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
@@ -38,10 +38,10 @@ shape the editor would not.
|
||||
|
||||
"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.
|
||||
CommonMark's spelling stays wherever a document holds none of those shapes. The plain flavour is
|
||||
lossy and stays editor-normal: its reduction reads and writes ADF with adjacent text nodes of
|
||||
identical marks and no attributes merged, `-0` read as `0`, and an empty `attrs`, `content` or
|
||||
`marks` the absent key.
|
||||
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
|
||||
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
|
||||
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0`
|
||||
as `0`, and an empty `attrs`, `content` or `marks` the absent key.
|
||||
|
||||
## `!adf:textBreak{}` parts text CommonMark would join
|
||||
|
||||
|
||||
+16
-2
@@ -68,8 +68,22 @@ export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
||||
return marksKey([left]) === marksKey([right])
|
||||
}
|
||||
|
||||
export function identicalMarks(left: AdfNode, right: AdfNode): boolean {
|
||||
return marksKey(nodeMarks(left)) === marksKey(nodeMarks(right))
|
||||
export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
|
||||
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.
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
||||
|
||||
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
type NodeHolder = { content?: AdfNode[] }
|
||||
|
||||
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
|
||||
return markKey(candidate) === markKey(mark)
|
||||
export function sameMark(left: AdfMark, right: AdfMark): boolean {
|
||||
return identicalMark(normalMark(left), normalMark(right))
|
||||
}
|
||||
|
||||
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const node of nodes) {
|
||||
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 joinsNormally(previous: AdfNode, node: AdfNode): boolean {
|
||||
if (!mergesText(previous) || !mergesText(node)) return false
|
||||
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
|
||||
}
|
||||
|
||||
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
||||
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
||||
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
||||
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
|
||||
const content = mergeAdjacentText(nodeContent(entry.source))
|
||||
const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally)
|
||||
if (content.length === 0) continue
|
||||
entry.holder.content = content.map((source) => {
|
||||
const holder = normalNode(source)
|
||||
@@ -76,15 +67,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
|
||||
function mergesText(node: AdfNode): boolean {
|
||||
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')}`
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import { blockArgument } from '../markdown/block-directive.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { joinsNormally } from '../adf/editor-normal.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { mergeAdjacentText } from '../adf/editor-normal.ts'
|
||||
import { mergeAdjacentText } from '../adf/document.ts'
|
||||
|
||||
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[]> {
|
||||
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 {
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { LineContainer } from '../line-container.ts'
|
||||
import type { MarkRun } from './line-escaping.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.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 { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { plainLineFallback, type PlainLineFallback } from './inline-line.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[] {
|
||||
for (let current = leaves; ; ) {
|
||||
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
|
||||
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally))
|
||||
let changed = false
|
||||
const trimmed: AdfNode[] = []
|
||||
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')
|
||||
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(paragraph(text('a'), text('b')), text('c'), text('d'), deep)), /^unsupported-nesting-depth at \/content\/3(\/content\/0)+$/)
|
||||
let deepInline: AdfNode = text('x')
|
||||
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
|
||||
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
|
||||
|
||||
@@ -51,8 +51,8 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
||||
const fault = adfDocumentFault(document)
|
||||
if (fault !== undefined) return faulted(fault, [])
|
||||
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.
|
||||
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
|
||||
// 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(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
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
|
||||
}
|
||||
|
||||
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)
|
||||
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}`) }
|
||||
|
||||
@@ -10,7 +10,7 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.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 { noSpans, readInlineDirective } from '../directive-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[] }
|
||||
|
||||
// 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>
|
||||
|
||||
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.
|
||||
type Piece =
|
||||
| Bracket
|
||||
| { kind: 'break' }
|
||||
| { kind: 'carry'; 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 }
|
||||
| { 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`.
|
||||
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
|
||||
// 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
|
||||
definitions: LinkDefinitions
|
||||
highlights: boolean
|
||||
openingSpellableLink: boolean
|
||||
// The nodes carry pieces hold: none joins its neighbour.
|
||||
ownMarks: Set<AdfNode>
|
||||
path: ConvertErrorPath
|
||||
pending: string
|
||||
pieces: Piece[]
|
||||
@@ -60,7 +67,7 @@ type Scan = {
|
||||
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 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 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 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> {
|
||||
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> {
|
||||
const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
|
||||
function scanInline(scan: Scan): Result<Scanned> {
|
||||
const { source } = scan
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
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)
|
||||
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)
|
||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||
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
|
||||
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
|
||||
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
|
||||
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value))
|
||||
}
|
||||
|
||||
function carryPiece(scan: Scan, node: AdfNode): Piece {
|
||||
scan.carried.add(node)
|
||||
scan.ownMarks.add(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.
|
||||
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))
|
||||
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, 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)
|
||||
scan.openingSpellableLink = true
|
||||
return undefined
|
||||
@@ -252,7 +274,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | 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.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
@@ -268,40 +290,44 @@ function pushNode(scan: Scan, node: AdfNode): void {
|
||||
scan.pieces.push({ kind: 'nodes', nodes: [node] })
|
||||
}
|
||||
|
||||
function assemble(scan: Scan): Result<InlineContent> {
|
||||
function assemble(scan: Scan): Result<Scanned> {
|
||||
const only = scan.pieces[0]
|
||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const resolved = resolveNodes(scan.pieces, scan, true)
|
||||
const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved
|
||||
const nodes = resolveNodes(scan.pieces, scan, true)
|
||||
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 })
|
||||
}
|
||||
|
||||
// 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[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
if (!isTextBreak(node, scan.carried)) {
|
||||
parted.push(node)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (!isTextBreak(item)) {
|
||||
parted.push(item)
|
||||
continue
|
||||
}
|
||||
const previous = nodes[index - 1]
|
||||
const next = nodes[index + 1]
|
||||
if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(next) || !readsAsOne(previous, next)) {
|
||||
const previous = items[index - 1]
|
||||
const next = items[index + 1]
|
||||
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 success(parted)
|
||||
}
|
||||
|
||||
function isTextBreak(node: AdfNode, carried: ReadonlySet<AdfNode>): boolean {
|
||||
return node.type === textBreakName && !carried.has(node)
|
||||
function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean {
|
||||
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 {
|
||||
@@ -317,8 +343,8 @@ function holdsLink(pieces: readonly Piece[]): boolean {
|
||||
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||
}
|
||||
|
||||
function marksLink(nodes: readonly AdfNode[]): boolean {
|
||||
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
|
||||
function marksLink(nodes: readonly Inline[]): boolean {
|
||||
return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
|
||||
}
|
||||
|
||||
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> {
|
||||
const nodes = resolveNodes(inner, scan, false)
|
||||
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)
|
||||
return success(nodes.value.map(altText).join(''))
|
||||
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(adfNodes(nodes.value).map(altText).join(''))
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 runs = delimiterRuns(pieces)
|
||||
const pairings = matchEmphasis(runs)
|
||||
writeUnpaired(nodes, runs, pairings)
|
||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||
return success(mergeText(nodes.flat(), scan.carried))
|
||||
return success(mergeText(nodes.flat(), readJoins(scan)))
|
||||
}
|
||||
|
||||
function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet<AdfNode>): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const node of nodes) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) {
|
||||
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
||||
// A text break is a wall: the text on either side joins only its own side.
|
||||
function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] {
|
||||
const merged: Inline[] = []
|
||||
let run: AdfNode[] = []
|
||||
for (const item of items) {
|
||||
if (!isTextBreak(item)) {
|
||||
run.push(item)
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
function pieceNodes(piece: Piece): AdfNode[] {
|
||||
function pieceNodes(piece: Piece): Inline[] {
|
||||
switch (piece.kind) {
|
||||
case 'break':
|
||||
return [{ type: textBreakName }]
|
||||
case 'carry':
|
||||
return [piece.node]
|
||||
case 'highlight':
|
||||
@@ -509,6 +537,8 @@ function pieceNodes(piece: Piece): AdfNode[] {
|
||||
return bracketNodes(piece)
|
||||
case 'run':
|
||||
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.
|
||||
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 tails = new Map<Run, number>()
|
||||
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).
|
||||
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) {
|
||||
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
||||
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
|
||||
}
|
||||
|
||||
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
|
||||
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] {
|
||||
const found: HighlightDelimiter[] = []
|
||||
let line = 0
|
||||
let position = 0
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
const held = nodes[index] ?? []
|
||||
const held = adfNodes(nodes[index] ?? [])
|
||||
const [holder] = held
|
||||
if (piece.kind === 'highlight' && holder !== undefined) {
|
||||
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.
|
||||
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 paired: { closer: number; opener: number }[] = []
|
||||
let closer = 0
|
||||
@@ -579,7 +609,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
|
||||
let candidate = found[closer]
|
||||
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
||||
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 })
|
||||
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.
|
||||
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()) {
|
||||
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) {
|
||||
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)
|
||||
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] }
|
||||
@@ -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).
|
||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
|
||||
return nodes.map((node) => {
|
||||
if (isTextBreak(node)) return node
|
||||
const marks = nodeMarks(node)
|
||||
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```\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', () => {
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
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('\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 { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
@@ -18,7 +17,7 @@ const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
|
||||
function read(markdown: string): readonly AdfNode[] | string {
|
||||
const parsed = plainMarkdownToAdf(markdown)
|
||||
if (!parsed.ok) return parsed.error.code
|
||||
const blocks = toEditorNormal(parsed.value).content ?? []
|
||||
const blocks = parsed.value.content ?? []
|
||||
const pending = [...blocks]
|
||||
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
|
||||
for (const child of block.content ?? []) pending.push(child)
|
||||
@@ -31,8 +30,8 @@ function read(markdown: string): readonly AdfNode[] | string {
|
||||
return blocks
|
||||
}
|
||||
|
||||
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
|
||||
return toEditorNormal(document(...blocks)).content ?? []
|
||||
function blocks(...content: AdfNode[]): readonly AdfNode[] {
|
||||
return content
|
||||
}
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
@@ -57,7 +56,7 @@ function bare(type: string, ...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 {
|
||||
@@ -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('> [!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.')))])
|
||||
})
|
||||
|
||||
@@ -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('.'))),
|
||||
])
|
||||
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]- [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]- <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]- [a b](u)\n'), normal(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('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, 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'), 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'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
|
||||
assert.deepEqual(read('> [!faq]- [a 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'), blocks(node('expand', { title: '@M (u) http://y' }, 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.'))])
|
||||
})
|
||||
|
||||
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'
|
||||
assert.deepEqual(read(markdown), normal(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('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, 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'), blocks(bare('bulletList', bare('listItem', bare('expand', 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', () => {
|
||||
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'))),
|
||||
])
|
||||
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')
|
||||
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||
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)
|
||||
}
|
||||
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.')))])
|
||||
const tasks = bare(
|
||||
'taskList',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
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).
|
||||
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 {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 54`
|
||||
`Next ID = 55`
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
|
||||
|
||||
Reference in New Issue
Block a user