diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 7b138c0..f0cf278 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -49,6 +49,16 @@ test('refuses a node whose carry nests too deep ahead of a later node in its lin assert.deepEqual(path(refused), ['content', 0, 'content', 0]) }) +test('refuses a carried run at the node whose carry nests too deep, among the run\'s own nodes', () => { + let deep: JsonValue = 'a' + for (let depth = 0; depth < largestNesting - 1; depth += 1) deep = { deep } + const carriedEm: AdfNode = { marks: [{ attrs: { deep }, type: 'em' }], text: 'x', type: 'text' } + const twiceStrong: AdfNode = { marks: [{ type: 'strong' }, { type: 'strong' }], text: 'y', type: 'text' } + const refused = adfToLosslessMarkdown(document(paragraph({ text: 'ok', type: 'text' }, carriedEm, twiceStrong))) + assert.equal(code(refused), 'unsupported-nesting-depth') + assert.deepEqual(path(refused), ['content', 0, 'content', 1]) +}) + test('refuses a value that is not an ADF document, naming the check it failed', () => { assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN') }) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 5b02f79..5d7abe4 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -234,13 +234,15 @@ function inlineRuns(nodes: readonly RunNode[], firstIndex: number, carried: Read for (const [offset, runNode] of nodes.entries()) { const index = firstIndex + offset const { node, outer } = runNode - const previous = runs[runs.length - 1] // A carried node spells its marks inside the carry. - const carriedNode = carries(node, carried, index) - const inside = carriedNode || previous?.kind !== 'marked' ? undefined : insideRun(runNode, previous.mark) - const mark = carriedNode ? undefined : nodeMarks(node)[outer] + const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[outer] + if (mark === undefined) { + runs.push({ index, kind: 'plain', node }) + continue + } + const previous = runs[runs.length - 1] + const inside = previous?.kind === 'marked' ? insideRun(runNode, previous.mark) : undefined if (previous?.kind === 'marked' && inside !== undefined) previous.nodes.push(inside) - else if (mark === undefined) runs.push({ index, kind: 'plain', node }) else runs.push({ index, kind: 'marked', mark, nodes: [{ node, outer: outer + 1 }] }) } return runs diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 4b0f3c5..5deef9c 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -465,6 +465,11 @@ test('writes what the editor-normal form of the document writes', () => { assert.equal(portable(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n') }) +test('keeps the code mark of a node past an opening link when the fallback drops the link\'s own', () => { + const link: AdfMark = { attrs: { href: 'u' }, type: 'link' } + assert.equal(portable(paragraph(text('x]: y', link, code), text('z', code))), '[x\\]: y](u)`z`\n') +}) + test('writes a text node holding content the same in either order beside its neighbour', () => { const holding: AdfNode = { content: [text('inner')], text: 'a', type: 'text' } assert.equal(portable(paragraph(holding, text('b'))), 'ab\n') diff --git a/src/markdown/portable/editor-normal.ts b/src/markdown/portable/editor-normal.ts index f9ad390..aa88f1a 100644 --- a/src/markdown/portable/editor-normal.ts +++ b/src/markdown/portable/editor-normal.ts @@ -1,6 +1,6 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { JsonValue } from '../../json-value.ts' -import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { joinsWhenRead } from '../adjacent-text.ts' type JsonContainer = JsonValue[] | { [key: string]: JsonValue } @@ -13,10 +13,6 @@ export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean return joinsWhenRead(normalNode(previous), normalNode(node)) } -export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean { - return identicalMark(normalMark(left), normalMark(right)) -} - 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 }] diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts index cb7843a..924bf03 100644 --- a/src/markdown/portable/inline-reduction.test.ts +++ b/src/markdown/portable/inline-reduction.test.ts @@ -37,6 +37,30 @@ test('reduces a line alike dropping every run a pass may or the first alone', () }) // Drops only the first run each pass finds, with every misflanked highlight, and takes no cap. +test('refuses an opening-link fallback whose link run holds no code span', () => { + const link = { attrs: { href: 'u' }, type: 'link' } + const lines: AdfNode[][] = [ + [{ marks: [link], text: 'a', type: 'text' }], + [{ marks: [link, { type: 'em' }], text: 'a', type: 'text' }], + [{ marks: [link], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: 'c', type: 'text' }], + ] + for (const line of lines) { + const refused = withoutFallback(line, { kind: 'opening-link' }, []) + assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape') + } +}) + +test('takes code off every node of an opening link\'s run that an opening-link fallback names', () => { + const link = { attrs: { href: 'u' }, type: 'link' } + const line: AdfNode[] = [{ marks: [link, { type: 'code' }], text: 'a', type: 'text' }, { marks: [link], text: 'b', type: 'text' }] + assert.deepEqual(withoutFallback(line, { kind: 'opening-link' }, []), success([{ marks: [link], text: 'a', type: 'text' }, { marks: [link], text: 'b', type: 'text' }])) +}) + +test('refuses an unspellable-run fallback that takes no mark', () => { + const refused = withoutFallback([{ marks: [{ type: 'em' }], text: 'a', type: 'text' }], { kind: 'unspellable-run', runs: [{ depth: 1, first: 0, last: 0 }] }, []) + assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape') +}) + function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Result { const leaves = portableLeaves(nodes, container, [], 0) if (!leaves.ok) return leaves diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index 055f51f..d76db68 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -3,11 +3,11 @@ import type { LineContainer } from '../line-container.ts' import type { MarkRun } from '../emit/line-escaping.ts' import { codeTextMarkTypes } from '../../adf/inline-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { imageAside } from '../image-aside.ts' import { insideRun, isEmphasis, type RunNode } from '../mark-spellings.ts' -import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' +import { joinsWhenEditorNormal } from './editor-normal.ts' import { largestNesting } from '../../nesting.ts' -import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts' import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts' import { spellDestination } from '../commonmark/link-syntax.ts' @@ -145,11 +145,11 @@ function highlightsInsideSharedMarks(leaves: readonly AdfNode[]): AdfNode[] { const marks = nodeMarks(leaf) if (marks[0]?.type === highlight) { const held = marks.slice(1) - shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMarkWhenEditorNormal(other, mark))) + shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => identicalMark(other, mark))) run.push(leaf) continue } - for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMarkWhenEditorNormal(other, mark)))])) + for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => identicalMark(other, mark)))])) run = [] spelled.push(leaf) } @@ -229,7 +229,7 @@ function sharedRuns(marks: readonly AdfMark[], neighbour: AdfNode): number { } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - // Terminates: each fallback takes a mark off some leaf, and trimmedEdges adds none to any character. + // Terminates: withoutFallback refuses a pass that takes no mark, and trimmedEdges adds none to any character. for (let current = leaves, pass = 1; ; pass += 1) { const fallback = portableLineFallback(current, container, path) if (!fallback.ok) return fallback @@ -260,7 +260,7 @@ function withoutEmphasis(leaves: readonly AdfNode[]): AdfNode[] { } // The runs one pass takes back may nest, so a leaf drops its mark at each depth a run over it names. -function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { +function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] | undefined { const depths = new Map() for (const run of runs) { for (let index = run.first; index <= run.last; index += 1) { @@ -269,8 +269,13 @@ function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): Adf else held.push(run.depth) } } - return leaves.map((leaf, index) => { + const kept = leaves.map((leaf, index) => { const dropped = depths.get(index) return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => !dropped.includes(held))) }) + return markCount(kept) < markCount(leaves) ? kept : undefined +} + +function markCount(leaves: readonly AdfNode[]): number { + return leaves.reduce((count, leaf) => count + nodeMarks(leaf).length, 0) } diff --git a/todo.md b/todo.md index 3df9f59..1057565 100644 --- a/todo.md +++ b/todo.md @@ -81,6 +81,8 @@ mention or code spelled `!adf:text{text=" "}`. Joining the run across the break `inline-line.ts` ruled worse with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback, first as a set of parted breaks, then as a flag the first fallback clears. +Once a run spans a node that doesn't hold its mark, `outer` and `depth` differ. Settle which one `tryLink`'s `depth + 1` and `MarkRun.depth` mean, and keep `MarkRun.depth`'s comment true. + ### 133. Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element. A writer panel picks the elements, classes and `data-*` attributes. Where a link marks a card, both