mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Give each markdown input its own reader: commonMarkToAdf, losslessMarkdownToAdf and portableMarkdownToAdf, each claiming only its flavour
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
try {
|
||||
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
|
||||
const { adfToLosslessMarkdown, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js')
|
||||
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
|
||||
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
|
||||
window.convertCorpus = (corpus) => ({
|
||||
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
||||
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
|
||||
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: losslessMarkdownToAdf(markdown) })),
|
||||
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(losslessMarkdownToAdf(markdown)) })),
|
||||
realPayloads: corpus.realPayloads.map(({ json, name }) => {
|
||||
const adf = JSON.parse(json)
|
||||
const emitted = adfToMarkdown(adf)
|
||||
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined }
|
||||
const emitted = adfToLosslessMarkdown(adf)
|
||||
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
|
||||
}),
|
||||
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||
const adf = JSON.parse(json)
|
||||
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) }
|
||||
return { emitted: adfToLosslessMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(losslessMarkdownToAdf(markdown)) }
|
||||
}),
|
||||
})
|
||||
} catch (cause) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { adfToMarkdown, adfToPlainMarkdown, isAdfDocument, markdownToAdf, plainMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
import { adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
|
||||
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
const emitted: Result<string> = adfToMarkdown(document)
|
||||
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
|
||||
const plainEmitted: Result<string> = adfToPlainMarkdown(document)
|
||||
const plainParsed: Result<AdfDocument, ParseError> = plainMarkdownToAdf('x\n')
|
||||
const emitted: Result<string> = adfToLosslessMarkdown(document)
|
||||
const parsed: Result<AdfDocument, ParseError> = losslessMarkdownToAdf('x\n')
|
||||
const portableEmitted: Result<string> = adfToPortableMarkdown(document)
|
||||
const portableParsed: Result<AdfDocument, ParseError> = portableMarkdownToAdf('x\n')
|
||||
const commonMarkParsed: Result<AdfDocument, ParseError> = commonMarkToAdf('x\n')
|
||||
const guarded: boolean = isAdfDocument(document)
|
||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
||||
|
||||
export const surface = { code, guarded, line, plainEmitted, plainParsed }
|
||||
export const surface = { code, commonMarkParsed, guarded, line, portableEmitted, portableParsed }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { adfToMarkdown, markdownToAdf } from '@larvit/adf-codec'
|
||||
import { adfToLosslessMarkdown, losslessMarkdownToAdf } from '@larvit/adf-codec'
|
||||
|
||||
const document = { content: [{ content: [{ marks: [{ type: 'em' }], text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
const emitted = adfToMarkdown(document)
|
||||
const emitted = adfToLosslessMarkdown(document)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : emitted.error.message)
|
||||
assert.deepEqual(markdownToAdf(emitted.value), { ok: true, value: document })
|
||||
assert.deepEqual(losslessMarkdownToAdf(emitted.value), { ok: true, value: document })
|
||||
|
||||
@@ -4,23 +4,14 @@ import test from 'node:test'
|
||||
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/plain/adf-to-plain-markdown.ts'
|
||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../markdown/plain/editor-normal.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts'
|
||||
import { losslessMarkdownToAdf, portableMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../markdown/portable/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
const renamedPrefix = '!adg:'
|
||||
|
||||
// Renaming the prefix changes what markdown reads only where a directive was read.
|
||||
function readsNoDirective(markdown: string): boolean {
|
||||
const read = markdownToAdf(markdown)
|
||||
const renamed = markdownToAdf(markdown.replaceAll(directivePrefix, renamedPrefix))
|
||||
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
|
||||
}
|
||||
|
||||
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
|
||||
// Each block's text, an expand's title and an image's alt and url, in document order: what the portable pair keeps.
|
||||
function shownText(nodes: readonly AdfNode[]): string[] {
|
||||
const shown: string[] = []
|
||||
for (const node of nodes) {
|
||||
@@ -37,9 +28,9 @@ function shownText(nodes: readonly AdfNode[]): string[] {
|
||||
test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const emitted = adfToMarkdown(document)
|
||||
const emitted = adfToLosslessMarkdown(document)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||
const read = markdownToAdf(emitted.value)
|
||||
const read = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
|
||||
}),
|
||||
@@ -47,26 +38,25 @@ test('a generated document emits markdown that reads back to it', { timeout: pro
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
|
||||
test('a generated document writes portable markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const written = adfToPlainMarkdown(document)
|
||||
const written = adfToPortableMarkdown(document)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
|
||||
const read = plainMarkdownToAdf(written.value)
|
||||
const read = portableMarkdownToAdf(written.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
||||
const reduced = reduceToPlain(document)
|
||||
const reduced = reduceToPortable(document)
|
||||
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
|
||||
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
||||
assert.deepEqual(adfToPortableMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
|
||||
test('a generated document writes the portable markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
|
||||
assert.deepEqual(adfToPortableMarkdown(document), adfToPortableMarkdown(toEditorNormal(document)))
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../adf/document.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus', 'commonmark-spec')
|
||||
const checks = ['count', 'fixpoint', 'text'] as const
|
||||
@@ -289,9 +289,9 @@ function concatenatedText(document: AdfNode): string {
|
||||
}
|
||||
|
||||
function fixpointRefused(example: SpecExample, document: AdfDocument): string | undefined {
|
||||
const emitted = adfToMarkdown(document)
|
||||
const emitted = adfToLosslessMarkdown(document)
|
||||
if (!emitted.ok) return emitted.error.code
|
||||
const again = markdownToAdf(emitted.value)
|
||||
const again = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(again.ok, `example ${example.example} emits markdown it cannot read back`)
|
||||
assert.deepEqual(again.value, document, `example ${example.example} does not hold its own round-trip`)
|
||||
return undefined
|
||||
@@ -312,7 +312,7 @@ function countMismatch(example: SpecExample, document: AdfDocument): string | un
|
||||
|
||||
for (const example of spec) {
|
||||
test(`CommonMark example ${example.example} => ${example.section}`, () => {
|
||||
const parse = markdownToAdf(example.markdown)
|
||||
const parse = commonMarkToAdf(example.markdown)
|
||||
const refused = exampleToRefusal.get(example.example)
|
||||
if (refused !== undefined) {
|
||||
assert.ok(!parse.ok, `example ${example.example} was expected to refuse with ${refused} but parsed`)
|
||||
|
||||
@@ -4,10 +4,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { isAdfDocument } from '../adf/document.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
|
||||
@@ -79,7 +79,7 @@ for (const directory of roundTripDirectories) {
|
||||
test(`${directory}/${name} emits its markdown byte for byte`, () => {
|
||||
const parsed: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
|
||||
const result = adfToMarkdown(parsed)
|
||||
const result = adfToLosslessMarkdown(parsed)
|
||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||
const expected = readFileSync(join(roundTripRoot, directory, `${name}.md`))
|
||||
const emitted = Buffer.from(result.value, 'utf8')
|
||||
@@ -94,7 +94,7 @@ for (const directory of roundTripDirectories) {
|
||||
test(`${directory}/${name} reads its markdown back to the document beside it`, () => {
|
||||
const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||
const result = losslessMarkdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||
assert.deepEqual(result.value, expected)
|
||||
})
|
||||
@@ -122,12 +122,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
||||
test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => {
|
||||
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||
const result = losslessMarkdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||
assert.deepEqual(result.value, expected)
|
||||
const emitted = adfToMarkdown(result.value)
|
||||
const emitted = adfToLosslessMarkdown(result.value)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||
const again = markdownToAdf(emitted.value)
|
||||
const again = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
||||
assert.deepEqual(again.value, expected)
|
||||
})
|
||||
@@ -141,9 +141,9 @@ for (const name of names(realPayloadsRoot, '.json')) {
|
||||
test(`real-payloads/${name} emits markdown that reads back to it`, () => {
|
||||
const payload: unknown = JSON.parse(readFileSync(join(realPayloadsRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`)
|
||||
const emitted = adfToMarkdown(payload)
|
||||
const emitted = adfToLosslessMarkdown(payload)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||
const parsed = markdownToAdf(emitted.value)
|
||||
const parsed = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
|
||||
assert.deepEqual(parsed.value, payload)
|
||||
})
|
||||
@@ -159,7 +159,7 @@ function lineStarting(markdown: string, offset: number): { line: number; offset:
|
||||
for (const name of pairedNames(errorsRoot, '.md', '.error')) {
|
||||
test(`errors/${name} is refused with the error it names, at a line of its own input`, () => {
|
||||
const markdown = readFileSync(join(errorsRoot, `${name}.md`), 'utf8')
|
||||
const result = markdownToAdf(markdown)
|
||||
const result = losslessMarkdownToAdf(markdown)
|
||||
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
|
||||
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
|
||||
const { position } = result.error
|
||||
|
||||
@@ -8,10 +8,11 @@ import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { Result } from '../result.ts'
|
||||
import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { blockArgument, listBreakName, marksAttribute } from '../markdown/block-directive.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { carryName } from '../markdown/opaque-carry.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import {
|
||||
directivePrefix,
|
||||
spellAttributes,
|
||||
@@ -27,7 +28,6 @@ import { fencedCodeBlock } from '../markdown/commonmark/backtick-runs.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { markSpelling } from '../markdown/mark-spellings.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||
@@ -382,7 +382,7 @@ const cleanMarkdown = markdownOf(false)
|
||||
const hostileMarkdown = markdownOf(true)
|
||||
|
||||
const canonical = adfDocument
|
||||
.map((document) => adfToMarkdown(document))
|
||||
.map((document) => adfToLosslessMarkdown(document))
|
||||
.filter((emitted): emitted is Extract<Result<string>, { ok: true }> => emitted.ok)
|
||||
.map((emitted) => emitted.value)
|
||||
|
||||
@@ -424,16 +424,16 @@ test('generated markdown refuses, or its spelling reads back and spells itself',
|
||||
let fixpoints = 0
|
||||
fc.assert(
|
||||
fc.property(document, (input) => {
|
||||
const parsed = markdownToAdf(input)
|
||||
const parsed = losslessMarkdownToAdf(input)
|
||||
if (!parsed.ok) return
|
||||
const emitted = adfToMarkdown(parsed.value)
|
||||
const emitted = adfToLosslessMarkdown(parsed.value)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
|
||||
fixpoints += 1
|
||||
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||
const read = markdownToAdf(emitted.value)
|
||||
const read = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
|
||||
const respelled = adfToMarkdown(read.value)
|
||||
const respelled = adfToLosslessMarkdown(read.value)
|
||||
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
|
||||
assert.equal(respelled.value, emitted.value)
|
||||
}),
|
||||
@@ -444,3 +444,18 @@ test('generated markdown refuses, or its spelling reads back and spells itself',
|
||||
assert.ok(directiveShaped >= directiveShapedFloor, `${directiveShaped} runs reaching the fixpoint held a node or mark outside CommonMark's own types, under the floor of ${directiveShapedFloor}`)
|
||||
})
|
||||
|
||||
|
||||
test('generated markdown read as CommonMark refuses, or its document reads back through the lossless pair', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(document, (input) => {
|
||||
const parsed = commonMarkToAdf(input)
|
||||
if (!parsed.ok) return
|
||||
const emitted = adfToLosslessMarkdown(parsed.value)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
|
||||
const read = losslessMarkdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||
export type { JsonValue } from './json-value.ts'
|
||||
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
|
||||
export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts'
|
||||
export { commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
export { isAdfDocument } from './adf/document.ts'
|
||||
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Claims } from './portable/conventions.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { carryFenceType } from './opaque-carry.ts'
|
||||
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||
@@ -5,8 +6,8 @@ import { infoStringCarries } from './commonmark/grammar.ts'
|
||||
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
||||
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
||||
export function languageSlot(language: JsonValue | undefined, claims: Claims): LanguageSlot {
|
||||
if (language === undefined) return { kind: 'none' }
|
||||
if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
|
||||
if (typeof language !== 'string' || (claims.carryFence && carryFenceType(language) !== undefined) || !infoStringCarries(language)) return { kind: 'attribute' }
|
||||
return { info: language, kind: 'fence' }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
||||
import { adfToLosslessMarkdown, losslessMarkdownToAdf } from '../../index.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
@@ -34,44 +34,44 @@ function position(result: Result<string>): unknown {
|
||||
test('names the node a refusal came from, and no source the emitter never read', () => {
|
||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||
assert.deepEqual(path(adfToLosslessMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToLosslessMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToLosslessMarkdown({ type: 'doc', version: 2 })), [])
|
||||
assert.equal(position(adfToLosslessMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||
})
|
||||
|
||||
test('refuses a value that is not an ADF document, naming the check it failed', () => {
|
||||
assert.equal(markdown(adfToMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
|
||||
assert.equal(markdown(adfToLosslessMarkdown({ type: 'doc', version: Number.NaN })), 'not-an-adf-document: an ADF document holds a version number: found NaN')
|
||||
})
|
||||
|
||||
test('refuses a document version the markdown cannot carry', () => {
|
||||
assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
|
||||
assert.equal(code(adfToLosslessMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
|
||||
})
|
||||
|
||||
test('carries a text node attribute no spelling holds', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))),
|
||||
'!adf:carry{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '!adf:paragraph {localId=a}\n!adf:/paragraph\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), '!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), '!adf:hardBreak{localId=a}x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n!adf:/blockquote\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), '!adf:listItem\n!adf:/listItem\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '!adf:paragraph {localId=a}\n!adf:/paragraph\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), '!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), '!adf:hardBreak{localId=a}x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n!adf:/blockquote\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'listItem' }))), '!adf:listItem\n!adf:/listItem\n')
|
||||
})
|
||||
|
||||
test('spells an ordered list from the order attribute its first marker is', () => {
|
||||
const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }]
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '!adf:orderedList\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), '1. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: items, type: 'orderedList' }))), '!adf:orderedList\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), '1. x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. x\n')
|
||||
})
|
||||
|
||||
test('spells a code block language no info string holds as an attribute', () => {
|
||||
const language = (value: string): string => markdown(adfToMarkdown(document({ attrs: { language: value }, type: 'codeBlock' })))
|
||||
const language = (value: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { language: value }, type: 'codeBlock' })))
|
||||
assert.equal(language(''), '!adf:codeBlock {language=""}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(language('a`b'), '!adf:codeBlock {language="a\\u0060b"}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(language(' sql'), '!adf:codeBlock {language=" sql"}\n```\n```\n!adf:/codeBlock\n')
|
||||
@@ -82,7 +82,7 @@ test('spells a code block language no info string holds as an attribute', () =>
|
||||
})
|
||||
|
||||
test('spells a link destination CommonMark cannot as the directive link', () => {
|
||||
const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
const link = (href: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
assert.equal(link('https://example.com/a b>c'), '!adf:link[t]{href="https://example.com/a b>c"}\n')
|
||||
assert.equal(link('<https://example.com/'), '!adf:link[t]{href="\\u003chttps://example.com/"}\n')
|
||||
assert.equal(link('https://example.com/a\\b'), '!adf:link[t]{href="https://example.com/a\\\\b"}\n')
|
||||
@@ -90,12 +90,12 @@ test('spells a link destination CommonMark cannot as the directive link', () =>
|
||||
assert.equal(link('https://example.com/a\nb'), '!adf:link[t]{href="https://example.com/a\\nb"}\n')
|
||||
const entity = 'https://example.com/?a=1&b=2'
|
||||
const autolinkShaped = document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' }))
|
||||
assert.equal(markdown(adfToMarkdown(autolinkShaped)), '!adf:link[https://example.com/?a=1\\&b=2]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(autolinkShaped)), '!adf:link[https://example.com/?a=1\\&b=2]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
|
||||
})
|
||||
|
||||
test('spells a link carrying an attribute CommonMark cannot as the directive link, and refuses none', () => {
|
||||
const link = (attrs: Record<string, string>): string =>
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'link' }], text: 't', type: 'text' }))))
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs, type: 'link' }], text: 't', type: 'text' }))))
|
||||
assert.equal(link({ collection: 'c', href: 'https://example.com/' }), '!adf:link[t]{collection=c href="https://example.com/"}\n')
|
||||
assert.equal(link({ href: 'https://example.com/', id: 'i' }), '!adf:link[t]{href="https://example.com/" id=i}\n')
|
||||
assert.equal(link({ href: 'https://example.com/', occurrenceKey: 'k' }), '!adf:link[t]{href="https://example.com/" occurrenceKey=k}\n')
|
||||
@@ -103,7 +103,7 @@ test('spells a link carrying an attribute CommonMark cannot as the directive lin
|
||||
})
|
||||
|
||||
test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => {
|
||||
const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
const link = (href: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
assert.equal(link('https://en.example.com/a_(b)'), '[t](https://en.example.com/a_(b))\n')
|
||||
assert.equal(link('https://example.com/a)b'), '[t](https://example.com/a\\)b)\n')
|
||||
assert.equal(link('https://example.com/a(b'), '[t](https://example.com/a\\(b)\n')
|
||||
@@ -113,7 +113,7 @@ test('escapes the parenthesis a link destination leaves unbalanced, and no other
|
||||
|
||||
test('escapes the quote a link title holds, and spells the rest as the directive link', () => {
|
||||
const titled = (title: string): string =>
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))))
|
||||
assert.equal(titled('He said "hi"'), '[t](https://example.com/ "He said \\"hi\\"")\n')
|
||||
assert.equal(titled('a\nb'), '!adf:link[t]{href="https://example.com/" title="a\\nb"}\n')
|
||||
assert.equal(titled('a\\b'), '!adf:link[t]{href="https://example.com/" title="a\\\\b"}\n')
|
||||
@@ -121,7 +121,7 @@ test('escapes the quote a link title holds, and spells the rest as the directive
|
||||
})
|
||||
|
||||
test('carries a link mark the link spelling cannot write', () => {
|
||||
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
|
||||
const carried = (mark: AdfMark): string => markdown(adfToLosslessMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
|
||||
assert.equal(
|
||||
carried({ attrs: { href: 'x', rel: 'y' }, type: 'link' }),
|
||||
'!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"rel\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
|
||||
@@ -131,7 +131,7 @@ test('carries a link mark the link spelling cannot write', () => {
|
||||
})
|
||||
|
||||
test('carries a mark the canonical spellings cannot nest', () => {
|
||||
const carried = (...marks: AdfMark[]): string => markdown(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' }))))
|
||||
const carried = (...marks: AdfMark[]): string => markdown(adfToLosslessMarkdown(document(paragraph({ marks, text: 'x', type: 'text' }))))
|
||||
assert.equal(carried({ type: 'annotation' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"annotation\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n')
|
||||
assert.equal(
|
||||
carried({ type: 'code' }, { type: 'strong' }),
|
||||
@@ -148,8 +148,8 @@ test('carries a mark the canonical spellings cannot nest', () => {
|
||||
})
|
||||
|
||||
function readsBack(doc: AdfDocument): string {
|
||||
const spelled = markdown(adfToMarkdown(doc))
|
||||
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
|
||||
const spelled = markdown(adfToLosslessMarkdown(doc))
|
||||
assert.deepEqual(losslessMarkdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
|
||||
return spelled
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ test('spells a code span opening a line, whose backticks open no fence', () => {
|
||||
})
|
||||
|
||||
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
||||
const broken = (second: string): string => markdown(adfToMarkdown(document(paragraph({ text: 'a | b', type: 'text' }, { type: 'hardBreak' }, { text: second, type: 'text' }))))
|
||||
const broken = (second: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text: 'a | b', type: 'text' }, { type: 'hardBreak' }, { text: second, type: 'text' }))))
|
||||
assert.equal(broken('--- | ---'), 'a | b\\\n\\--- | ---\n')
|
||||
assert.equal(broken(':--- | ---:'), 'a | b\\\n\\:--- | ---:\n')
|
||||
assert.equal(broken('c | d'), 'a | b\\\nc | d\n')
|
||||
@@ -168,39 +168,39 @@ test('escapes the delimiter row a hard break leaves opening a pipe table with no
|
||||
|
||||
test('parts two adjacent lists of the same kind, the marker spelling being what merges', () => {
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(list, list))), '- x\n\n!adf:listBreak\n\n- x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(list, list))), '- x\n\n!adf:listBreak\n\n- x\n')
|
||||
const ordered: AdfNode = { attrs: { order: 1 }, content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }], type: 'orderedList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(ordered, ordered))), '1. x\n\n!adf:listBreak\n\n1. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), '!adf:panel info\n- x\n!adf:listBreak\n- x\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(ordered, ordered))), '1. x\n\n!adf:listBreak\n\n1. x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { panelType: 'info' }, content: [list, list], type: 'panel' }))), '!adf:panel info\n- x\n!adf:listBreak\n- x\n!adf:/panel\n')
|
||||
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
|
||||
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
|
||||
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
|
||||
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
|
||||
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
|
||||
assert.ok(markdown(adfToLosslessMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
|
||||
assert.ok(markdown(adfToLosslessMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
|
||||
assert.ok(markdown(adfToLosslessMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
|
||||
})
|
||||
|
||||
test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'a&b' }))), '```adf:\n{\n "type": "a&b"\n}\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'a&b' }))), '```adf:\n{\n "type": "a&b"\n}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
|
||||
})
|
||||
|
||||
test('spells the code block whose language opens with the reserved info string prefix', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
|
||||
})
|
||||
|
||||
test('breaks a mark run at the node it carries', () => {
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
const carried: AdfNode = { marks: [strong], type: 'placeholder' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [strong], text: 'a', type: 'text' }, carried, { marks: [strong], text: 'b', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [strong], text: 'a', type: 'text' }, carried, { marks: [strong], text: 'b', type: 'text' })))),
|
||||
'**a**!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"placeholder\\"}"}**b**\n',
|
||||
)
|
||||
})
|
||||
@@ -208,52 +208,52 @@ test('breaks a mark run at the node it carries', () => {
|
||||
test('refuses a carried node nested deeper than the levels its position leaves', () => {
|
||||
let node: AdfNode = { type: 'blockCard' }
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockCard' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(node))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(paragraph(node)))), 'unsupported-nesting-depth')
|
||||
let shallow: AdfNode = { type: 'blockCard' }
|
||||
for (let depth = 0; depth < 200; depth += 1) shallow = { content: [shallow], type: 'blockCard' }
|
||||
assert.ok(adfToMarkdown(document(shallow)).ok)
|
||||
assert.ok(adfToLosslessMarkdown(document(shallow)).ok)
|
||||
let quoted: AdfNode = shallow
|
||||
for (let depth = 0; depth < 150; depth += 1) quoted = { content: [quoted], type: 'blockquote' }
|
||||
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
||||
})
|
||||
|
||||
test('carries a code block holding a node no fence holds', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
|
||||
for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
|
||||
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
|
||||
assert.ok(markdown(adfToLosslessMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
|
||||
}
|
||||
})
|
||||
|
||||
test('spells a list its own content shape cannot hold as a directive', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
|
||||
})
|
||||
|
||||
test('spells an ordered list no marker fits as a directive', () => {
|
||||
const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }
|
||||
const list = (order: number, items: number): AdfDocument =>
|
||||
document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' })
|
||||
assert.equal(markdown(adfToMarkdown(list(1.5, 1))), '!adf:orderedList {order="1.5"}\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToMarkdown(list(999999999, 1))), '999999999. x\n')
|
||||
assert.equal(markdown(adfToMarkdown(list(999999999, 2))), '!adf:orderedList {order=999999999}\n!adf:listItem\nx\n!adf:/listItem\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(list(1.5, 1))), '!adf:orderedList {order="1.5"}\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(list(999999999, 1))), '999999999. x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(list(999999999, 2))), '!adf:orderedList {order=999999999}\n!adf:listItem\nx\n!adf:/listItem\n!adf:listItem\nx\n!adf:/listItem\n!adf:/orderedList\n')
|
||||
})
|
||||
|
||||
test('carries a code mark over anything but text', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))),
|
||||
'!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"code\\"}],\\"type\\":\\"hardBreak\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('spells a heading level no ATX heading fits as a directive', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading {level=7}\nx\n!adf:/heading\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading\nx\n!adf:/heading\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading {level=7}\nx\n!adf:/heading\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading\nx\n!adf:/heading\n')
|
||||
})
|
||||
|
||||
test('escapes only text that would otherwise open a construct', () => {
|
||||
const emitted = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
const emitted = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
assert.equal(emitted('<div>'), '\\<div>\n')
|
||||
assert.equal(emitted('<div'), '\\<div\n')
|
||||
assert.equal(emitted('<div and more'), '\\<div and more\n')
|
||||
@@ -272,8 +272,8 @@ test('escapes only text that would otherwise open a construct', () => {
|
||||
assert.equal(emitted('a <!-- b'), 'a <!-- b\n')
|
||||
assert.equal(emitted('a <!-- b --> c'), 'a \\<!-- b --> c\n')
|
||||
const later = paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, { text: '<div', type: 'text' })
|
||||
assert.equal(markdown(adfToMarkdown(document(later))), 'a\\\n\\<div\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph({ text: '<!-- x', type: 'text' })], type: 'blockquote' }))), '> \\<!-- x\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(later))), 'a\\\n\\<div\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph({ text: '<!-- x', type: 'text' })], type: 'blockquote' }))), '> \\<!-- x\n')
|
||||
assert.equal(emitted('| a | b |'), '\\| a | b |\n')
|
||||
assert.equal(emitted('!adf:mention[@x]{id=1}'), '\\!adf:mention[@x]{id=1}\n')
|
||||
assert.equal(emitted('!adf: completes nothing'), '\\!adf: completes nothing\n')
|
||||
@@ -293,14 +293,14 @@ test('escapes only text that would otherwise open a construct', () => {
|
||||
})
|
||||
|
||||
test('escapes a heading closing sequence', () => {
|
||||
const heading = (text: string): string => markdown(adfToMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
|
||||
const heading = (text: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' })))
|
||||
assert.equal(heading('done #'), '## done \\#\n')
|
||||
assert.equal(heading('#tag first'), '## #tag first\n')
|
||||
})
|
||||
|
||||
test('wraps adjacent nodes carrying one mark once, and a differing mark twice', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'strong' }, { type: 'em' })), '**a*b***\n')
|
||||
assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'em' })), '**a**_b_\n')
|
||||
const link = (href: string): AdfMark => ({ attrs: { href }, type: 'link' })
|
||||
@@ -309,7 +309,7 @@ test('wraps adjacent nodes carrying one mark once, and a differing mark twice',
|
||||
|
||||
test('carries a mark spelling that cannot open or close where it sits', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
assert.equal(
|
||||
emitted({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' }),
|
||||
@@ -336,7 +336,7 @@ test('carries a mark spelling that cannot open or close where it sits', () => {
|
||||
|
||||
test('refuses a node carrying one mark type twice', () => {
|
||||
const em: AdfMark = { type: 'em' }
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('parts a nested list the tight spelling would swallow from the block above it', () => {
|
||||
@@ -344,19 +344,19 @@ test('parts a nested list the tight spelling would swallow from the block above
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
||||
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
|
||||
assert.equal(markdown(adfToMarkdown(outer(text('a'), ordered))), '- a\n\n 2. b\n')
|
||||
assert.equal(markdown(adfToMarkdown(outer(text('a'), { ...ordered, attrs: { order: 1 } }))), '- a\n 1. b\n')
|
||||
assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), '- a\n\n -\n')
|
||||
assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(outer(text('a'), ordered))), '- a\n\n 2. b\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(outer(text('a'), { ...ordered, attrs: { order: 1 } }))), '- a\n 1. b\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), '- a\n\n -\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n')
|
||||
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
|
||||
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
|
||||
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
|
||||
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
|
||||
assert.ok(markdown(adfToLosslessMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
|
||||
})
|
||||
|
||||
test('refuses marks and attributes nested deeper than the emitter carries', () => {
|
||||
const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' }))
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
||||
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
||||
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
||||
const deeper = (key: string, type: string): string =>
|
||||
@@ -374,31 +374,31 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
||||
type: 'panel',
|
||||
})
|
||||
const roundTrips = (node: AdfNode): void => {
|
||||
const spelled = adfToMarkdown(document(node))
|
||||
const spelled = adfToLosslessMarkdown(document(node))
|
||||
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||
const read = markdownToAdf(spelled.value)
|
||||
const read = losslessMarkdownToAdf(spelled.value)
|
||||
assert.ok(read.ok, read.ok ? '' : read.error.message)
|
||||
assert.deepEqual(read.value, document(node))
|
||||
}
|
||||
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
|
||||
`unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels its position leaves`,
|
||||
)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
||||
assert.deepEqual(path(adfToLosslessMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
||||
roundTrips(paragraph(card(largestNesting)))
|
||||
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('marks', 'panel'))
|
||||
assert.deepEqual(path(adfToMarkdown(document(marked(largestNesting - 2)))), ['content', 0])
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(marked(largestNesting - 2)))), deeper('marks', 'panel'))
|
||||
assert.deepEqual(path(adfToLosslessMarkdown(document(marked(largestNesting - 2)))), ['content', 0])
|
||||
const markedCode: AdfNode = { content: [{ text: 'x', type: 'text' }], marks: [{ attrs: { deep: nested(largestNesting - 2) }, type: 'em' }], type: 'codeBlock' }
|
||||
assert.equal(markdown(adfToMarkdown(document(markedCode))), deeper('marks', 'codeBlock'))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(markedCode))), deeper('marks', 'codeBlock'))
|
||||
roundTrips(marked(largestNesting - 3))
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted(marked('a_', { type: 'em' })), '_a\\__\n')
|
||||
assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n')
|
||||
assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n')
|
||||
@@ -423,14 +423,14 @@ test('escapes a literal delimiter that would merge with an emitted one', () => {
|
||||
})
|
||||
|
||||
test('escapes a literal delimiter run that only closes', () => {
|
||||
const emitted = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
const emitted = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text, type: 'text' }))))
|
||||
assert.equal(emitted('a* b'), 'a\\* b\n')
|
||||
assert.equal(emitted('2 * 3'), '2 * 3\n')
|
||||
})
|
||||
|
||||
test("spells a mark run CommonMark's matching pairs as written", () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
assert.equal(emitted({ text: 'un', type: 'text' }, marked('a', em, strong), { text: 'istic', type: 'text' }), 'un***a***istic\n')
|
||||
@@ -442,24 +442,24 @@ test("spells a mark run CommonMark's matching pairs as written", () => {
|
||||
})
|
||||
|
||||
test('escapes a hyphen underline a hard break would expose', () => {
|
||||
const line = (text: string): string => markdown(adfToMarkdown(document(paragraph({ text: 'foo', type: 'text' }, { type: 'hardBreak' }, { text, type: 'text' }))))
|
||||
const line = (text: string): string => markdown(adfToLosslessMarkdown(document(paragraph({ text: 'foo', type: 'text' }, { type: 'hardBreak' }, { text, type: 'text' }))))
|
||||
assert.equal(line('--'), 'foo\\\n\\--\n')
|
||||
assert.equal(line('=='), 'foo\\\n\\==\n')
|
||||
})
|
||||
|
||||
test('spells a list item whose marker completes a thematic break as a directive', () => {
|
||||
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
|
||||
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n- -\n!adf:/listItem\n!adf:/bulletList\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(nested))), '- -\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n- -\n!adf:/listItem\n!adf:/bulletList\n')
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document({ content: [item(paragraph({ text: 'a', type: 'text' }), nested), item({ type: 'rule' })], type: 'bulletList' }))),
|
||||
markdown(adfToLosslessMarkdown(document({ content: [item(paragraph({ text: 'a', type: 'text' }), nested), item({ type: 'rule' })], type: 'bulletList' }))),
|
||||
'!adf:bulletList\n!adf:listItem\na\n\n- -\n!adf:/listItem\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n',
|
||||
)
|
||||
const spaced: AdfNode = { content: [{ text: 'a\n \nb', type: 'text' }], type: 'codeBlock' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document({ attrs: { order: 3 }, content: [item(spaced)], type: 'orderedList' }))),
|
||||
markdown(adfToLosslessMarkdown(document({ attrs: { order: 3 }, content: [item(spaced)], type: 'orderedList' }))),
|
||||
'!adf:orderedList {order=3}\n!adf:listItem\n```\na\n \nb\n```\n!adf:/listItem\n!adf:/orderedList\n',
|
||||
)
|
||||
})
|
||||
@@ -489,23 +489,23 @@ test('carries a text node holding no text, or holding content', () => {
|
||||
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||
const em = { type: 'em' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))),
|
||||
'!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"hardBreak\\"}"}b\n',
|
||||
)
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))),
|
||||
'!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"\u00a0a\\",\\"type\\":\\"text\\"}"}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('pads a code span whose edges CommonMark would strip', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
||||
})
|
||||
|
||||
test('spells a code span per code-marked node, parted by the text break', () => {
|
||||
const code_ = { type: 'code' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
||||
markdown(adfToLosslessMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
||||
'`a`!adf:textBreak{}`b`\n',
|
||||
)
|
||||
})
|
||||
@@ -513,47 +513,47 @@ test('spells a code span per code-marked node, parted by the text break', () =>
|
||||
test('refuses a document nested deeper than the emitter carries', () => {
|
||||
let node: AdfNode = paragraph({ text: 'x', type: 'text' })
|
||||
for (let depth = 0; depth < 600; depth += 1) node = { content: [node], type: 'blockquote' }
|
||||
assert.equal(code(adfToMarkdown(document(node))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document(node))), 'unsupported-nesting-depth')
|
||||
let carried: AdfNode = paragraph({ text: 'x', type: 'text' })
|
||||
for (let depth = 0; depth < 500; depth += 1) carried = { content: [carried], type: 'blockquote' }
|
||||
assert.ok(adfToMarkdown(document(carried)).ok)
|
||||
assert.ok(adfToLosslessMarkdown(document(carried)).ok)
|
||||
const listed = (levels: number, first: readonly AdfNode[]): AdfNode => {
|
||||
let list: AdfNode = { content: [{ content: [...first], type: 'listItem' }], type: 'bulletList' }
|
||||
for (let level = 1; level < levels; level += 1) list = { content: [{ content: [...first, list], type: 'listItem' }], type: 'bulletList' }
|
||||
return list
|
||||
}
|
||||
assert.ok(adfToMarkdown(document(listed(largestNesting, [paragraph({ text: 'x', type: 'text' })]))).ok)
|
||||
assert.equal(code(adfToMarkdown(document(listed(largestNesting + 1, [paragraph({ text: 'x', type: 'text' })])))), 'unsupported-nesting-depth')
|
||||
assert.ok(adfToLosslessMarkdown(document(listed(largestNesting, [paragraph({ text: 'x', type: 'text' })]))).ok)
|
||||
assert.equal(code(adfToLosslessMarkdown(document(listed(largestNesting + 1, [paragraph({ text: 'x', type: 'text' })])))), 'unsupported-nesting-depth')
|
||||
const directiveLists = largestNesting / 2
|
||||
const deep = document(listed(directiveLists, [{ type: 'rule' }]))
|
||||
assert.deepEqual(markdownToAdf(markdown(adfToMarkdown(deep))), { ok: true, value: deep })
|
||||
assert.equal(code(adfToMarkdown(document(listed(directiveLists + 1, [{ type: 'rule' }])))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [listed(directiveLists, [{ type: 'rule' }])], type: 'panel' }))), 'unsupported-nesting-depth')
|
||||
assert.deepEqual(losslessMarkdownToAdf(markdown(adfToLosslessMarkdown(deep))), { ok: true, value: deep })
|
||||
assert.equal(code(adfToLosslessMarkdown(document(listed(directiveLists + 1, [{ type: 'rule' }])))), 'unsupported-nesting-depth')
|
||||
assert.equal(code(adfToLosslessMarkdown(document({ content: [listed(directiveLists, [{ type: 'rule' }])], type: 'panel' }))), 'unsupported-nesting-depth')
|
||||
const chain = (levels: number): AdfNode => {
|
||||
let card: AdfNode = { type: 'blockCard' }
|
||||
for (let level = 0; level < levels; level += 1) card = { content: [card], type: 'blockCard' }
|
||||
return card
|
||||
}
|
||||
const carriedInItem = (levels: number): AdfDocument => document(listed(1, [{ type: 'rule' }, chain(levels)]))
|
||||
assert.deepEqual(markdownToAdf(markdown(adfToMarkdown(carriedInItem(248)))), { ok: true, value: carriedInItem(248) })
|
||||
assert.equal(code(adfToMarkdown(carriedInItem(249))), 'unsupported-nesting-depth')
|
||||
assert.deepEqual(losslessMarkdownToAdf(markdown(adfToLosslessMarkdown(carriedInItem(248)))), { ok: true, value: carriedInItem(248) })
|
||||
assert.equal(code(adfToLosslessMarkdown(carriedInItem(249))), 'unsupported-nesting-depth')
|
||||
})
|
||||
|
||||
test('emits an empty list item without trailing whitespace', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n')
|
||||
})
|
||||
|
||||
test('spells a block directive as its node type, arg and attributes', () => {
|
||||
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
|
||||
})
|
||||
|
||||
test('carries a directive attribute no section spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||
const carried = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(node)))
|
||||
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
|
||||
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
|
||||
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
|
||||
@@ -572,7 +572,7 @@ test('carries a directive attribute no section spells', () => {
|
||||
})
|
||||
|
||||
test('carries an arg slot value no bare token spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||
const carried = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(node)))
|
||||
assert.equal(
|
||||
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
||||
'```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
|
||||
@@ -582,35 +582,35 @@ test('carries an arg slot value no bare token spells', () => {
|
||||
|
||||
test('carries a block node mark in the reserved attribute', () => {
|
||||
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
|
||||
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
|
||||
})
|
||||
|
||||
test('refuses the content a directive body has no room for', () => {
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a media holds no content: this one holds some')
|
||||
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a media holds no content: this one holds some')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text')
|
||||
})
|
||||
|
||||
test('separates blocks in a container body by a blank line only where a directive line is not separation already', () => {
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), '!adf:panel info\na\n\nb\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel(text('a'), text('b')))), '!adf:panel info\na\n\nb\n!adf:/panel\n')
|
||||
const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), caption))), '!adf:panel info\na\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, text('a')))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\na\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel(paragraph(), text('a')))), '!adf:panel info\n!adf:paragraph\n!adf:/paragraph\na\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel(caption, caption))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel(text('a'), caption))), '!adf:panel info\na\n!adf:caption\nc\n!adf:/caption\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel(caption, text('a')))), '!adf:panel info\n!adf:caption\nc\n!adf:/caption\na\n!adf:/panel\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(panel(paragraph(), text('a')))), '!adf:panel info\n!adf:paragraph\n!adf:/paragraph\na\n!adf:/panel\n')
|
||||
})
|
||||
|
||||
test('spells the image form for exactly the centered external media shape', () => {
|
||||
const url = 'https://example.com/moon.png'
|
||||
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
|
||||
document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
|
||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
|
||||
const fallback = (attrs: AdfAttributes): boolean => markdown(adfToMarkdown(single(attrs))).startsWith('!adf:mediaSingle {layout=center}')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToLosslessMarkdown(single({ type: 'external', url }))), `\n`)
|
||||
assert.equal(markdown(adfToLosslessMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
|
||||
const fallback = (attrs: AdfAttributes): boolean => markdown(adfToLosslessMarkdown(single(attrs))).startsWith('!adf:mediaSingle {layout=center}')
|
||||
assert.ok(fallback({ alt: '', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 'a\nb', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url }))
|
||||
@@ -618,17 +618,17 @@ test('spells the image form for exactly the centered external media shape', () =
|
||||
assert.ok(fallback({ alt: ' moon ', type: 'external', url }))
|
||||
assert.ok(fallback({ alt: 4, type: 'external', url }))
|
||||
assert.ok(fallback({ type: 'external', url: 4 }))
|
||||
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToLosslessMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
|
||||
const media: AdfNode = { attrs: { type: 'external', url }, type: 'media' }
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToLosslessMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
assert.equal(code(adfToLosslessMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a mediaSingle the image form does not fit as a directive', () => {
|
||||
const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' }
|
||||
const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' }
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(single))),
|
||||
markdown(adfToLosslessMarkdown(document(single))),
|
||||
'!adf:mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n!adf:media {type=external url="https://example.com/moon.png"}\n!adf:/mediaSingle\n',
|
||||
)
|
||||
})
|
||||
@@ -640,34 +640,34 @@ test('spells a table as a pipe table only where every row and cell is plain', ()
|
||||
const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' })
|
||||
const directive = (result: Result<string>): boolean => markdown(result).startsWith('!adf:table')
|
||||
const header = row(cell('tableHeader', text('Part')))
|
||||
assert.equal(markdown(adfToMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n')
|
||||
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n')
|
||||
assert.ok(directive(adfToMarkdown(table())))
|
||||
assert.ok(directive(adfToMarkdown(table(row()))))
|
||||
assert.ok(directive(adfToMarkdown(table({ type: 'tableRow' }))))
|
||||
assert.ok(directive(adfToMarkdown(table(header, row()))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableCell', text('Bolt M8')))))))
|
||||
assert.ok(directive(adfToMarkdown(table(cell('tableHeader', text('Part'))))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n')
|
||||
assert.ok(directive(adfToLosslessMarkdown(table())))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row()))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table({ type: 'tableRow' }))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(header, row()))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableCell', text('Bolt M8')))))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(cell('tableHeader', text('Part'))))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableHeader'))))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))))
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ attrs: { url: 'a|b' }, type: 'blockCard' }], type: 'paragraph' }))))),
|
||||
markdown(adfToLosslessMarkdown(table(row(cell('tableHeader', { content: [{ attrs: { url: 'a|b' }, type: 'blockCard' }], type: 'paragraph' }))))),
|
||||
'| !adf:carry{json="{\\"attrs\\":{\\"url\\":\\"a\\u007cb\\"},\\"type\\":\\"blockCard\\"}"} |\n| --- |\n',
|
||||
)
|
||||
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: '\fa', type: 'text' }], type: 'paragraph' }))))), '| \fa |\n| --- |\n')
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
||||
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| !adf:text{text=" "}a |\n| --- |\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(table(row(cell('tableHeader', { content: [{ text: '\fa', type: 'text' }], type: 'paragraph' }))))), '| \fa |\n| --- |\n')
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), '| !adf:text{text=" "}a |\n| --- |\n')
|
||||
const marked = (mark: AdfMark): AdfDocument => table(row(cell('tableHeader', { content: [{ marks: [mark], text: 'l', type: 'text' }], type: 'paragraph' })))
|
||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
||||
assert.ok(directive(adfToMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(marked({ attrs: { href: 'https://example.com/?x|y' }, type: 'link' }))))
|
||||
assert.ok(directive(adfToLosslessMarkdown(marked({ attrs: { href: 'https://example.com/', title: 'a|b' }, type: 'link' }))))
|
||||
const codeSpan: AdfNode = { marks: [{ type: 'code' }], text: 'a|b', type: 'text' }
|
||||
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { content: [codeSpan], type: 'paragraph' }))))))
|
||||
assert.equal(markdown(adfToMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
||||
assert.ok(directive(adfToLosslessMarkdown(table(row(cell('tableHeader', { content: [codeSpan], type: 'paragraph' }))))))
|
||||
assert.equal(markdown(adfToLosslessMarkdown(marked({ attrs: { href: 'https://example.com/x' }, type: 'link' }))), '| [l](https://example.com/x) |\n| --- |\n')
|
||||
})
|
||||
|
||||
test('spells an inline node as a directive with its content slot and attributes', () => {
|
||||
const emitted = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||
const emitted = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(paragraph(node))))
|
||||
assert.equal(emitted({ attrs: { timestamp: '1756080000000' }, type: 'date' }), '!adf:date{timestamp=1756080000000}\n')
|
||||
assert.equal(emitted({ type: 'mention' }), '!adf:mention{}\n')
|
||||
assert.equal(emitted({ attrs: { text: '' }, type: 'status' }), '!adf:status[]\n')
|
||||
@@ -679,7 +679,7 @@ test('spells an inline node as a directive with its content slot and attributes'
|
||||
})
|
||||
|
||||
test('carries an inline node attribute no section spells', () => {
|
||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node))))
|
||||
const carried = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(paragraph(node))))
|
||||
assert.equal(carried({ attrs: { rounded: true }, type: 'status' }), '!adf:carry{json="{\\"attrs\\":{\\"rounded\\":true},\\"type\\":\\"status\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { toString: 'x' }, type: 'status' }), '!adf:carry{json="{\\"attrs\\":{\\"toString\\":\\"x\\"},\\"type\\":\\"status\\"}"}\n')
|
||||
assert.equal(carried({ attrs: { color: 4 }, type: 'status' }), '!adf:carry{json="{\\"attrs\\":{\\"color\\":4},\\"type\\":\\"status\\"}"}\n')
|
||||
@@ -688,7 +688,7 @@ test('carries an inline node attribute no section spells', () => {
|
||||
})
|
||||
|
||||
test('refuses the content and slot an inline directive has no room for', () => {
|
||||
const refused = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node, { text: 'y', type: 'text' }))))
|
||||
const refused = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(paragraph(node, { text: 'y', type: 'text' }))))
|
||||
const neither = (type: string, held: string): string => `unsupported-node-shape: a ${type} node holds neither content nor text: this one holds ${held}`
|
||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'status' }), neither('status', 'content'))
|
||||
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
|
||||
@@ -704,7 +704,7 @@ test('carries an inline node whose content slot holds a line ending or a null ch
|
||||
|
||||
test('spells the directive marks around the longest run they cover', () => {
|
||||
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
const underline: AdfMark = { type: 'underline' }
|
||||
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
|
||||
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
|
||||
@@ -727,7 +727,7 @@ test('spells the directive marks around the longest run they cover', () => {
|
||||
})
|
||||
|
||||
test('carries a mark directive attribute no spelling holds', () => {
|
||||
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
||||
const carried = (mark: AdfMark): string => markdown(adfToLosslessMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' }))))
|
||||
assert.equal(
|
||||
carried({ attrs: { width: 2 }, type: 'border' }),
|
||||
'!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"width\\":2},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
|
||||
@@ -739,19 +739,19 @@ test('carries a mark directive attribute no spelling holds', () => {
|
||||
})
|
||||
|
||||
test('carries whitespace CommonMark strips in the reserved text directive', () => {
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||
const emitted = (...content: AdfNode[]): string => markdown(adfToLosslessMarkdown(document(paragraph(...content))))
|
||||
assert.equal(emitted({ text: ' lead', type: 'text' }), '!adf:text{text=" "}lead\n')
|
||||
assert.equal(emitted({ text: 'trail ', type: 'text' }), 'trail!adf:text{text=" "}\n')
|
||||
assert.equal(emitted({ text: 'a\nb', type: 'text' }), 'a!adf:text{text="\\n"}b\n')
|
||||
assert.equal(emitted({ text: '\t', type: 'text' }), '!adf:text{text="\\t"}\n')
|
||||
assert.equal(emitted({ text: 'a ', type: 'text' }, { type: 'hardBreak' }, { text: ' b', type: 'text' }), 'a!adf:text{text=" "}\\\n!adf:text{text=" "}b\n')
|
||||
assert.equal(emitted({ marks: [{ type: 'em' }], text: ' a ', type: 'text' }), '_!adf:text{text=" "}a!adf:text{text=" "}_\n')
|
||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 1 }, content: [{ text: 'x ', type: 'text' }], type: 'heading' }))), '# x!adf:text{text=" "}\n')
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { level: 1 }, content: [{ text: 'x ', type: 'text' }], type: 'heading' }))), '# x!adf:text{text=" "}\n')
|
||||
// CommonMark strips spaces and tabs alone, so the whitespace beside them is plain text.
|
||||
assert.equal(emitted({ text: '\va\f', type: 'text' }), '\va\f\n')
|
||||
})
|
||||
|
||||
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
|
||||
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
|
||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { WrittenFlavour } from '../portable/conventions.ts'
|
||||
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
|
||||
import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts'
|
||||
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
@@ -24,20 +24,20 @@ type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
||||
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
||||
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
|
||||
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
|
||||
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
|
||||
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
|
||||
export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined }
|
||||
|
||||
export const largestListMarker = 999999999
|
||||
// Bare because tryList admits no item carrying attributes, marks or text.
|
||||
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
|
||||
return writeMarkdown(document, 'lossless')
|
||||
}
|
||||
|
||||
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
|
||||
export function writeMarkdown(document: AdfDocument, flavour: WrittenFlavour): Result<string> {
|
||||
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}`, [])
|
||||
@@ -124,11 +124,11 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
||||
}
|
||||
|
||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
|
||||
if (plain !== undefined) return plain
|
||||
const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined
|
||||
if (portable !== undefined) return portable
|
||||
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
|
||||
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node, writing.flavour))
|
||||
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node))
|
||||
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||
@@ -137,15 +137,14 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
|
||||
return undefined
|
||||
}
|
||||
|
||||
// The plain flavour's nodes, in the shapes the plain reduction leaves them.
|
||||
function spellPlainBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
// The portable flavour's nodes, in the shapes the portable reduction leaves them.
|
||||
function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
|
||||
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
|
||||
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
|
||||
const title = nodeAttrs(node)['title']
|
||||
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
|
||||
// The reader takes a title as lossless inline text.
|
||||
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, 'lossless')
|
||||
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, writing.flavour)
|
||||
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
|
||||
}
|
||||
|
||||
@@ -177,7 +176,7 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
|
||||
else for (const block of walk.value.blocks) previous.push(block)
|
||||
}
|
||||
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
|
||||
// The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
|
||||
// The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour.
|
||||
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
|
||||
}
|
||||
|
||||
@@ -247,13 +246,13 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||
if (!inner.ok) return inner
|
||||
const text = joinBlocks(inner.value.blocks, 'document')
|
||||
const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
|
||||
const alert = flavourClaims[writing.flavour].alerts && leadingMarker(text, readAlertMarker) !== undefined
|
||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||
}
|
||||
|
||||
function tryCodeBlock(node: AdfNode): string | undefined {
|
||||
function tryCodeBlock(node: AdfNode, flavour: WrittenFlavour): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, ['language'])) return undefined
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
const slot = languageSlot(nodeAttrs(node)['language'], flavourClaims[flavour])
|
||||
if (slot.kind === 'attribute') return undefined
|
||||
const [only, ...others] = fencedTexts(node) ?? []
|
||||
if (only === undefined || others.length > 0) return undefined
|
||||
@@ -264,7 +263,7 @@ function tryCodeBlock(node: AdfNode): string | undefined {
|
||||
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const texts = fencedTexts(node)
|
||||
if (texts === undefined) return carriedFence(carriedBlock(node, path, depth))
|
||||
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'], flavourClaims.lossless)
|
||||
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (opener === undefined) return carriedFence(carriedBlock(node, path, depth))
|
||||
if (!opener.ok) return opener
|
||||
@@ -283,7 +282,7 @@ function fencedTexts(node: AdfNode): string[] | undefined {
|
||||
return texts
|
||||
}
|
||||
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedBlock> | undefined {
|
||||
if (!holdsOnlyAttributes(node, ['level'])) return undefined
|
||||
const level = nodeAttrs(node)['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||
@@ -314,7 +313,7 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
|
||||
for (const [offset, item] of walked.entries()) {
|
||||
const inner = joinBlocks(item.walk.blocks, 'list-item')
|
||||
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
|
||||
const escaped = writing.flavour === 'plain' && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
|
||||
const escaped = flavourClaims[writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
|
||||
const line = tryListItemLines(escaped, ordered ? `${start + offset}. ` : '- ')
|
||||
if (line === undefined) {
|
||||
// The directive form spends a level the walk did not count.
|
||||
@@ -347,7 +346,7 @@ function tryListItemLines(inner: string, marker: string): string | undefined {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedBlock> | undefined {
|
||||
const content = nodeContent(node)
|
||||
if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
|
||||
const line = emitInlineLine(content, 'paragraph', path, flavour)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { WrittenFlavour } from '../portable/conventions.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter } from '../plain/conventions.ts'
|
||||
import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
@@ -26,7 +26,7 @@ type InlineContext = {
|
||||
atBlockEnd: boolean
|
||||
bracketed: boolean
|
||||
carried: ReadonlySet<number>
|
||||
flavour: Flavour
|
||||
flavour: WrittenFlavour
|
||||
openingLinkAsDirective: boolean
|
||||
path: ConvertErrorPath
|
||||
spansLines: boolean
|
||||
@@ -36,11 +36,11 @@ type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[
|
||||
|
||||
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
|
||||
|
||||
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
||||
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
|
||||
|
||||
export type PlainLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<string> {
|
||||
const emitted = emitLine(nodes, container, path, flavour)
|
||||
if (!emitted.ok) return emitted
|
||||
return success(emitted.value.line)
|
||||
@@ -52,15 +52,15 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
|
||||
return success(emitted.value.openingLinkAsDirective)
|
||||
}
|
||||
|
||||
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
|
||||
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'plain', openingLinkAsDirective: false })
|
||||
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
|
||||
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', openingLinkAsDirective: false })
|
||||
if (!emission.ok) return emission
|
||||
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
|
||||
const verdict = lineVerdict(emission.value.segments, container, 'plain')
|
||||
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path)
|
||||
const verdict = lineVerdict(emission.value.segments, container, 'portable')
|
||||
return success(verdict.kind === 'line' ? undefined : verdict)
|
||||
}
|
||||
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: Flavour): string | undefined {
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined {
|
||||
const emitted = emitLine(nodes, 'table-cell', path, flavour)
|
||||
if (!emitted.ok) return undefined
|
||||
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
|
||||
@@ -75,7 +75,7 @@ export function tryImageLine(alt: string | undefined, href: string): string | un
|
||||
return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||
}
|
||||
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
|
||||
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
|
||||
// Terminates because takeFallback refuses a pass that took no new fallback.
|
||||
for (;;) {
|
||||
@@ -115,7 +115,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
||||
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
|
||||
}
|
||||
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt {
|
||||
const verdict = lineVerdict(segments, container, flavour)
|
||||
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
|
||||
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
|
||||
@@ -123,7 +123,7 @@ function attemptLine(segments: readonly InlineSegment[], container: LineContaine
|
||||
}
|
||||
|
||||
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
|
||||
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): PortableLineFallback | { kind: 'line'; text: string } {
|
||||
const assembled = assembleInlineLine(segments, container, flavour)
|
||||
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
|
||||
const [run, ...others] = assembled.unspellableRuns
|
||||
@@ -273,7 +273,7 @@ function emitText(node: AdfNode, context: InlineContext, index: number): Emissio
|
||||
|
||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||
if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, range, context)
|
||||
const spelling = markSpelling(mark.type)
|
||||
if (spelling === undefined) return success({ carry: range })
|
||||
const attributes = spellMarkAttributes(mark, spelling)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { Claims, WrittenFlavour } from '../portable/conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
|
||||
import { flavourClaims, highlightDelimiter, highlightFlanking } from '../portable/conventions.ts'
|
||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||
import { readEntityReference } from '../commonmark/entity-references.ts'
|
||||
@@ -37,8 +37,8 @@ const delimiters = ['*', '_', '`', '~']
|
||||
|
||||
const followsLinkText = /[([]/
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
|
||||
return escape(resolveEmphasis(segments), container, flavour === 'plain')
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour): AssembledLine {
|
||||
return escape(resolveEmphasis(segments), container, flavourClaims[flavour])
|
||||
}
|
||||
|
||||
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
@@ -70,11 +70,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
return resolved
|
||||
}
|
||||
|
||||
function escape(segments: readonly InlineSegment[], container: LineContainer, highlights: boolean): AssembledLine {
|
||||
function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine {
|
||||
const scan = segments.map((segment) => segment.text).join('')
|
||||
const escapings: InlineEscaping[] = []
|
||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
|
||||
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, highlights))
|
||||
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, claims))
|
||||
const placements: number[] = []
|
||||
let output = ''
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
@@ -89,7 +89,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, hi
|
||||
return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
|
||||
}
|
||||
|
||||
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> {
|
||||
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, claims: Claims): ReadonlySet<number> {
|
||||
const escaped = new Set<number>()
|
||||
const linkClose = lastLinkClose(scan, escapings)
|
||||
let line = scanLine(scan, 0)
|
||||
@@ -100,15 +100,17 @@ function escapeClaims(scan: string, escapings: readonly InlineEscaping[], contai
|
||||
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
|
||||
const escaping = escapings[index]
|
||||
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||
const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
|
||||
const opensEquals: boolean = claims.highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
|
||||
const opensDirective = claims.directives && claimsDirectivePrefix(scan, index)
|
||||
const claimed: boolean =
|
||||
(escapable &&
|
||||
((opensEquals && claimsHighlight(scan, index)) ||
|
||||
claimsLineStart(line, index, container) ||
|
||||
mergesWithSyntax(scan, escapings, index) ||
|
||||
opensDirective ||
|
||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
|
||||
(escaping === 'bracketed-link-target' &&
|
||||
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
|
||||
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || opensDirective))
|
||||
if (claimed) escaped.add(index)
|
||||
afterEscape = claimed
|
||||
pairsEquals = opensEquals && !claimed
|
||||
@@ -306,7 +308,6 @@ function claimsCharacter(
|
||||
if (character === '\\') return backslashEscape(scan, index) !== undefined
|
||||
if (character === '&') return readEntityReference(scan, index) !== undefined
|
||||
if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
|
||||
if (character === '!') return claimsDirectivePrefix(scan, index)
|
||||
if (character === '[') return index < linkClose
|
||||
if (character === '`') return opensCodeSpan(scan, index, afterEscape)
|
||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { WrittenFlavour } from '../portable/conventions.ts'
|
||||
import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts'
|
||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||
import { tryPipeCell } from './inline-line.ts'
|
||||
|
||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): string | undefined {
|
||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined {
|
||||
const rows = pipeRows(node)
|
||||
if (rows === undefined) return undefined
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -3,14 +3,15 @@ import test from 'node:test'
|
||||
|
||||
import type { Block } from './blocks.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { flavourClaims } from '../portable/conventions.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
function definitions(markdown: string): [string, LinkDefinition][] {
|
||||
return [...parseBlocks(markdown).definitions]
|
||||
return [...parseBlocks(markdown, flavourClaims.lossless).definitions]
|
||||
}
|
||||
|
||||
function kinds(markdown: string): string[] {
|
||||
return parseBlocks(markdown).blocks.map((block) => block.kind)
|
||||
return parseBlocks(markdown, flavourClaims.lossless).blocks.map((block) => block.kind)
|
||||
}
|
||||
|
||||
function faults(markdown: string): string[] {
|
||||
@@ -23,7 +24,7 @@ function faults(markdown: string): string[] {
|
||||
if (block.kind === 'bulletList' || block.kind === 'orderedList') for (const item of block.items) walk(item)
|
||||
}
|
||||
}
|
||||
walk(parseBlocks(markdown).blocks)
|
||||
walk(parseBlocks(markdown, flavourClaims.lossless).blocks)
|
||||
return messages
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ test('opens a container where the content model takes content, and holds it open
|
||||
assert.deepEqual(faults('!adf:panel info\n> Part.\n!adf:/panel\n'), [])
|
||||
assert.deepEqual(faults('!adf:panel info\n- !adf:panel warning\n Part.\n !adf:/panel\n!adf:/panel\n'), [])
|
||||
assert.deepEqual(faults('!adf:panel info\n```\n!adf:/panel\n```\n!adf:/panel\n'), [])
|
||||
assert.deepEqual(parseBlocks('!adf:panel info {panelColor="#ff0000"}\nPart.\n!adf:/panel\n').blocks, [
|
||||
assert.deepEqual(parseBlocks('!adf:panel info {panelColor="#ff0000"}\nPart.\n!adf:/panel\n', flavourClaims.lossless).blocks, [
|
||||
{
|
||||
argument: 'info',
|
||||
attributes: new Map([['panelColor', { decoded: '#ff0000', spelling: '"#ff0000"' }]]),
|
||||
@@ -100,7 +101,7 @@ test('opens a container where the content model takes content, and holds it open
|
||||
position: { line: 1, offset: 0 },
|
||||
},
|
||||
])
|
||||
assert.deepEqual(parseBlocks('!adf:rule\n').blocks, [
|
||||
assert.deepEqual(parseBlocks('!adf:rule\n', flavourClaims.lossless).blocks, [
|
||||
{ argument: undefined, attributes: new Map(), blocks: undefined, kind: 'directive', name: 'rule', position: { line: 1, offset: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Claims } from '../portable/conventions.ts'
|
||||
import type { ConvertFault, SourcePosition } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
@@ -73,6 +74,7 @@ type ContainerStack = {
|
||||
}
|
||||
|
||||
type Walk = ParsedBlocks & {
|
||||
claims: Claims
|
||||
leaf: OpenLeaf | undefined
|
||||
leafOpeners: Map<Block[], Map<string, LeafOpener>>
|
||||
position: SourcePosition
|
||||
@@ -83,9 +85,10 @@ const indentedCodeColumns = 4
|
||||
const largestOpenerIndentation = 3
|
||||
const tabStop = 4
|
||||
|
||||
export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
export function parseBlocks(markdown: string, claims: Claims): ParsedBlocks {
|
||||
const walk: Walk = {
|
||||
blocks: [],
|
||||
claims,
|
||||
definitions: new Map(),
|
||||
leaf: undefined,
|
||||
leafOpeners: new Map(),
|
||||
@@ -336,7 +339,7 @@ function continuesLazily(walk: Walk, line: Line): boolean {
|
||||
if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false
|
||||
if (leadingColumns(line) >= indentedCodeColumns) return true
|
||||
const opener = removeColumns(line, largestOpenerIndentation).text
|
||||
if (readDirectiveLine(opener) !== undefined || claimsPipeLine(opener) || isThematicBreak(opener)) return false
|
||||
if ((walk.claims.directives && readDirectiveLine(opener) !== undefined) || (walk.claims.pipeTables && claimsPipeLine(opener)) || isThematicBreak(opener)) return false
|
||||
return atxHeading(opener) === undefined && openingCodeFence(opener) === undefined && openingHtmlBlock(opener, true) === undefined
|
||||
}
|
||||
|
||||
@@ -388,14 +391,14 @@ function readIndentedCodeLine(leaf: Extract<OpenLeaf, { kind: 'indented-code' }>
|
||||
|
||||
function openLeaf(walk: Walk, line: Line): void {
|
||||
const opener = removeColumns(line, largestOpenerIndentation).text
|
||||
const directive = readDirectiveLine(opener)
|
||||
const directive = walk.claims.directives ? readDirectiveLine(opener) : undefined
|
||||
if (directive !== undefined) {
|
||||
closeLeaf(walk)
|
||||
if (directive.fault === undefined) applyDirectiveLine(walk, directive.value)
|
||||
else pushFault(walk, directive.fault)
|
||||
return
|
||||
}
|
||||
const cells = pipeCells(opener)
|
||||
const cells = walk.claims.pipeTables ? pipeCells(opener) : undefined
|
||||
if (cells !== undefined) {
|
||||
closeLeaf(walk)
|
||||
walk.leaf = { kind: 'pipe-table', position: walk.position, rows: [cells] }
|
||||
@@ -424,7 +427,7 @@ function readLineBlock(walk: Walk, opener: string): boolean {
|
||||
if (level !== undefined) {
|
||||
const paragraph = takeParagraph(walk)
|
||||
if (paragraph !== undefined) {
|
||||
currentBlocks(walk).push(bareTableFault(paragraph) ?? { kind: 'heading', level, position: paragraph.position, text: paragraph.text })
|
||||
currentBlocks(walk).push(bareTableFault(paragraph, walk.claims) ?? { kind: 'heading', level, position: paragraph.position, text: paragraph.text })
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -456,7 +459,7 @@ function closeLeaf(walk: Walk): void {
|
||||
if (leaf === undefined) return
|
||||
if (leaf.kind === 'paragraph') {
|
||||
const paragraph = takeParagraph(walk)
|
||||
if (paragraph !== undefined) currentBlocks(walk).push(bareTableFault(paragraph) ?? paragraph)
|
||||
if (paragraph !== undefined) currentBlocks(walk).push(bareTableFault(paragraph, walk.claims) ?? paragraph)
|
||||
return
|
||||
}
|
||||
walk.leaf = undefined
|
||||
@@ -480,7 +483,8 @@ function pipeTableBlock(rows: readonly [string[], ...string[][]], position: Sour
|
||||
}
|
||||
|
||||
// spec/flavour.md, Tables: GFM's table without the leading pipes, which no line of it claims.
|
||||
function bareTableFault(paragraph: Extract<Block, { kind: 'paragraph' }>): Block | undefined {
|
||||
function bareTableFault(paragraph: Extract<Block, { kind: 'paragraph' }>, claims: Claims): Block | undefined {
|
||||
if (!claims.pipeTables) return undefined
|
||||
let header: string[] | undefined
|
||||
for (const line of paragraph.text.split('\n')) {
|
||||
const cells = barePipeCells(line)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { Claims } from '../portable/conventions.ts'
|
||||
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
@@ -9,7 +9,7 @@ import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
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 { highlightDelimiter, highlightFlanking } from '../portable/conventions.ts'
|
||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
|
||||
@@ -54,11 +54,11 @@ 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 = {
|
||||
claims: Claims
|
||||
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
|
||||
path: ConvertErrorPath
|
||||
pending: string
|
||||
@@ -81,8 +81,8 @@ const spellableLink = 'link takes the directive form only where CommonMark canno
|
||||
const textBreak: TextBreak = { kind: 'textBreak' }
|
||||
|
||||
// The outermost content: only here does a text break see both its neighbours.
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
|
||||
const scan = freshScan(source, { definitions, path }, { container, highlights: flavour === 'plain', spans: noSpans })
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, claims: Claims): Result<InlineContent> {
|
||||
const scan = freshScan(source, { definitions, path }, { claims, container, spans: noSpans })
|
||||
const scanned = scanInline(scan)
|
||||
if (!scanned.ok) return scanned
|
||||
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
|
||||
@@ -96,7 +96,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
||||
return success({ nodes: nodes.value })
|
||||
}
|
||||
|
||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'spans'>): Scan {
|
||||
return { ...shared, ...own, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function scanInline(scan: Scan): Result<Scanned> {
|
||||
break
|
||||
}
|
||||
case '!': {
|
||||
const directive = readDirective(scan, index)
|
||||
const directive = scan.claims.directives ? readDirective(scan, index) : undefined
|
||||
if (directive === undefined) {
|
||||
index = openBracket(scan, index)
|
||||
break
|
||||
@@ -276,8 +276,8 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
const { definitions, path } = scan
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path }, { container: undefined, highlights: false, spans: span.spans }))
|
||||
const { claims, definitions, path } = scan
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, spans: span.spans }))
|
||||
if (!parsed.ok) return parsed
|
||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
@@ -357,7 +357,7 @@ function readDelimiterRun(scan: Scan, index: number): number {
|
||||
const character = scan.source.charAt(index)
|
||||
const length = runLength(scan.source, index)
|
||||
const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length))
|
||||
if ((character === '~' && length !== 2) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length)
|
||||
if ((character === '~' && (!scan.claims.strikethrough || length !== 2)) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length)
|
||||
else {
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ canClose: flags.canClose, canOpen: flags.canOpen, character, kind: 'run', length })
|
||||
@@ -366,7 +366,7 @@ function readDelimiterRun(scan: Scan, index: number): number {
|
||||
}
|
||||
|
||||
function readEquals(scan: Scan, index: number): number {
|
||||
if (!scan.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
|
||||
if (!scan.claims.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
|
||||
scan.pending += '='
|
||||
return index + 1
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block, DirectiveBlock } from './blocks.ts'
|
||||
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
||||
import type { Claims } from '../portable/conventions.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { Flavour } from '../plain/conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
|
||||
@@ -10,11 +10,10 @@ import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.t
|
||||
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||
import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||
import { inlineLeaves } from '../plain/inline-reduction.ts'
|
||||
import { flavourClaims, leadingMarker, readAlertMarker, readTaskMarker } from '../portable/conventions.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain/conventions.ts'
|
||||
import { mintTaskIds } from '../plain/task-ids.ts'
|
||||
import { mintTaskIds } from '../portable/task-ids.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||
@@ -24,32 +23,36 @@ import { spellsEmpty } from '../empty-keys.ts'
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
|
||||
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
|
||||
type Reading = { carried: Set<AdfNode>; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
|
||||
type Reading = { claims: Claims; definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo }
|
||||
|
||||
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
|
||||
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'lossless')
|
||||
export function commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, flavourClaims.commonmark)
|
||||
}
|
||||
|
||||
export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'plain')
|
||||
export function losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, flavourClaims.lossless)
|
||||
}
|
||||
|
||||
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||
export function portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, flavourClaims.portable)
|
||||
}
|
||||
|
||||
function readDocument(markdown: string, claims: Claims): Result<AdfDocument, ParseError> {
|
||||
if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []), { line: 1, offset: 0 })
|
||||
const parsed = parseBlocks(markdown)
|
||||
const parsed = parseBlocks(markdown, claims)
|
||||
const [only] = parsed.blocks
|
||||
if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) {
|
||||
const fault = documentFault(only)
|
||||
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
|
||||
}
|
||||
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||
const reading: Reading = { claims, definitions: parsed.definitions, inExpand: false, memo: new Map() }
|
||||
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), { line: 1, offset: 0 })
|
||||
if (!content.ok) return content
|
||||
const document: AdfDocument = { content: content.value, type: 'doc', version: 1 }
|
||||
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
|
||||
if (claims.taskMarkers) mintTaskIds(document, markdown)
|
||||
return success(document)
|
||||
}
|
||||
|
||||
@@ -107,9 +110,9 @@ function partsFault(): ConvertFault {
|
||||
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
switch (block.kind) {
|
||||
case 'blockquote':
|
||||
return reading.flavour === 'plain' ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
return reading.claims.alerts ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
case 'bulletList':
|
||||
return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
|
||||
return reading.claims.taskMarkers ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
|
||||
case 'code':
|
||||
return codeBlockNode(block.language, block.text, reading, path, depth)
|
||||
case 'directive':
|
||||
@@ -155,16 +158,10 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro
|
||||
const { folded, panelType } = led.marker
|
||||
const { line, rest } = markerLine(led.text)
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless')
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.claims)
|
||||
if (!title.ok) return title
|
||||
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
|
||||
const leaves: AdfNode[] = []
|
||||
for (const [index, node] of title.value.nodes.entries()) {
|
||||
const held = node.text === undefined ? inlineLeaves([node], 'paragraph', [...path, 'content', index], depth) : success([node])
|
||||
if (!held.ok) return held
|
||||
for (const leaf of held.value) leaves.push(leaf)
|
||||
}
|
||||
const text = titleText(leaves)
|
||||
const text = titleText(title.value.nodes)
|
||||
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
|
||||
}
|
||||
@@ -231,8 +228,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr
|
||||
function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
|
||||
if (!read.ok) return read
|
||||
const inExpand = reading.inExpand || read.value.node.type === 'expand' || read.value.node.type === 'nestedExpand'
|
||||
const built = directiveBody(read.value, block.blocks, { ...reading, inExpand }, path, depth)
|
||||
const built = directiveBody(read.value, block.blocks, reading, path, depth)
|
||||
if (!built.ok) return built
|
||||
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
|
||||
if (readable === undefined) return built
|
||||
@@ -263,7 +259,7 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
|
||||
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `delete the empty fence: a fence beside another holds code, and this one holds none`, path)
|
||||
const attribute = nodeAttrs(node)['language']
|
||||
const fromFence = first.language !== ''
|
||||
const slot = languageSlot(fromFence ? first.language : attribute)
|
||||
const slot = languageSlot(fromFence ? first.language : attribute, flavourClaims.lossless)
|
||||
if (!fromFence && slot.kind === 'fence') {
|
||||
return failure('unsupported-node-shape', `move language=${spellStringAttribute(slot.info)} to the fences' info strings: they can spell this language`, path)
|
||||
}
|
||||
@@ -321,12 +317,10 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const carriedType = carryFenceType(language)
|
||||
const carriedType = reading.claims.carryFence ? carryFenceType(language) : undefined
|
||||
if (carriedType !== undefined) {
|
||||
const carried = readCarriedBlock(carriedType, text, depth)
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||
reading.carried.add(carried.value)
|
||||
return success(carried.value)
|
||||
return carried.fault === undefined ? success(carried.value) : faulted(carried.fault, path)
|
||||
}
|
||||
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
||||
return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
|
||||
@@ -334,14 +328,14 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C
|
||||
|
||||
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
|
||||
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
|
||||
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.claims)
|
||||
if (!content.ok) return content
|
||||
const image = content.value.image
|
||||
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
|
||||
}
|
||||
|
||||
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
|
||||
const content = parseInlineContent(text, reading.definitions, path, container, reading.claims)
|
||||
if (!content.ok) return content
|
||||
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
||||
return success(withContent(node, content.value.nodes))
|
||||
|
||||
+12
-40
@@ -2,10 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
||||
import { adfToPlainMarkdown } from '../plain/adf-to-plain-markdown.ts'
|
||||
import { adfToPortableMarkdown } from '../portable/adf-to-portable-markdown.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { portableMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
@@ -15,7 +14,7 @@ const strong: AdfMark = { type: 'strong' }
|
||||
const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
|
||||
|
||||
function read(markdown: string): readonly AdfNode[] | string {
|
||||
const parsed = plainMarkdownToAdf(markdown)
|
||||
const parsed = portableMarkdownToAdf(markdown)
|
||||
if (!parsed.ok) return parsed.error.code
|
||||
const blocks = parsed.value.content ?? []
|
||||
const pending = [...blocks]
|
||||
@@ -35,7 +34,7 @@ function document(...content: AdfNode[]): AdfDocument {
|
||||
}
|
||||
|
||||
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
|
||||
const markdown = adfToPlainMarkdown(document(...content))
|
||||
const markdown = adfToPortableMarkdown(document(...content))
|
||||
return markdown.ok ? read(markdown.value) : markdown.error.code
|
||||
}
|
||||
|
||||
@@ -119,22 +118,15 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
|
||||
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), [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'), [node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())])
|
||||
assert.deepEqual(read('> [!faq]- [a b](u)\n'), [node('expand', { title: 'a\nb (u)' }, paragraph())])
|
||||
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), [node('expand', { title: '@M (u) http://y' }, paragraph())])
|
||||
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), [node('expand', { title: 'Set ==x== here' }, paragraph())])
|
||||
assert.deepEqual(read('> [!faq]- !adf:status[x]{color=red} ~~y~~\n'), [node('expand', { title: '!adf:status[x]{color=red} y' }, paragraph())])
|
||||
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), [node('expand', { title: 'Set x here' }, paragraph())])
|
||||
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
|
||||
})
|
||||
|
||||
test("reads a folded callout's title leaves as a walk rather than as one call's arguments", () => {
|
||||
const children = Array.from({ length: 130000 }, () => '{\\"text\\":\\"a\\",\\"type\\":\\"text\\"}').join(',')
|
||||
const parsed = plainMarkdownToAdf(`> [!NOTE]- !adf:carry{json="{\\"content\\":[${children}],\\"type\\":\\"unknownInline\\"}"}\n`)
|
||||
assert.equal(parsed.ok && parsed.value.content?.[0]?.attrs?.['title'], 'a'.repeat(130000))
|
||||
})
|
||||
|
||||
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), [node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))])
|
||||
assert.deepEqual(read('- > [!NOTE]-\n'), [bare('bulletList', bare('listItem', bare('expand', paragraph())))])
|
||||
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), [bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))])
|
||||
})
|
||||
|
||||
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
|
||||
@@ -142,7 +134,7 @@ test('reads a bullet list whose every item leads with a task marker to a task li
|
||||
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'), [bare('taskList', task('DONE'), task('TODO', text('after')))])
|
||||
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
||||
const minted = portableMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
||||
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||
node(
|
||||
'taskList',
|
||||
@@ -234,27 +226,12 @@ test('reads text the writer kept from reading as a marker back as text', () => {
|
||||
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
|
||||
})
|
||||
|
||||
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
|
||||
assert.deepEqual(read('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))])
|
||||
assert.deepEqual(read('!adf:taskList\n!adf:taskItem TODO\nb\n!adf:/taskItem\n!adf:/taskList\n'), [bare('taskList', task('TODO', text('b')))])
|
||||
const listed = plainMarkdownToAdf('!adf:taskList {localId=01a0eeb2-be48-7ea7-8587-db5e013c374a}\n- [ ] b\n!adf:/taskList\n')
|
||||
assert.deepEqual(listed.ok ? listed.value.content?.[0]?.attrs : listed.error.code, { localId: '01a0eeb2-be48-7ea7-8587-db5e013c374a' })
|
||||
const future = bare('futureBlock', text('==x=='))
|
||||
const carried = adfToMarkdown(document(future))
|
||||
assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future])
|
||||
const uncarriable = bare('taskList', node('taskItem', { extra: { a: 1 }, state: 'TODO' }, text('a')))
|
||||
const carriedPanel = adfToMarkdown(document(node('panel', { extra: true, panelType: 'info' }, uncarriable)))
|
||||
const restored = carriedPanel.ok ? plainMarkdownToAdf(carriedPanel.value) : carriedPanel
|
||||
assert.deepEqual(restored.ok ? restored.value.content : restored.error.code, [node('panel', { extra: true, panelType: 'info' }, uncarriable)])
|
||||
const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' }
|
||||
const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
|
||||
const spelled = adfToMarkdown(document(held))
|
||||
assert.deepEqual(spelled.ok ? read(spelled.value) : spelled.error.code, [paragraph(text('a ==b== c', red), text(' '), text('d ', highlight), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g', highlight))])
|
||||
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
|
||||
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
|
||||
assert.equal(read('!adf:panel\n'), 'malformed-directive')
|
||||
test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, and refuses only what it refuses', () => {
|
||||
assert.deepEqual(read('!adf:panel\n'), [said('!adf:panel')])
|
||||
assert.deepEqual(read('a !adf:carry{json="{}"} \\!adf:x\n'), [said('a !adf:carry{json="{}"} !adf:x')])
|
||||
assert.deepEqual(read('```adf:blockCard\n{}\n```\n'), [node('codeBlock', { language: 'adf:blockCard' }, text('{}'))])
|
||||
const refusal = (markdown: string): unknown => {
|
||||
const parsed = plainMarkdownToAdf(markdown)
|
||||
const parsed = portableMarkdownToAdf(markdown)
|
||||
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
|
||||
}
|
||||
for (const markdown of ['> [!tip] \n', '> [!NOTE]- \n', '- [x] \n']) {
|
||||
@@ -267,8 +244,3 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
|
||||
assert.equal(typeof read(deep), 'object')
|
||||
})
|
||||
|
||||
test('joins a carried text node to no neighbour, inside a highlight too', () => {
|
||||
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
|
||||
assert.deepEqual(read(`==a${carried}==\n`), [paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })])
|
||||
})
|
||||
@@ -1,353 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToPlainMarkdown, reduceToPlain } from './adf-to-plain-markdown.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function plain(...content: AdfNode[]): string {
|
||||
return plainDocument(document(...content))
|
||||
}
|
||||
|
||||
function plainDocument(input: AdfDocument): string {
|
||||
const markdown = adfToPlainMarkdown(input)
|
||||
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
|
||||
}
|
||||
|
||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||
return { attrs, content, type }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'paragraph' }
|
||||
}
|
||||
|
||||
function said(value: string): AdfNode {
|
||||
return paragraph(text(value))
|
||||
}
|
||||
|
||||
function item(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'listItem' }
|
||||
}
|
||||
|
||||
function bulletList(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'bulletList' }
|
||||
}
|
||||
|
||||
function cell(type: string, ...content: AdfNode[]): AdfNode {
|
||||
return { content, type }
|
||||
}
|
||||
|
||||
function row(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'tableRow' }
|
||||
}
|
||||
|
||||
function link(href: string, title?: string): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
test('refuses what the document guard refuses, and nothing else', () => {
|
||||
assert.equal(plainDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
|
||||
assert.equal(plainDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
|
||||
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 /)
|
||||
})
|
||||
|
||||
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
||||
const lowest = (bottom: AdfNode): string => {
|
||||
let deep = bottom
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||
return plainDocument(document(deep)).split(' ')[0] ?? ''
|
||||
}
|
||||
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
|
||||
const bottoms: AdfNode[] = [
|
||||
bulletList(item(said('x'))),
|
||||
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
|
||||
node('taskList', {}, node('taskItem', {}, wrapped)),
|
||||
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
|
||||
node('panel', {}, said('x')),
|
||||
node('expand', {}, said('x')),
|
||||
node('decisionList', {}, node('decisionItem', {}, text('x'))),
|
||||
node('table', {}, row(cell('tableCell', said('x')))),
|
||||
node('mediaSingle', {}, node('caption', {}, text('x'))),
|
||||
node('heading', { level: 1 }, wrapped),
|
||||
node('codeBlock', {}, wrapped),
|
||||
paragraph(wrapped),
|
||||
]
|
||||
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
|
||||
})
|
||||
|
||||
test('spells a panel as an alert in the GitHub word for its colour', () => {
|
||||
const panel = (panelType: string | undefined): string =>
|
||||
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
|
||||
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
|
||||
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
|
||||
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
|
||||
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(plain(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
|
||||
})
|
||||
|
||||
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
|
||||
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
|
||||
assert.equal(
|
||||
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
|
||||
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
|
||||
)
|
||||
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
||||
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
||||
assert.equal(plain(node('expand', { title: '\ta \t b\t ' })), '> [!NOTE]- a \t b\n')
|
||||
assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n')
|
||||
})
|
||||
|
||||
test('spells a task list as a bullet list whose items lead with their state', () => {
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
|
||||
const nested = node('taskList', {}, task('TODO', 'Review'))
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
|
||||
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- \\[x]\n- Stray\n')
|
||||
assert.equal(plain(node('taskList', {}, node('taskList', {}), task('DONE', 'a'), said('b'), node('taskList', {}, task('TODO', 'c')))), '- \\[x] a\n- b\n - [ ] c\n')
|
||||
assert.equal(plain(node('taskList', {}, paragraph(), task('DONE', 'a'))), '- [x] a\n')
|
||||
assert.equal(plain(bulletList(item(said('x'))), node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', 'c')))), '- x\n- \\[x] a\n - [ ] c\n')
|
||||
assert.equal(plain(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
|
||||
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
|
||||
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
|
||||
assert.equal(plain(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
|
||||
const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a'))))
|
||||
assert.equal(plain(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n')
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n')
|
||||
})
|
||||
|
||||
test('spells a decision list as a plain bullet list', () => {
|
||||
assert.equal(plain(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
|
||||
})
|
||||
|
||||
test('spells a highlight as a == pair around the run, whatever its colour', () => {
|
||||
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
|
||||
assert.equal(plain(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
|
||||
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
||||
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
||||
assert.equal(plain(paragraph(text('a', highlight('#fff'), code), text('b', highlight('#fff')))), '`a`==b==\n')
|
||||
assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
|
||||
assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
|
||||
assert.equal(plain(paragraph(text('この機能は'), text('日本語', highlight('#fff')), text('でのみ'))), 'この機能は==日本語==でのみ\n')
|
||||
assert.equal(plain(paragraph(text('サーバー'), text('停止', highlight('#fff')), text('中 iPhone'), text('専用', highlight('#fff')), text('アプリ 기능은 '), text('한국어', highlight('#fff')), text('에서만'))), 'サーバー==停止==中 iPhone==専用==アプリ 기능은 ==한국어==에서만\n')
|
||||
assert.equal(plain(paragraph(text('日==本==語'))), '日\\==本\\==語\n')
|
||||
})
|
||||
|
||||
test('escapes text a renderer would take as a flavour marker, and only there', () => {
|
||||
assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
|
||||
assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
|
||||
assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
|
||||
assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n')
|
||||
assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n')
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
|
||||
})
|
||||
|
||||
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
|
||||
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
|
||||
assert.equal(plain(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
|
||||
assert.equal(plain(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
|
||||
assert.equal(plain(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
|
||||
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
|
||||
assert.equal(plain(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
|
||||
})
|
||||
|
||||
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
|
||||
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
|
||||
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
|
||||
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
|
||||
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
|
||||
assert.equal(plain(node('codeBlock', { language: 'adf:x' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
|
||||
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
|
||||
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
|
||||
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
|
||||
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
|
||||
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
|
||||
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
|
||||
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
|
||||
const long = node('orderedList', { order: 1e10 }, item(said('y')))
|
||||
assert.equal(plain(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
|
||||
assert.equal(plain(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- \\[x] t\n- 10000000000. y\n')
|
||||
assert.equal(plain(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
|
||||
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
|
||||
assert.equal(plain(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
|
||||
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
|
||||
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
|
||||
})
|
||||
|
||||
test('spells an inline node as its text', () => {
|
||||
assert.equal(plain(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
|
||||
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
|
||||
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
|
||||
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
|
||||
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
|
||||
})
|
||||
|
||||
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
|
||||
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
|
||||
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
|
||||
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
|
||||
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
|
||||
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
|
||||
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||
})
|
||||
|
||||
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
|
||||
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '\n\nThe moon.\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '\n\n\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&>' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
|
||||
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n\n')
|
||||
assert.equal(plain(external), '\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
|
||||
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
|
||||
assert.equal(plain(caption), 'The moon.\n')
|
||||
})
|
||||
|
||||
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
|
||||
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
|
||||
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
|
||||
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
|
||||
assert.equal(plain(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
|
||||
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
|
||||
assert.equal(plain(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
|
||||
})
|
||||
|
||||
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
|
||||
assert.equal(plain(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
|
||||
assert.equal(plain(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
|
||||
assert.equal(plain(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
|
||||
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
|
||||
assert.equal(plain(bulletList()), '')
|
||||
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
|
||||
assert.equal(plain(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
|
||||
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
|
||||
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
|
||||
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
|
||||
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
|
||||
})
|
||||
|
||||
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
|
||||
const table = node(
|
||||
'table',
|
||||
{ layout: 'wide' },
|
||||
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
|
||||
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
|
||||
)
|
||||
assert.equal(plain(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
|
||||
const spanned = node(
|
||||
'table',
|
||||
{},
|
||||
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
|
||||
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||
row(cell('tableCell', said('d'))),
|
||||
row(cell('tableCell')),
|
||||
)
|
||||
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
|
||||
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
|
||||
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
|
||||
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
|
||||
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
|
||||
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
|
||||
assert.equal(plain(node('table', {}, row())), '')
|
||||
})
|
||||
|
||||
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
|
||||
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
|
||||
assert.equal(plain(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
|
||||
assert.equal(plain(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
|
||||
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
|
||||
})
|
||||
|
||||
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
|
||||
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n')
|
||||
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
|
||||
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
|
||||
})
|
||||
|
||||
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
|
||||
assert.equal(plain(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
|
||||
assert.equal(plain(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
|
||||
assert.equal(plain(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
|
||||
const highlight: AdfMark = { type: 'backgroundColor' }
|
||||
assert.equal(plain(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
|
||||
})
|
||||
|
||||
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
|
||||
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
|
||||
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
|
||||
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
|
||||
assert.equal(plain(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
|
||||
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
|
||||
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
||||
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||
assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
|
||||
assert.equal(plain(paragraph({ content: [text('dropped')], text: 'a', type: 'text' }, text('b'))), 'ab\n')
|
||||
})
|
||||
|
||||
test('keeps a code span opening a line, whose backticks open no fence', () => {
|
||||
assert.equal(plain(paragraph(text('``` x', code))), '```` ``` x ````\n')
|
||||
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n```` ``` x ````\n')
|
||||
})
|
||||
|
||||
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
|
||||
assert.equal(plain(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
|
||||
assert.equal(plain(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
|
||||
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
|
||||
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
|
||||
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
|
||||
assert.equal(plain(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
|
||||
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
|
||||
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
||||
})
|
||||
|
||||
test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => {
|
||||
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
|
||||
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
|
||||
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
|
||||
})
|
||||
|
||||
test('writes what the editor-normal form of the document writes', () => {
|
||||
assert.equal(plain({ attrs: { order: -0 }, content: [{ content: [], type: 'listItem' }], type: 'orderedList' }), '0.\n')
|
||||
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
|
||||
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\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(plain(paragraph(holding, text('b'))), 'ab\n')
|
||||
assert.equal(plain(paragraph(text('b'), holding)), 'ba\n')
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
|
||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
function taskIds(document: AdfDocument): unknown[] {
|
||||
const ids: unknown[] = []
|
||||
const pending: AdfNode[] = [...(document.content ?? [])].reverse()
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId'])
|
||||
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function tasks(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => {
|
||||
const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
|
||||
const minted = unminted()
|
||||
mintTaskIds(minted, '- [ ] a\n', new Set())
|
||||
const ids = taskIds(minted)
|
||||
assert.equal(ids.length, 4)
|
||||
for (const id of ids) assert.match(String(id), uuidV4)
|
||||
assert.equal(new Set(ids).size, 4)
|
||||
const again = unminted()
|
||||
mintTaskIds(again, '- [ ] a\n', new Set())
|
||||
assert.deepEqual(taskIds(again), ids)
|
||||
const other = unminted()
|
||||
mintTaskIds(other, '- [ ] b\n', new Set())
|
||||
assert.equal(taskIds(other).some((id) => ids.includes(id)), false)
|
||||
})
|
||||
|
||||
test('keeps a localId the document spells and skips it when minting, a carried one too', () => {
|
||||
const first = tasks({ type: 'taskList' })
|
||||
mintTaskIds(first, 'x', new Set())
|
||||
const [taken] = taskIds(first)
|
||||
const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }
|
||||
const spelled = tasks({ type: 'taskList' }, carried)
|
||||
mintTaskIds(spelled, 'x', new Set([carried]))
|
||||
const [minted, kept] = taskIds(spelled)
|
||||
assert.equal(kept, taken)
|
||||
assert.notEqual(minted, taken)
|
||||
assert.match(String(minted), uuidV4)
|
||||
})
|
||||
|
||||
test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => {
|
||||
const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' })
|
||||
const carriedList = list()
|
||||
const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }
|
||||
const future: AdfNode = { content: [list()], type: 'futureBlock' }
|
||||
const document = tasks(carriedList, carriedPanel, future)
|
||||
mintTaskIds(document, 'x', new Set([carriedList, carriedPanel]))
|
||||
assert.deepEqual(document, tasks(list(), { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }, { content: [list()], type: 'futureBlock' }))
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToPortableMarkdown, reduceToPortable } from './adf-to-portable-markdown.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function portable(...content: AdfNode[]): string {
|
||||
return portableDocument(document(...content))
|
||||
}
|
||||
|
||||
function portableDocument(input: AdfDocument): string {
|
||||
const markdown = adfToPortableMarkdown(input)
|
||||
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
|
||||
}
|
||||
|
||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||
return { attrs, content, type }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'paragraph' }
|
||||
}
|
||||
|
||||
function said(value: string): AdfNode {
|
||||
return paragraph(text(value))
|
||||
}
|
||||
|
||||
function item(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'listItem' }
|
||||
}
|
||||
|
||||
function bulletList(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'bulletList' }
|
||||
}
|
||||
|
||||
function cell(type: string, ...content: AdfNode[]): AdfNode {
|
||||
return { content, type }
|
||||
}
|
||||
|
||||
function row(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'tableRow' }
|
||||
}
|
||||
|
||||
function link(href: string, title?: string): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
test('refuses what the document guard refuses, and nothing else', () => {
|
||||
assert.equal(portableDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
|
||||
assert.equal(portableDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
|
||||
let deep: AdfNode = said('x')
|
||||
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||
assert.match(portableDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
|
||||
assert.match(portableDocument(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(portableDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
|
||||
})
|
||||
|
||||
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
||||
const lowest = (bottom: AdfNode): string => {
|
||||
let deep = bottom
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||
return portableDocument(document(deep)).split(' ')[0] ?? ''
|
||||
}
|
||||
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
|
||||
const bottoms: AdfNode[] = [
|
||||
bulletList(item(said('x'))),
|
||||
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
|
||||
node('taskList', {}, node('taskItem', {}, wrapped)),
|
||||
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
|
||||
node('panel', {}, said('x')),
|
||||
node('expand', {}, said('x')),
|
||||
node('decisionList', {}, node('decisionItem', {}, text('x'))),
|
||||
node('table', {}, row(cell('tableCell', said('x')))),
|
||||
node('mediaSingle', {}, node('caption', {}, text('x'))),
|
||||
node('heading', { level: 1 }, wrapped),
|
||||
node('codeBlock', {}, wrapped),
|
||||
paragraph(wrapped),
|
||||
]
|
||||
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
|
||||
})
|
||||
|
||||
test('spells a panel as an alert in the GitHub word for its colour', () => {
|
||||
const panel = (panelType: string | undefined): string =>
|
||||
portable(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
|
||||
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
|
||||
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
|
||||
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
|
||||
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(portable(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
|
||||
})
|
||||
|
||||
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
|
||||
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
|
||||
assert.equal(
|
||||
portable(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
|
||||
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
|
||||
)
|
||||
assert.equal(portable(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
||||
assert.equal(portable(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
||||
assert.equal(portable(node('expand', { title: '\ta \t b\t ' })), '> [!NOTE]- a \t b\n')
|
||||
assert.equal(portable(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) \\==w\\==\n>\n> b\n')
|
||||
})
|
||||
|
||||
test('spells a task list as a bullet list whose items lead with their state', () => {
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
|
||||
const nested = node('taskList', {}, task('TODO', 'Review'))
|
||||
assert.equal(portable(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
|
||||
assert.equal(portable(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- \\[x]\n- Stray\n')
|
||||
assert.equal(portable(node('taskList', {}, node('taskList', {}), task('DONE', 'a'), said('b'), node('taskList', {}, task('TODO', 'c')))), '- \\[x] a\n- b\n - [ ] c\n')
|
||||
assert.equal(portable(node('taskList', {}, paragraph(), task('DONE', 'a'))), '- [x] a\n')
|
||||
assert.equal(portable(bulletList(item(said('x'))), node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', 'c')))), '- x\n- \\[x] a\n - [ ] c\n')
|
||||
assert.equal(portable(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
|
||||
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
|
||||
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
|
||||
assert.equal(portable(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
|
||||
const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a'))))
|
||||
assert.equal(portable(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n')
|
||||
assert.equal(portable(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n')
|
||||
})
|
||||
|
||||
test('spells a decision list as a plain bullet list', () => {
|
||||
assert.equal(portable(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
|
||||
})
|
||||
|
||||
test('spells a highlight as a == pair around the run, whatever its colour', () => {
|
||||
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
|
||||
assert.equal(portable(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
|
||||
assert.equal(portable(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
||||
assert.equal(portable(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
||||
assert.equal(portable(paragraph(text('a', highlight('#fff'), code), text('b', highlight('#fff')))), '`a`==b==\n')
|
||||
assert.equal(portable(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
|
||||
assert.equal(portable(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
|
||||
assert.equal(portable(paragraph(text('この機能は'), text('日本語', highlight('#fff')), text('でのみ'))), 'この機能は==日本語==でのみ\n')
|
||||
assert.equal(portable(paragraph(text('サーバー'), text('停止', highlight('#fff')), text('中 iPhone'), text('専用', highlight('#fff')), text('アプリ 기능은 '), text('한국어', highlight('#fff')), text('에서만'))), 'サーバー==停止==中 iPhone==専用==アプリ 기능은 ==한국어==에서만\n')
|
||||
assert.equal(portable(paragraph(text('日==本==語'))), '日\\==本\\==語\n')
|
||||
})
|
||||
|
||||
test('escapes text a renderer would take as a flavour marker, and only there', () => {
|
||||
assert.equal(portable(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
|
||||
assert.equal(portable({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
|
||||
assert.equal(portable({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
|
||||
assert.equal(portable(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n')
|
||||
assert.equal(portable(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n')
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
|
||||
assert.equal(portable(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
|
||||
assert.equal(portable(said('!adf:panel ~~x~~'), node('expand', { title: '!adf:x ==y==' }, said('z'))), '!adf:panel \\~~x\\~~\n\n> [!NOTE]- !adf:x \\==y\\==\n>\n> z\n')
|
||||
})
|
||||
|
||||
test('unwraps the containers portable markdown has no spelling for to their body blocks in order', () => {
|
||||
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
|
||||
assert.equal(portable(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
|
||||
assert.equal(portable(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
|
||||
assert.equal(portable(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
|
||||
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
|
||||
assert.equal(portable(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
|
||||
})
|
||||
|
||||
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
|
||||
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
|
||||
assert.equal(portable(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
|
||||
assert.equal(portable({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
|
||||
assert.equal(portable(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
|
||||
assert.equal(portable(node('codeBlock', { language: 'adf:x' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```adf:x\na\nb\n```\n')
|
||||
assert.equal(portable(node('codeBlock', {})), '```\n```\n')
|
||||
assert.equal(portable(node('rule', { color: '#000' })), '---\n')
|
||||
assert.equal(portable(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
|
||||
assert.equal(portable(node('orderedList', {}, item(said('a')))), '1. a\n')
|
||||
assert.equal(portable(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
|
||||
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
|
||||
assert.equal(portable(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
|
||||
const long = node('orderedList', { order: 1e10 }, item(said('y')))
|
||||
assert.equal(portable(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
|
||||
assert.equal(portable(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- \\[x] t\n- 10000000000. y\n')
|
||||
assert.equal(portable(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
|
||||
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
|
||||
assert.equal(portable(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
|
||||
assert.equal(portable(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
|
||||
assert.equal(portable(node('heading', { level: 7 }, text('h'))), 'h\n')
|
||||
})
|
||||
|
||||
test('spells an inline node as its text', () => {
|
||||
assert.equal(portable(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
|
||||
assert.equal(portable(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
|
||||
assert.equal(portable(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
|
||||
assert.equal(portable(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
|
||||
assert.equal(portable(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
|
||||
})
|
||||
|
||||
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
|
||||
assert.equal(portable(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
|
||||
assert.equal(portable(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
|
||||
assert.equal(portable(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
|
||||
assert.equal(portable(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
|
||||
assert.equal(portable(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
|
||||
assert.equal(portable(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
|
||||
assert.equal(portable(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
|
||||
assert.equal(portable(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||
})
|
||||
|
||||
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
|
||||
assert.equal(portable(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '\n\nThe moon.\n')
|
||||
assert.equal(portable(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '\n')
|
||||
assert.equal(portable(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '\n\n\n')
|
||||
assert.equal(portable(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&>' }))), '\n')
|
||||
assert.equal(portable(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
|
||||
assert.equal(portable(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n\n')
|
||||
assert.equal(portable(external), '\n')
|
||||
assert.equal(portable(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
|
||||
assert.equal(portable(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
|
||||
assert.equal(portable(caption), 'The moon.\n')
|
||||
})
|
||||
|
||||
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
|
||||
assert.equal(portable(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
|
||||
assert.equal(portable(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
|
||||
assert.equal(portable(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
|
||||
assert.equal(portable(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
|
||||
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
|
||||
assert.equal(portable(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
|
||||
assert.equal(portable(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
|
||||
})
|
||||
|
||||
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
|
||||
assert.equal(portable(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
|
||||
assert.equal(portable(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
|
||||
assert.equal(portable(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
|
||||
assert.equal(portable(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
|
||||
assert.equal(portable(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
|
||||
assert.equal(portable(bulletList()), '')
|
||||
assert.equal(portable(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
|
||||
assert.equal(portable(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
|
||||
assert.equal(portable(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
|
||||
assert.equal(portable(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
|
||||
assert.equal(portable(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
|
||||
assert.equal(portable(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
|
||||
})
|
||||
|
||||
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
|
||||
const table = node(
|
||||
'table',
|
||||
{ layout: 'wide' },
|
||||
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
|
||||
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
|
||||
)
|
||||
assert.equal(portable(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
|
||||
const spanned = node(
|
||||
'table',
|
||||
{},
|
||||
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
|
||||
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||
row(cell('tableCell', said('d'))),
|
||||
row(cell('tableCell')),
|
||||
)
|
||||
assert.equal(portable(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
|
||||
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
|
||||
assert.equal(portable(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
|
||||
assert.equal(portable(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
|
||||
assert.equal(portable(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
|
||||
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
|
||||
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
|
||||
assert.equal(portable(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
|
||||
assert.equal(portable(node('table', {}, row())), '')
|
||||
})
|
||||
|
||||
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
|
||||
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
|
||||
assert.equal(portable(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
|
||||
assert.equal(portable(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
|
||||
assert.equal(portable(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
|
||||
})
|
||||
|
||||
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
|
||||
assert.equal(portable(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n')
|
||||
assert.equal(portable(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
|
||||
assert.equal(portable(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
|
||||
})
|
||||
|
||||
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
|
||||
assert.equal(portable(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
|
||||
assert.equal(portable(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
|
||||
assert.equal(portable(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
|
||||
const highlight: AdfMark = { type: 'backgroundColor' }
|
||||
assert.equal(portable(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
|
||||
})
|
||||
|
||||
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
|
||||
assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
|
||||
assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
|
||||
assert.equal(portable(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
|
||||
assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
|
||||
assert.equal(portable(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
|
||||
assert.equal(portable(paragraph(text(' x ', code))), '` x `\n')
|
||||
assert.equal(portable(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||
assert.equal(portable(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||
assert.equal(portable(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
|
||||
assert.equal(portable(paragraph({ content: [text('dropped')], text: 'a', type: 'text' }, text('b'))), 'ab\n')
|
||||
})
|
||||
|
||||
test('keeps a code span opening a line, whose backticks open no fence', () => {
|
||||
assert.equal(portable(paragraph(text('``` x', code))), '```` ``` x ````\n')
|
||||
assert.equal(portable(paragraph(text('a\n'), text('``` x', code))), 'a\\\n```` ``` x ````\n')
|
||||
})
|
||||
|
||||
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
|
||||
assert.equal(portable(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
|
||||
assert.equal(portable(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
|
||||
assert.equal(portable(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
|
||||
assert.equal(portable(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
|
||||
assert.equal(portable(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
|
||||
assert.equal(portable(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
|
||||
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
|
||||
assert.equal(portable(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
||||
})
|
||||
|
||||
test('keeps the nodes the portable flavour spells and degrades only what it cannot', () => {
|
||||
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
|
||||
const reduced = reduceToPortable(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
|
||||
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
|
||||
})
|
||||
|
||||
test('writes what the editor-normal form of the document writes', () => {
|
||||
assert.equal(portable({ attrs: { order: -0 }, content: [{ content: [], type: 'listItem' }], type: 'orderedList' }), '0.\n')
|
||||
assert.equal(portable({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
|
||||
assert.equal(portable(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\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')
|
||||
assert.equal(portable(paragraph(text('b'), holding)), 'ba\n')
|
||||
})
|
||||
+11
-11
@@ -3,11 +3,11 @@ import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims, taskMarker } from './conventions.ts'
|
||||
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { taskMarker } from './conventions.ts'
|
||||
import { toEditorNormal } from './editor-normal.ts'
|
||||
|
||||
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||
@@ -42,16 +42,16 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||
taskList: reduceTaskList,
|
||||
}
|
||||
|
||||
export function adfToPlainMarkdown(document: AdfDocument): Result<string> {
|
||||
const reduced = reduceToPlain(document)
|
||||
return reduced.ok ? writeMarkdown(reduced.value, 'plain') : reduced
|
||||
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
|
||||
const reduced = reduceToPortable(document)
|
||||
return reduced.ok ? writeMarkdown(reduced.value, 'portable') : reduced
|
||||
}
|
||||
|
||||
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
||||
export function reduceToPortable(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}`, [])
|
||||
// Two documents the editor holds equal write the same plain markdown (docs/decisions.md §Equality is deep).
|
||||
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
|
||||
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
|
||||
if (!blocks.ok) {
|
||||
// Merging text renumbers siblings, so a refusal's path comes from the caller's document.
|
||||
@@ -75,7 +75,7 @@ function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<A
|
||||
return entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
|
||||
}),
|
||||
)
|
||||
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||
return blocks.ok ? portableSequence(blocks.value, reduction) : blocks
|
||||
}
|
||||
|
||||
function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
@@ -86,7 +86,7 @@ function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
|
||||
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const blocks = standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
|
||||
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||
return blocks.ok ? portableSequence(blocks.value, reduction) : blocks
|
||||
}
|
||||
|
||||
function standsInline(node: AdfNode): boolean {
|
||||
@@ -113,13 +113,13 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
|
||||
}
|
||||
|
||||
// A list still taking the directive form gives way to its items' blocks.
|
||||
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||
function portableSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
|
||||
for (let index = 0; index < sequence.length; index += 1) {
|
||||
const listed = sequence[index]
|
||||
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
|
||||
const block = numberedPastMarkers(listed)
|
||||
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true
|
||||
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true
|
||||
if (spelled) continue
|
||||
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
|
||||
// Step back to the merged neighbour, which may now take the directive form itself.
|
||||
@@ -245,7 +245,7 @@ function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
|
||||
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
|
||||
if (!leaves.ok) return leaves
|
||||
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
const slot = languageSlot(nodeAttrs(node)['language'], flavourClaims.portable)
|
||||
const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' }
|
||||
return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block])
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
|
||||
|
||||
export type Flavour = 'lossless' | 'plain'
|
||||
export type Flavour = 'commonmark' | 'lossless' | 'portable'
|
||||
|
||||
export type WrittenFlavour = Exclude<Flavour, 'commonmark'>
|
||||
|
||||
// What a reader reads past CommonMark, and so what its writer escapes (docs/decisions.md §Each call names its flavour).
|
||||
export type Claims = {
|
||||
alerts: boolean
|
||||
carryFence: boolean
|
||||
directives: boolean
|
||||
highlights: boolean
|
||||
pipeTables: boolean
|
||||
strikethrough: boolean
|
||||
taskMarkers: boolean
|
||||
}
|
||||
|
||||
export const flavourClaims: Readonly<Record<Flavour, Claims>> = {
|
||||
commonmark: { alerts: false, carryFence: false, directives: false, highlights: false, pipeTables: false, strikethrough: false, taskMarkers: false },
|
||||
lossless: { alerts: false, carryFence: true, directives: true, highlights: false, pipeTables: true, strikethrough: true, taskMarkers: false },
|
||||
portable: { alerts: true, carryFence: false, directives: false, highlights: true, pipeTables: true, strikethrough: true, taskMarkers: true },
|
||||
}
|
||||
|
||||
type AlertMarker = { folded: boolean; length: number; panelType: string }
|
||||
|
||||
@@ -6,7 +6,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
|
||||
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts'
|
||||
import { portableLineFallback, type PortableLineFallback } from '../emit/inline-line.ts'
|
||||
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||
|
||||
const highlight = 'backgroundColor'
|
||||
@@ -118,7 +118,7 @@ function lineBreak(container: LineContainer): AdfNode {
|
||||
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
|
||||
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
|
||||
const text = value.replace(/[\r\u0000]/g, '')
|
||||
const kept = plainMarks(marks, container, text)
|
||||
const kept = portableMarks(marks, container, text)
|
||||
const leaves: AdfNode[] = []
|
||||
for (const [index, line] of text.split('\n').entries()) {
|
||||
if (index > 0) leaves.push(lineBreak(container))
|
||||
@@ -132,13 +132,13 @@ function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
|
||||
}
|
||||
|
||||
// A highlight goes first, where `highlighted` looks for it, and code last, the only place its spelling holds.
|
||||
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
|
||||
function portableMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
|
||||
const kept: AdfMark[] = []
|
||||
for (const mark of marks) {
|
||||
if (!keptMarks.includes(mark.type) || kept.some((held) => held.type === mark.type)) continue
|
||||
if (mark.type === 'code' && container === 'table-cell' && text.includes('|')) continue
|
||||
const plain = mark.type === 'link' ? plainLink(mark, container) : { type: mark.type }
|
||||
if (plain !== undefined) kept.push(plain)
|
||||
const portable = mark.type === 'link' ? portableLink(mark, container) : { type: mark.type }
|
||||
if (portable !== undefined) kept.push(portable)
|
||||
}
|
||||
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
|
||||
// The reader highlights no code, as Atlassian's schema allows none.
|
||||
@@ -146,7 +146,7 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s
|
||||
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
|
||||
}
|
||||
|
||||
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
const attrs = nodeAttrs(mark)
|
||||
const held = attrs['href']
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined
|
||||
@@ -257,16 +257,16 @@ function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index
|
||||
|
||||
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
for (let current = leaves; ; ) {
|
||||
const fallback = plainLineFallback(current, container, path)
|
||||
const fallback = portableLineFallback(current, container, path)
|
||||
if (!fallback.ok) return fallback
|
||||
if (fallback.value === undefined) return success(current)
|
||||
const fixed = withoutFallback(current, fallback.value)
|
||||
if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path)
|
||||
if (fixed === undefined) return failure('unsupported-node-shape', 'a portable line keeps a spelling that dropping a mark does not change', path)
|
||||
current = trimmedEdges(fixed)
|
||||
}
|
||||
}
|
||||
|
||||
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
||||
function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback): AdfNode[] | undefined {
|
||||
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
||||
const mark = nodeMarks(leaves[0] ?? {})[0]
|
||||
if (mark === undefined || mark.type !== 'link') return undefined
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
|
||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
function taskIds(document: AdfDocument): unknown[] {
|
||||
const ids: unknown[] = []
|
||||
const pending: AdfNode[] = [...(document.content ?? [])].reverse()
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId'])
|
||||
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function tasks(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
test('mints each task node a UUID v4 in document order, unique and the same every run', () => {
|
||||
const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
|
||||
const minted = unminted()
|
||||
mintTaskIds(minted, '- [ ] a\n')
|
||||
const ids = taskIds(minted)
|
||||
assert.equal(ids.length, 4)
|
||||
for (const id of ids) assert.match(String(id), uuidV4)
|
||||
assert.equal(new Set(ids).size, 4)
|
||||
const again = unminted()
|
||||
mintTaskIds(again, '- [ ] a\n')
|
||||
assert.deepEqual(taskIds(again), ids)
|
||||
const other = unminted()
|
||||
mintTaskIds(other, '- [ ] b\n')
|
||||
assert.equal(taskIds(other).some((id) => ids.includes(id)), false)
|
||||
})
|
||||
@@ -1,30 +1,20 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { nodeContent } from '../../adf/document.ts'
|
||||
|
||||
const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList'])
|
||||
|
||||
export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet<AdfNode>): void {
|
||||
const taken = new Set<string>()
|
||||
for (const node of preorder(document, () => true)) {
|
||||
const localId = nodeAttrs(node)['localId']
|
||||
if (typeof localId === 'string') taken.add(localId)
|
||||
}
|
||||
export function mintTaskIds(document: AdfDocument, markdown: string): void {
|
||||
const seed = hash128(markdown).join(' ')
|
||||
let count = 0
|
||||
for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) {
|
||||
if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue
|
||||
let localId = ''
|
||||
do {
|
||||
count += 1
|
||||
localId = uuidV4(hash128(`${seed} ${count}`))
|
||||
} while (taken.has(localId))
|
||||
taken.add(localId)
|
||||
node.attrs = { ...node.attrs, localId }
|
||||
for (const node of preorder(document)) {
|
||||
if (!taskTypes.has(node.type)) continue
|
||||
count += 1
|
||||
node.attrs = { ...node.attrs, localId: uuidV4(hash128(`${seed} ${count}`)) }
|
||||
}
|
||||
}
|
||||
|
||||
function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean): Generator<AdfNode> {
|
||||
function* preorder(document: AdfDocument): Generator<AdfNode> {
|
||||
const pending: AdfNode[] = []
|
||||
const pushReversed = (nodes: readonly AdfNode[]): void => {
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
@@ -35,7 +25,7 @@ function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean):
|
||||
pushReversed(document.content ?? [])
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
yield node
|
||||
if (entered(node)) pushReversed(nodeContent(node))
|
||||
if (blockNodeModel(node.type)?.contentModel === 'block') pushReversed(nodeContent(node))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user