mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-13 04:08:29 +01:00
Move the portable flavour's reading and writing into markdown/portable/
This commit is contained in:
+7
-1
@@ -9,10 +9,16 @@
|
||||
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "**/parse/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/markdown/emit/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/markdown/parse/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "!../emit/spelling-questions.ts"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
|
||||
"no-restricted-imports": ["error", { "patterns": [{ "group": ["**/emit/**", "!../emit/spelling-questions.ts", "**/portable/**"], "message": "docs/decisions.md §The source parts by ADF and format" }] }]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -61,7 +61,7 @@ In `docs/decisions.md`:
|
||||
- Firefox reads the build
|
||||
- The coverage floors
|
||||
- The size ratchet
|
||||
- The project ships under the comprehension floor until items 62 and 95 land
|
||||
- The project ships under the comprehension floor until item 95 lands
|
||||
- Properties on a fixed seed
|
||||
- The CommonMark suite checks three ways
|
||||
- The flavour spec is read as a source
|
||||
|
||||
@@ -9,22 +9,26 @@ leg "lint ($node_image)" in_image "$node_image" npm run lint
|
||||
|
||||
# The boundaries pass by finding nothing, so probe that each still fires: oxlint bumps automerge on green.
|
||||
boundaries_fire() {
|
||||
local allowed=src/markdown/parse/boundary-probe-allowed.ts directive=src/markdown/directive/boundary-probe.ts directive_emit=src/markdown/directive/boundary-probe-emit.ts lint_log parse=src/markdown/parse/boundary-probe.ts
|
||||
local allowed=src/markdown/parse/boundary-probe-allowed.ts directive=src/markdown/directive/boundary-probe.ts directive_emit=src/markdown/directive/boundary-probe-emit.ts emit=src/markdown/emit/boundary-probe.ts lint_log parse=src/markdown/parse/boundary-probe.ts parse_portable=src/markdown/parse/boundary-probe-portable.ts
|
||||
lint_log=$(mktemp) &&
|
||||
# The paths are baked in: the trap fires after this function's locals are gone.
|
||||
trap "rm -f $allowed $directive $directive_emit $lint_log $parse" EXIT &&
|
||||
trap "rm -f $allowed $directive $directive_emit $emit $lint_log $parse $parse_portable" EXIT &&
|
||||
trap 'exit 130' INT &&
|
||||
trap 'exit 143' TERM &&
|
||||
echo "import '../emit/spelling-questions.ts';" > "$allowed" &&
|
||||
in_image "$node_image" npm run lint &&
|
||||
echo "import '../parse/blocks.ts';" > "$directive" &&
|
||||
echo "import '../emit/image.ts';" > "$directive_emit" &&
|
||||
echo "import '../portable/conventions.ts';" > "$emit" &&
|
||||
echo "import '../emit/adf-to-markdown.ts';" > "$parse" &&
|
||||
echo "import '../portable/conventions.ts';" > "$parse_portable" &&
|
||||
! in_image "$node_image" npm run lint -- --format unix 2>&1 | tee "$lint_log" &&
|
||||
grep -q "^$directive:.*eslint(no-restricted-imports)" "$lint_log" &&
|
||||
grep -q "^$directive_emit:.*eslint(no-restricted-imports)" "$lint_log" &&
|
||||
grep -q "^$emit:.*eslint(no-restricted-imports)" "$lint_log" &&
|
||||
grep -q "^$parse:.*eslint(no-restricted-imports)" "$lint_log" &&
|
||||
rm -f "$allowed" "$directive" "$directive_emit" "$lint_log" "$parse"
|
||||
grep -q "^$parse_portable:.*eslint(no-restricted-imports)" "$lint_log" &&
|
||||
rm -f "$allowed" "$directive" "$directive_emit" "$emit" "$lint_log" "$parse" "$parse_portable"
|
||||
}
|
||||
|
||||
leg "import boundaries fire ($node_image)" boundaries_fire
|
||||
|
||||
+13
-9
@@ -688,11 +688,11 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul
|
||||
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
||||
exits 0 on.
|
||||
|
||||
## The project ships under the comprehension floor until items 62 and 95 land
|
||||
## The project ships under the comprehension floor until item 95 lands
|
||||
|
||||
2026-10-03, the maintainer; item 95 in place of item 61, 2026-10-09, the maintainer. KISS, a
|
||||
technical principle, and its comprehension floor of 7.
|
||||
Valid while `todo.md` items 62 and 95 are open.
|
||||
2026-10-03, the maintainer; item 95 in place of item 61, 2026-10-09, the maintainer; item 62
|
||||
landed 2026-10-22. KISS, a technical principle, and its comprehension floor of 7.
|
||||
Valid while `todo.md` item 95 is open.
|
||||
|
||||
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
|
||||
2026-10-08 is the baseline.
|
||||
@@ -911,7 +911,8 @@ is the markdown flavour's choice, not ADF's.
|
||||
## The source parts by ADF and format
|
||||
|
||||
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the import
|
||||
boundaries' lint 2026-10-22, the maintainer. Goal 2. Valid while each format has a reader and a
|
||||
boundaries' lint 2026-10-22, the maintainer; the portable flavour's own directory, item 62,
|
||||
2026-10-22. Goal 2. Valid while each format has a reader and a
|
||||
writer through ADF.
|
||||
|
||||
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
|
||||
@@ -925,8 +926,10 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const
|
||||
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
||||
what both read where there are two.
|
||||
|
||||
A flavour's own code, both directions included, sits in its own directory: `markdown/portable/`;
|
||||
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. The directive grammar, both
|
||||
A flavour's own code, both directions included, sits in its own directory: `markdown/portable/`.
|
||||
It hands `parse/` and `emit/` the blocks it reads and writes its own way, so neither imports it.
|
||||
Syntax that `parse/` and `emit/` read for a claim in `flavourClaims`, such as `==`, sits in
|
||||
`markdown/` itself. The directive grammar, both
|
||||
directions included, sits in `markdown/directive/`, which imports neither `emit/` nor `parse/`.
|
||||
Otherwise each format directory parts into `emit/` (ADF→format) and
|
||||
`parse/` (format→ADF), the rest of it holding what both directions read. A construct's reader lives
|
||||
@@ -939,7 +942,8 @@ consults it rather than restating it, so no fixture the emitter writes can be re
|
||||
spelling the emitter refuses gives its own error rather than a second name for it. That consulting
|
||||
is the only import `parse/` takes from `emit/`, through `emit/spelling-questions.ts`.
|
||||
|
||||
`.oxlintrc.json` enforces that limit for `markdown/parse/`, and keeps `markdown/directive/` from
|
||||
importing `emit/` or `parse/`, as `no-restricted-imports` overrides. They are lint rules because the
|
||||
`.oxlintrc.json` enforces that limit for `markdown/parse/`, keeps `parse/` and `emit/` from
|
||||
importing `markdown/portable/`, and keeps `markdown/directive/` from importing `emit/` or `parse/`,
|
||||
as `no-restricted-imports` overrides. They are lint rules because the
|
||||
technical principles refuse a test that scans the source, and a lint rule reads every import
|
||||
spelling: `from`, a bare `import`, double quotes and a dynamic `import()`.
|
||||
|
||||
@@ -458,3 +458,7 @@ Read most (opens and searches):
|
||||
## Reading cost, chunk d728d0158069, finished 2026-10-22T11:05:26Z
|
||||
|
||||
60874 new input tokens before the first commit.
|
||||
|
||||
## Reading cost, chunk 5b5bf9403c95, finished 2026-10-22T17:27:25Z
|
||||
|
||||
114435 new input tokens before the first commit.
|
||||
|
||||
@@ -6,7 +6,8 @@ import type { AdfNode } from '../adf/document.ts'
|
||||
import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.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 { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../markdown/portable/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
|
||||
@@ -13,7 +13,7 @@ import { adfToPortableMarkdown } from '../markdown/portable/adf-to-portable-mark
|
||||
import { blockArgument, marksAttribute } from '../markdown/directive/block.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { carryName } from '../markdown/opaque-carry.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import {
|
||||
directivePrefix,
|
||||
spellAttributes,
|
||||
@@ -30,6 +30,7 @@ import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { markSpelling } from '../markdown/mark-spellings.ts'
|
||||
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
||||
import { portableMarkdownToAdf } from '../markdown/portable/portable-markdown-to-adf.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { textDirectiveName } from '../markdown/directive/text.ts'
|
||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||
|
||||
+2
-1
@@ -4,5 +4,6 @@ export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Resu
|
||||
export type { JsonValue } from './json-value.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 { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts'
|
||||
export { isAdfDocument } from './adf/document.ts'
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { WrittenFlavour } from '../flavour.ts'
|
||||
import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts'
|
||||
import { blockDirectiveForm, documentSpelling } from '../directive/block.ts'
|
||||
import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts'
|
||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -22,41 +21,48 @@ import { tryPipeTable } from './pipe-table.ts'
|
||||
type BlockContainer = 'directive' | 'document' | 'list-item'
|
||||
type Delimiters = readonly [string, string]
|
||||
// headroom: the nesting levels the deepest node below may still spend before `largestNesting` refuses it.
|
||||
type EmittedBlock = SpelledBlock & { headroom: number }
|
||||
export type EmittedBlock = SpelledBlock & { headroom: number }
|
||||
// A flavour's own blocks, spelled before CommonMark's, and the openings its reader claims, which a list item or quote escapes.
|
||||
export type FlavourBlockWriters = {
|
||||
claimsItem: (text: string) => boolean
|
||||
claimsQuote: (text: string) => boolean
|
||||
spell: (node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing) => Result<EmittedBlock> | undefined
|
||||
}
|
||||
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||
type PlacedBlock = SpelledBlock & { node: AdfNode }
|
||||
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||
export type PlacedBlock = SpelledBlock & { node: AdfNode }
|
||||
export type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
|
||||
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
|
||||
// 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: WrittenFlavour; memo: SpellingMemo | undefined }
|
||||
export type WrittenAs = { flavour: 'lossless' } | { blocks: FlavourBlockWriters; flavour: 'portable' }
|
||||
export type Writing = WrittenAs & { memo: SpellingMemo | undefined }
|
||||
|
||||
const bulletDelimiters: Delimiters = ['-', '*']
|
||||
export const bulletDelimiters: Delimiters = ['-', '*']
|
||||
export const largestListMarker = 999999999
|
||||
// Bare because tryList admits no item carrying attributes, marks or text.
|
||||
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
||||
const orderedDelimiters: Delimiters = ['.', ')']
|
||||
|
||||
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
|
||||
return withinEngineLimits(() => writeMarkdown(document, 'lossless'))
|
||||
return withinEngineLimits(() => writeMarkdown(document, { flavour: 'lossless' }))
|
||||
}
|
||||
|
||||
export function writeMarkdown(document: AdfDocument, flavour: WrittenFlavour): Result<string> {
|
||||
export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): 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}`, [])
|
||||
if (document.content === undefined) return success(`${documentSpelling}\n`)
|
||||
// Every character of text is spelled at least once, so this much text cannot fit, and none of it is spelled.
|
||||
if (heldTextLength(document.content) > longestMarkdown) return tooLong()
|
||||
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
|
||||
const walk = walkBlocks(document.content, [], 0, { ...writtenAs, memo: undefined })
|
||||
if (!walk.ok) return walk
|
||||
const text = joinBlocks(walk.value.blocks, 'document')
|
||||
return withinLongest(text === '' ? '' : `${text}\n`)
|
||||
}
|
||||
|
||||
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
let headroom = largestNesting - depth
|
||||
if (headroom < 0) return tooDeep(path)
|
||||
const blocks: PlacedBlock[] = []
|
||||
@@ -76,7 +82,7 @@ function tooDeep(path: ConvertErrorPath): Result<never> {
|
||||
return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
}
|
||||
|
||||
function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer): string {
|
||||
export function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer): string {
|
||||
let text = ''
|
||||
let alternated = false
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
@@ -106,7 +112,7 @@ function interruptsParagraph(node: AdfNode): boolean {
|
||||
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
||||
}
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const model = blockNodeModel(node.type)
|
||||
if (model === undefined) return carriedFence(carriedBlock(node, path, depth))
|
||||
const readable = readableBlock(node, path, depth, writing, 'walked')
|
||||
@@ -141,8 +147,8 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
||||
}
|
||||
|
||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined
|
||||
if (portable !== undefined) return portable
|
||||
const own = writing.flavour === 'portable' ? writing.blocks.spell(node, path, depth, writing) : undefined
|
||||
if (own !== undefined) return own
|
||||
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, writing.flavour))
|
||||
@@ -154,76 +160,13 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
|
||||
return 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)
|
||||
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, writing.flavour)
|
||||
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
|
||||
}
|
||||
|
||||
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||
if (!inner.ok) return inner
|
||||
const body = joinBlocks(inner.value.blocks, 'document')
|
||||
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
|
||||
}
|
||||
|
||||
function quoted(text: string): string {
|
||||
export function quoted(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const items: PlacedBlock[][] = []
|
||||
let headroom = largestNesting - depth - 1
|
||||
// A child other than a task nests in the task before it.
|
||||
for (const [index, child] of nodeContent(node).entries()) {
|
||||
const task = isTaskItem(child)
|
||||
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
|
||||
if (!walk.ok) return walk
|
||||
headroom = Math.min(headroom, walk.value.headroom)
|
||||
const previous = items.at(-1)
|
||||
if (task || previous === undefined) items.push([...walk.value.blocks])
|
||||
else for (const block of walk.value.blocks) previous.push(block)
|
||||
}
|
||||
const list = spelledList(
|
||||
items.map((blocks) => joinBlocks(blocks, 'list-item')),
|
||||
() => '',
|
||||
bulletDelimiters,
|
||||
headroom,
|
||||
)
|
||||
// The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour.
|
||||
return list === undefined ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success(list)
|
||||
}
|
||||
|
||||
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
const block = emitBlock(node, path, depth, writing)
|
||||
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
||||
}
|
||||
|
||||
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
const marker = taskMarker(nodeAttrs(task)['state'])
|
||||
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
||||
if (task.type === 'taskItem') {
|
||||
const content = nodeContent(task)
|
||||
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
|
||||
if (!line.ok) return line
|
||||
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
|
||||
}
|
||||
const walk = walkBlocks(nodeContent(task), path, depth, writing)
|
||||
if (!walk.ok) return walk
|
||||
const [first, ...rest] = walk.value.blocks
|
||||
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
|
||||
return success({ blocks, headroom: walk.value.headroom })
|
||||
}
|
||||
|
||||
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
||||
return text === undefined ? undefined : success(commonMarkText(text))
|
||||
}
|
||||
@@ -233,7 +176,7 @@ function carriedFence(carried: Result<{ headroom: number; text: string }>): Resu
|
||||
return success({ ...carried.value, spelling: 'commonmark' })
|
||||
}
|
||||
|
||||
function commonMarkText(text: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
|
||||
export function commonMarkText(text: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
|
||||
return { headroom, spelling: 'commonmark', text }
|
||||
}
|
||||
|
||||
@@ -268,7 +211,7 @@ 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 = flavourClaims[writing.flavour].alerts && leadingMarker(text, readAlertMarker) !== undefined
|
||||
const alert = writing.flavour === 'portable' && writing.blocks.claimsQuote(text)
|
||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||
}
|
||||
|
||||
@@ -333,8 +276,7 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
|
||||
}
|
||||
const inners = walked.map((item) => {
|
||||
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.
|
||||
return flavourClaims[writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
|
||||
return writing.flavour === 'portable' && writing.blocks.claimsItem(inner) ? `\\${inner}` : inner
|
||||
})
|
||||
const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom)
|
||||
if (list !== undefined) return success(list)
|
||||
@@ -343,7 +285,7 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
|
||||
return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
|
||||
}
|
||||
|
||||
function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): EmittedBlock | undefined {
|
||||
export function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): EmittedBlock | undefined {
|
||||
const text = listLines(inners, number, usual)
|
||||
const alternateText = listLines(inners, number, alternate)
|
||||
return text === undefined || alternateText === undefined ? undefined : { alternateText, headroom, spelling: 'list', text }
|
||||
|
||||
@@ -9,7 +9,7 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../
|
||||
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { highlightDelimiter } from '../highlight-syntax.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../co
|
||||
import { delimiterRunToken, inlineSource, readInlineToken } from '../inline-tokens.ts'
|
||||
import { escapesLineClaim, type LinePosition } from '../commonmark/grammar.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { highlightFlanking } from '../portable/conventions.ts'
|
||||
import { highlightFlanking } from '../highlight-syntax.ts'
|
||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||
|
||||
|
||||
@@ -4,23 +4,21 @@ 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
|
||||
// `gfm`: a row may drop its leading `|`, and an alignment colon is read and dropped; `flavour`: every row opens with `|`, the delimiter row `-` runs only.
|
||||
pipeTables: 'flavour' | 'gfm' | false
|
||||
strikethrough: boolean
|
||||
taskMarkers: boolean
|
||||
}
|
||||
|
||||
type WrittenClaims = Claims & { pipeTables: 'flavour' | 'gfm'; strikethrough: true }
|
||||
|
||||
// Directives exclude alerts and highlights: an expand directive leaves `inExpand` unset and a directive's slot reads `==`.
|
||||
type ReadClaims = Claims & ({ directives: false } | { alerts: false; directives: true; highlights: false })
|
||||
// Directives exclude highlights: a directive's slot reads `==`.
|
||||
type ReadClaims = Claims & ({ directives: false } | { directives: true; highlights: false })
|
||||
|
||||
export const flavourClaims: Readonly<{ commonmark: ReadClaims } & Record<WrittenFlavour, ReadClaims & WrittenClaims>> = {
|
||||
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: 'flavour', strikethrough: true, taskMarkers: false },
|
||||
portable: { alerts: true, carryFence: false, directives: false, highlights: true, pipeTables: 'gfm', strikethrough: true, taskMarkers: true },
|
||||
commonmark: { carryFence: false, directives: false, highlights: false, pipeTables: false, strikethrough: false },
|
||||
lossless: { carryFence: true, directives: true, highlights: false, pipeTables: 'flavour', strikethrough: true },
|
||||
portable: { carryFence: false, directives: false, highlights: true, pipeTables: 'gfm', strikethrough: true },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
|
||||
|
||||
export const highlightDelimiter = '=='
|
||||
|
||||
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
|
||||
const boundingScript = /^[゙゚ーー゙゚\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
|
||||
|
||||
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
|
||||
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
|
||||
const end = index + highlightDelimiter.length
|
||||
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
|
||||
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
|
||||
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
|
||||
}
|
||||
|
||||
function bounds(outside: string, inside: string): boolean {
|
||||
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
|
||||
}
|
||||
|
||||
function flanks(character: string): boolean {
|
||||
return character !== '' && !/\s/.test(character)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { backslashEscape, inlineHtmlConstruct, readBracketedAutolink, readEmailA
|
||||
import { backtickCloser, backtickRun } from './commonmark/backtick-runs.ts'
|
||||
import { claimDirectivePrefix } from './directive/syntax.ts'
|
||||
import { delimiterFlags, runLength } from './commonmark/emphasis-matching.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from './portable/conventions.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from './highlight-syntax.ts'
|
||||
import { readEntityReference } from './commonmark/entity-references.ts'
|
||||
|
||||
// `directive` and `html` carry no `width`: a directive reads by its own grammar, and the reader refuses HTML.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts'
|
||||
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { highlightDelimiter } from '../highlight-syntax.ts'
|
||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
|
||||
import { inlineSource, readInlineToken } from '../inline-tokens.ts'
|
||||
|
||||
@@ -3,8 +3,9 @@ import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { commonMarkToAdf, losslessMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { portableMarkdownToAdf } from '../portable/portable-markdown-to-adf.ts'
|
||||
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strike: AdfMark = { type: 'strike' }
|
||||
|
||||
@@ -2,70 +2,60 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block, DirectiveBlock } from './blocks.ts'
|
||||
import type { BlockDirectiveNode } from '../directive/node-reading.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { Flavour } from '../flavour.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
|
||||
import { commonMarkSpelling, type SpellingMemo } from '../emit/spelling-questions.ts'
|
||||
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
|
||||
import { emptyKeys, nodeAttrs } from '../../adf/document.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertError, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts'
|
||||
import { flavourClaims } from '../flavour.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { leadingMarker, readAlertMarker, readTaskMarker } from '../portable/conventions.ts'
|
||||
import { mintTaskIds } from '../portable/task-ids.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { pathTo, splitTaskList } from '../portable/task-list.ts'
|
||||
import { readBlockDirectiveNode } from '../directive/node-reading.ts'
|
||||
import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts'
|
||||
import { spellsEmpty } from '../directive/empty-keys.ts'
|
||||
import { withinEngineLimits } from '../length-bound.ts'
|
||||
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
// A flavour's own reading of a quote and of a bullet list, which may read to several blocks.
|
||||
export type FlavourBlockReaders = {
|
||||
bulletList: (items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number) => Result<AdfNode[]>
|
||||
quote: (blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number) => Result<AdfNode>
|
||||
}
|
||||
|
||||
// The paragraph a task marker opens: a task item holds no image, so a lone one there reads as its linked alt text.
|
||||
type TaskParagraph = { kind: 'taskParagraph'; position: SourcePosition; text: string }
|
||||
export type ReadAs = { flavour: 'commonmark' | 'lossless' } | { blocks: FlavourBlockReaders; flavour: 'portable' }
|
||||
|
||||
type ReadBlock = Block | TaskParagraph
|
||||
|
||||
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
|
||||
type Reading = { definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
|
||||
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand. An expand directive leaves it unset, so no flavour reads both.
|
||||
export type Reading = ReadAs & { definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo }
|
||||
|
||||
export function commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'commonmark')
|
||||
return readDocument(markdown, { flavour: 'commonmark' })
|
||||
}
|
||||
|
||||
export function losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'lossless')
|
||||
return readDocument(markdown, { flavour: 'lossless' })
|
||||
}
|
||||
|
||||
export function portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'portable')
|
||||
}
|
||||
|
||||
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||
export function readDocument(markdown: string, readAs: ReadAs): Result<AdfDocument, ParseError> {
|
||||
return positioned(
|
||||
withinEngineLimits(() => readMarkdown(markdown, flavour)),
|
||||
withinEngineLimits(() => readMarkdown(markdown, readAs)),
|
||||
{ line: 1, offset: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
function readMarkdown(markdown: string, flavour: Flavour): Result<AdfDocument> {
|
||||
function readMarkdown(markdown: string, readAs: ReadAs): Result<AdfDocument> {
|
||||
if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, [])
|
||||
const parsed = parseBlocks(markdown, flavour)
|
||||
const parsed = parseBlocks(markdown, readAs.flavour)
|
||||
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 = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||
const reading: Reading = { ...readAs, definitions: parsed.definitions, inExpand: false, memo: new Map() }
|
||||
const content = readBlocks(parsed.blocks, reading, [], 0)
|
||||
if (!content.ok) return content
|
||||
const document: AdfDocument = { content: content.value, type: 'doc', version: 1 }
|
||||
if (flavourClaims[flavour].taskMarkers) mintTaskIds(document, markdown)
|
||||
return success(document)
|
||||
return content.ok ? success({ content: content.value, type: 'doc', version: 1 }) : content
|
||||
}
|
||||
|
||||
// `typeof` alone, since anything more throws on a revoked Proxy.
|
||||
@@ -76,7 +66,7 @@ function typeName(value: unknown): string {
|
||||
return type === 'object' ? 'an object' : `a ${type}`
|
||||
}
|
||||
|
||||
function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
export function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return tooDeep(path)
|
||||
const content: AdfNode[] = []
|
||||
for (const block of blocks) {
|
||||
@@ -85,8 +75,8 @@ function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: Conver
|
||||
const fault = documentFault(block) ?? unsupportedNodeShape(`delete the ${documentSpelling} line to give the document content: it stands only as the whole document`)
|
||||
return positioned(faulted(fault, nodePath), block.position)
|
||||
}
|
||||
if (block.kind === 'bulletList' && flavourClaims[reading.flavour].taskMarkers) {
|
||||
const listed = positioned(bulletBlocks(block.items, reading, nodePath, depth, content.length), block.position)
|
||||
if (block.kind === 'bulletList' && reading.flavour === 'portable') {
|
||||
const listed = positioned(reading.blocks.bulletList(block.items, reading, nodePath, depth, content.length), block.position)
|
||||
if (!listed.ok) return listed
|
||||
for (const node of listed.value) content.push(node)
|
||||
continue
|
||||
@@ -98,7 +88,7 @@ function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: Conver
|
||||
return success(content)
|
||||
}
|
||||
|
||||
function tooDeep<T>(path: ConvertErrorPath): Result<T> {
|
||||
export function tooDeep<T>(path: ConvertErrorPath): Result<T> {
|
||||
return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||
}
|
||||
|
||||
@@ -111,12 +101,12 @@ function documentFault(block: DirectiveBlock): ConvertFault | undefined {
|
||||
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
|
||||
}
|
||||
|
||||
function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
switch (block.kind) {
|
||||
case 'blockquote':
|
||||
return flavourClaims[reading.flavour].alerts ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
return reading.flavour === 'portable' ? reading.blocks.quote(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
case 'bulletList':
|
||||
// readBlocks reads a portable task list before it reaches here.
|
||||
// readBlocks reads a portable bullet list before it reaches here.
|
||||
return listNode({ type: 'bulletList' }, block.items, reading, path, depth)
|
||||
case 'code':
|
||||
return codeBlockNode(block.language, block.text, reading, path, depth)
|
||||
@@ -136,134 +126,9 @@ function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, d
|
||||
return success({ type: 'rule' })
|
||||
case 'table':
|
||||
return tableNode(block.rows, reading, path)
|
||||
case 'taskParagraph':
|
||||
return contentNode({ type: 'paragraph' }, block.text, reading, path, 'paragraph')
|
||||
}
|
||||
}
|
||||
|
||||
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
|
||||
if (block?.kind !== 'paragraph') return undefined
|
||||
const marker = leadingMarker(block.text, read)
|
||||
return marker === undefined ? undefined : { marker, position: block.position, text: block.text.slice(marker.length) }
|
||||
}
|
||||
|
||||
function markerLine(text: string): { line: string; rest: string } {
|
||||
const lineEnd = text.indexOf('\n')
|
||||
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
|
||||
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
|
||||
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
|
||||
}
|
||||
|
||||
// docs/decisions.md, An image reads as an image only alone in its paragraph: a marker or title line stands apart from the lines after it.
|
||||
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
|
||||
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
|
||||
}
|
||||
|
||||
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const [first, ...body] = blocks
|
||||
const led = markerLed(first, readAlertMarker)
|
||||
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
|
||||
const { folded, panelType } = led.marker
|
||||
const { line, rest } = markerLine(led.text)
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
|
||||
if (!title.ok) return title
|
||||
const text = titleText(title.value.nodes)
|
||||
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
|
||||
}
|
||||
|
||||
// docs/decisions.md, A callout title keeps its link targets.
|
||||
function titleText(nodes: readonly AdfNode[]): string {
|
||||
let text = ''
|
||||
let linked = ''
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const href = linkTarget(node)
|
||||
text += node.text ?? ''
|
||||
linked += href === undefined ? '' : node.text ?? ''
|
||||
if (href === undefined || linkTarget(nodes[index + 1]) === href) continue
|
||||
if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})`
|
||||
linked = ''
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function linkTarget(node: AdfNode | undefined): string | undefined {
|
||||
const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href
|
||||
return typeof href === 'string' ? href : undefined
|
||||
}
|
||||
|
||||
// Atlassian's schema requires a panel and an expand to hold a block.
|
||||
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
|
||||
if (!content.ok) return content
|
||||
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
|
||||
}
|
||||
|
||||
function taskItems(items: readonly Block[][]) {
|
||||
const led = []
|
||||
for (const [first, ...others] of items) {
|
||||
const marked = markerLed(first, readTaskMarker)
|
||||
if (marked === undefined) return undefined
|
||||
led.push({ ...marked, others })
|
||||
}
|
||||
return led
|
||||
}
|
||||
|
||||
function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number): Result<AdfNode[]> {
|
||||
const tasks = taskBlocks(items, reading, path, depth)
|
||||
if (tasks === undefined) {
|
||||
const list = listNode({ type: 'bulletList' }, items, reading, path, depth)
|
||||
return list.ok ? success([list.value]) : list
|
||||
}
|
||||
if (!tasks.ok) return tasks
|
||||
const { nodes: blocks, refusal } = tasks.value
|
||||
if (refusal === undefined) return success(blocks)
|
||||
const place = pathTo(blocks, refusal.marker)
|
||||
// task-list.test.ts pins that the split keeps the marker; were it lost, the error keeps its pre-split path, since nothing here throws.
|
||||
if (place === undefined) return { error: refusal.error, ok: false }
|
||||
const [blockIndex, ...steps] = place
|
||||
return { error: { ...refusal.error, path: [...path.slice(0, -1), listIndex + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false }
|
||||
}
|
||||
|
||||
// A block a task list refused. Its placeholder holds `marker` as its one child and stands where the block's node would, so the one split that builds the list places it; `within` is the error's path below that node.
|
||||
type Refusal = { error: ConvertError; marker: AdfNode; within: ConvertErrorPath }
|
||||
|
||||
// Nodes read from task blocks and the first refusal among them; a list is read up to the task holding its refusal.
|
||||
type TaskNodes = { nodes: AdfNode[]; refusal: Refusal | undefined }
|
||||
|
||||
function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<TaskNodes> | undefined {
|
||||
const led = taskItems(items)
|
||||
if (led === undefined) return undefined
|
||||
if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0])
|
||||
const tasks: AdfNode[] = []
|
||||
let refusal: Refusal | undefined
|
||||
for (const [index, { marker, others, position, text }] of led.entries()) {
|
||||
const body = text.replace(/^(?:[ \t\n]|\\\n)+/, '')
|
||||
const marked: TaskParagraph[] = body === '' ? [] : [{ kind: 'taskParagraph', position, text: body }]
|
||||
const task: AdfNode & { content: AdfNode[] } = { attrs: { state: marker.state }, content: [], type: 'blockTaskItem' }
|
||||
for (const block of [...marked, ...others]) {
|
||||
const read = readTaskBlock(block, reading, [...path, 'content', index], depth + 1)
|
||||
for (const node of read.nodes) task.content.push(node)
|
||||
refusal ??= read.refusal
|
||||
}
|
||||
tasks.push(task)
|
||||
if (refusal !== undefined) break
|
||||
}
|
||||
return success({ nodes: splitTaskList({ content: tasks, type: 'taskList' }), refusal })
|
||||
}
|
||||
|
||||
// A refused block reads to a placeholder. The split tells only paragraphs and task lists from other blocks, so a paragraph that failed to read stays a paragraph.
|
||||
function readTaskBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, depth: number): TaskNodes {
|
||||
const nodePath = [...path, 'content', 0]
|
||||
const nested = block.kind === 'bulletList' ? taskBlocks(block.items, reading, nodePath, depth) : undefined
|
||||
if (nested?.ok) return nested.value
|
||||
const read = positioned(nested ?? readBlocks([block], reading, path, depth), block.position)
|
||||
if (read.ok) return { nodes: read.value, refusal: undefined }
|
||||
const marker: AdfNode = { text: '', type: 'text' }
|
||||
const type = block.kind === 'taskParagraph' ? 'paragraph' : nested === undefined ? block.kind : 'taskList'
|
||||
return { nodes: [{ content: [marker], type }], refusal: { error: read.error, marker, within: read.error.path.slice(nodePath.length) } }
|
||||
}
|
||||
|
||||
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
|
||||
@@ -355,7 +220,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Readin
|
||||
return positioned(contentNode(node, only.text, reading, path, 'paragraph'), only.position)
|
||||
}
|
||||
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
export function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content = readBlocks(blocks, reading, path, depth + 1)
|
||||
if (!content.ok) return content
|
||||
return success(withContent(node, content.value))
|
||||
@@ -365,7 +230,7 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
|
||||
return content.length === 0 ? node : { ...node, content: [...content] }
|
||||
}
|
||||
|
||||
function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
export function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, blocks] of items.entries()) {
|
||||
const item = containerNode({ type: 'listItem' }, blocks, reading, [...path, 'content', index], depth)
|
||||
@@ -393,7 +258,7 @@ function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath):
|
||||
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> {
|
||||
export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
|
||||
if (!content.ok) return content
|
||||
return success(withContent(node, content.value.nodes))
|
||||
|
||||
@@ -4,7 +4,7 @@ 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'
|
||||
import { portableMarkdownToAdf } from '../parse/markdown-to-adf.ts'
|
||||
import { portableMarkdownToAdf } from './portable-markdown-to-adf.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
|
||||
@@ -11,6 +11,7 @@ import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { linkDefinition } from '../mark-spellings.ts'
|
||||
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
|
||||
import { portableBlockWriters } from './block-spelling.ts'
|
||||
import { splitTaskList } from './task-list.ts'
|
||||
import { taskMarker } from './conventions.ts'
|
||||
import { toEditorNormal } from './editor-normal.ts'
|
||||
@@ -53,7 +54,7 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
|
||||
return withinEngineLimits(() => {
|
||||
const reduced = reduceToPortable(document)
|
||||
return reduced.ok ? writeMarkdown(reduced.value, 'portable') : reduced
|
||||
return reduced.ok ? writeMarkdown(reduced.value, { blocks: portableBlockWriters, flavour: 'portable' }) : reduced
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,7 +133,7 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res
|
||||
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
||||
const block = numberedPastMarkers(next.block)
|
||||
const listed = block.type === 'bulletList' || block.type === 'orderedList'
|
||||
const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : success(!listed)
|
||||
const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { blocks: portableBlockWriters, flavour: 'portable', memo: reduction.memo }) : success(!listed)
|
||||
if (!spelled.ok) return spelled
|
||||
if (spelled.value) {
|
||||
sequence.push(block)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath, Result } from '../../result.ts'
|
||||
import type { EmittedBlock, FlavourBlockWriters, PlacedBlock, PlacedBlocks, Writing } from '../emit/adf-to-markdown.ts'
|
||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from './conventions.ts'
|
||||
import { bulletDelimiters, commonMarkText, emitBlock, joinBlocks, quoted, spelledList, walkBlocks } from '../emit/adf-to-markdown.ts'
|
||||
import { emitInlineLine } from '../emit/inline-line.ts'
|
||||
import { failure, success } from '../../result.ts'
|
||||
import { isTaskItem } from '../../adf/block-nodes.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
|
||||
export const portableBlockWriters: FlavourBlockWriters = {
|
||||
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
|
||||
claimsItem: (text) => leadingMarker(text, readTaskMarker) !== undefined,
|
||||
claimsQuote: (text) => leadingMarker(text, readAlertMarker) !== undefined,
|
||||
spell: spellPortableBlock,
|
||||
}
|
||||
|
||||
// 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)
|
||||
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, writing.flavour)
|
||||
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
|
||||
}
|
||||
|
||||
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||
if (!inner.ok) return inner
|
||||
const body = joinBlocks(inner.value.blocks, 'document')
|
||||
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
|
||||
}
|
||||
|
||||
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const items: PlacedBlock[][] = []
|
||||
let headroom = largestNesting - depth - 1
|
||||
// A child other than a task nests in the task before it.
|
||||
for (const [index, child] of nodeContent(node).entries()) {
|
||||
const task = isTaskItem(child)
|
||||
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
|
||||
if (!walk.ok) return walk
|
||||
headroom = Math.min(headroom, walk.value.headroom)
|
||||
const previous = items.at(-1)
|
||||
if (task || previous === undefined) items.push([...walk.value.blocks])
|
||||
else for (const block of walk.value.blocks) previous.push(block)
|
||||
}
|
||||
const list = spelledList(
|
||||
items.map((blocks) => joinBlocks(blocks, 'list-item')),
|
||||
() => '',
|
||||
bulletDelimiters,
|
||||
headroom,
|
||||
)
|
||||
// The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour.
|
||||
return list === undefined ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success(list)
|
||||
}
|
||||
|
||||
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
const block = emitBlock(node, path, depth, writing)
|
||||
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
||||
}
|
||||
|
||||
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||
const marker = taskMarker(nodeAttrs(task)['state'])
|
||||
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
||||
if (task.type === 'taskItem') {
|
||||
const content = nodeContent(task)
|
||||
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
|
||||
if (!line.ok) return line
|
||||
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
|
||||
}
|
||||
const walk = walkBlocks(nodeContent(task), path, depth, writing)
|
||||
if (!walk.ok) return walk
|
||||
const [first, ...rest] = walk.value.blocks
|
||||
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
|
||||
return success({ blocks, headroom: walk.value.headroom })
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
|
||||
|
||||
type AlertMarker = { folded: boolean; length: number; panelType: string }
|
||||
|
||||
export const foldedAlertMarker = '[!NOTE]-'
|
||||
export const highlightDelimiter = '=='
|
||||
|
||||
const alertWords: Readonly<Record<string, string>> = {
|
||||
error: 'CAUTION',
|
||||
@@ -14,9 +11,6 @@ const alertWords: Readonly<Record<string, string>> = {
|
||||
warning: 'WARNING',
|
||||
}
|
||||
|
||||
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
|
||||
const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
|
||||
|
||||
const panelTypesByWord: Readonly<Record<string, string>> = {
|
||||
attention: 'warning',
|
||||
bug: 'error',
|
||||
@@ -56,22 +50,6 @@ export function leadingMarker<T extends { length: number }>(text: string, read:
|
||||
return rest === '' || /^(?:[ \t\n]|\\\n)/.test(rest) ? marker : undefined
|
||||
}
|
||||
|
||||
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
|
||||
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
|
||||
const end = index + highlightDelimiter.length
|
||||
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
|
||||
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
|
||||
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
|
||||
}
|
||||
|
||||
function bounds(outside: string, inside: string): boolean {
|
||||
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
|
||||
}
|
||||
|
||||
function flanks(character: string): boolean {
|
||||
return character !== '' && !/\s/.test(character)
|
||||
}
|
||||
|
||||
export function taskMarker(state: unknown): string {
|
||||
return state === 'DONE' ? '[x]' : '[ ]'
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToPortableMarkdown } from '../portable/adf-to-portable-markdown.ts'
|
||||
import { adfToPortableMarkdown } from './adf-to-portable-markdown.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { portableMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { portableMarkdownToAdf } from './portable-markdown-to-adf.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block } from '../parse/blocks.ts'
|
||||
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
|
||||
import type { FlavourBlockReaders, Reading } from '../parse/markdown-to-adf.ts'
|
||||
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
import { parseInlineContent } from '../parse/inline-content.ts'
|
||||
import { pathTo, splitTaskList } from './task-list.ts'
|
||||
import { positioned, success } from '../../result.ts'
|
||||
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
|
||||
// The paragraph a task marker opens: a task item holds no image, so a lone one there reads as its linked alt text.
|
||||
type TaskParagraph = { kind: 'taskParagraph'; position: SourcePosition; text: string }
|
||||
|
||||
const portableBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteNode }
|
||||
|
||||
export function portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
const read = readDocument(markdown, { blocks: portableBlockReaders, flavour: 'portable' })
|
||||
if (read.ok) mintTaskIds(read.value, markdown)
|
||||
return read
|
||||
}
|
||||
|
||||
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
|
||||
if (block?.kind !== 'paragraph') return undefined
|
||||
const marker = leadingMarker(block.text, read)
|
||||
return marker === undefined ? undefined : { marker, position: block.position, text: block.text.slice(marker.length) }
|
||||
}
|
||||
|
||||
function markerLine(text: string): { line: string; rest: string } {
|
||||
const lineEnd = text.indexOf('\n')
|
||||
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
|
||||
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
|
||||
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
|
||||
}
|
||||
|
||||
// docs/decisions.md, An image reads as an image only alone in its paragraph: a marker or title line stands apart from the lines after it.
|
||||
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
|
||||
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
|
||||
}
|
||||
|
||||
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const [first, ...body] = blocks
|
||||
const led = markerLed(first, readAlertMarker)
|
||||
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
|
||||
const { folded, panelType } = led.marker
|
||||
const { line, rest } = markerLine(led.text)
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
|
||||
if (!title.ok) return title
|
||||
const text = titleText(title.value.nodes)
|
||||
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
|
||||
}
|
||||
|
||||
// docs/decisions.md, A callout title keeps its link targets.
|
||||
function titleText(nodes: readonly AdfNode[]): string {
|
||||
let text = ''
|
||||
let linked = ''
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const href = linkTarget(node)
|
||||
text += node.text ?? ''
|
||||
linked += href === undefined ? '' : node.text ?? ''
|
||||
if (href === undefined || linkTarget(nodes[index + 1]) === href) continue
|
||||
if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})`
|
||||
linked = ''
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function linkTarget(node: AdfNode | undefined): string | undefined {
|
||||
const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href
|
||||
return typeof href === 'string' ? href : undefined
|
||||
}
|
||||
|
||||
// Atlassian's schema requires a panel and an expand to hold a block.
|
||||
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
|
||||
if (!content.ok) return content
|
||||
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
|
||||
}
|
||||
|
||||
function taskItems(items: readonly Block[][]) {
|
||||
const led = []
|
||||
for (const [first, ...others] of items) {
|
||||
const marked = markerLed(first, readTaskMarker)
|
||||
if (marked === undefined) return undefined
|
||||
led.push({ ...marked, others })
|
||||
}
|
||||
return led
|
||||
}
|
||||
|
||||
function bulletBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number): Result<AdfNode[]> {
|
||||
const tasks = taskBlocks(items, reading, path, depth)
|
||||
if (tasks === undefined) {
|
||||
const list = listNode({ type: 'bulletList' }, items, reading, path, depth)
|
||||
return list.ok ? success([list.value]) : list
|
||||
}
|
||||
if (!tasks.ok) return tasks
|
||||
const { nodes: blocks, refusal } = tasks.value
|
||||
if (refusal === undefined) return success(blocks)
|
||||
const place = pathTo(blocks, refusal.marker)
|
||||
// task-list.test.ts pins that the split keeps the marker; were it lost, the error keeps its pre-split path, since nothing here throws.
|
||||
if (place === undefined) return { error: refusal.error, ok: false }
|
||||
const [blockIndex, ...steps] = place
|
||||
return { error: { ...refusal.error, path: [...path.slice(0, -1), listIndex + blockIndex, ...steps.slice(0, -2), ...refusal.within] }, ok: false }
|
||||
}
|
||||
|
||||
// A block a task list refused. Its placeholder holds `marker` as its one child and stands where the block's node would, so the one split that builds the list places it; `within` is the error's path below that node.
|
||||
type Refusal = { error: ConvertError; marker: AdfNode; within: ConvertErrorPath }
|
||||
|
||||
// Nodes read from task blocks and the first refusal among them; a list is read up to the task holding its refusal.
|
||||
type TaskNodes = { nodes: AdfNode[]; refusal: Refusal | undefined }
|
||||
|
||||
function taskBlocks(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<TaskNodes> | undefined {
|
||||
const led = taskItems(items)
|
||||
if (led === undefined) return undefined
|
||||
if (depth + 1 > largestNesting) return tooDeep([...path, 'content', 0])
|
||||
const tasks: AdfNode[] = []
|
||||
let refusal: Refusal | undefined
|
||||
for (const [index, { marker, others, position, text }] of led.entries()) {
|
||||
const body = text.replace(/^(?:[ \t\n]|\\\n)+/, '')
|
||||
const marked: TaskParagraph[] = body === '' ? [] : [{ kind: 'taskParagraph', position, text: body }]
|
||||
const task: AdfNode & { content: AdfNode[] } = { attrs: { state: marker.state }, content: [], type: 'blockTaskItem' }
|
||||
for (const block of [...marked, ...others]) {
|
||||
const read = readTaskBlock(block, reading, [...path, 'content', index], depth + 1)
|
||||
for (const node of read.nodes) task.content.push(node)
|
||||
refusal ??= read.refusal
|
||||
}
|
||||
tasks.push(task)
|
||||
if (refusal !== undefined) break
|
||||
}
|
||||
return success({ nodes: splitTaskList({ content: tasks, type: 'taskList' }), refusal })
|
||||
}
|
||||
|
||||
// A refused block reads to a placeholder. The split tells only paragraphs and task lists from other blocks, so a paragraph that failed to read stays a paragraph.
|
||||
function readTaskBlock(block: Block | TaskParagraph, reading: Reading, path: ConvertErrorPath, depth: number): TaskNodes {
|
||||
const nodePath = [...path, 'content', 0]
|
||||
const nested = block.kind === 'bulletList' ? taskBlocks(block.items, reading, nodePath, depth) : undefined
|
||||
if (nested?.ok) return nested.value
|
||||
const read = positioned(nested ?? (block.kind === 'taskParagraph' ? taskParagraph(block, reading, nodePath) : readBlocks([block], reading, path, depth)), block.position)
|
||||
if (read.ok) return { nodes: read.value, refusal: undefined }
|
||||
const marker: AdfNode = { text: '', type: 'text' }
|
||||
const type = block.kind === 'taskParagraph' ? 'paragraph' : nested === undefined ? block.kind : 'taskList'
|
||||
return { nodes: [{ content: [marker], type }], refusal: { error: read.error, marker, within: read.error.path.slice(nodePath.length) } }
|
||||
}
|
||||
|
||||
function taskParagraph(paragraph: TaskParagraph, reading: Reading, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
const node = contentNode({ type: 'paragraph' }, paragraph.text, reading, path, 'paragraph')
|
||||
return node.ok ? success([node.value]) : node
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 62 | 0.3.0 | | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 3 | 1 | 2 | 4 | 5 | code health by 5 | 13.1 |
|
||||
| 123 | 0.3.0 | | defect | **Spell a link title holding a backslash, an entity reference or, in a table cell, a pipe, instead of dropping it.** | 3 | 1 | 2 | 4 | 3 | 7 by 2 | 13.1 |
|
||||
| 122 | 0.3.0 | | defect | **Keep an image's URL and title when a link's text holds the image, instead of dropping them.** | 3 | 1 | 2 | 4 | 5 | 7 by 4 | 10.5 |
|
||||
| 95 | 0.3.0 | | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 4 | 1 | 3 | 6 | 6 | code health by 7 | 10.2 |
|
||||
@@ -21,7 +20,7 @@
|
||||
| 109 | 0.3.0 | | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 |
|
||||
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
|
||||
| 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
|
||||
| 73 | 0.3.0 | 33, 62, 91, 95, 109, 115, 122, 123, 125 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
|
||||
| 73 | 0.3.0 | 33, 91, 95, 109, 115, 122, 123, 125 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
|
||||
| 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 |
|
||||
| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 |
|
||||
| 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 |
|
||||
@@ -30,7 +29,7 @@
|
||||
| 80 | 0.4.0 | | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 1 | 2 | 3 | 3 | 6 by 4 | 6.0 |
|
||||
| 81 | 0.4.0 | | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 1 | 1 | 1 | 2 | 3 by 2 | 4.2 |
|
||||
| 6 | 0.4.0 | | decision | **Specify the HTML dialect.** | 3 | 2 | 1 | 4 | 6 | | 0.0 |
|
||||
| 7 | 0.4.0 | 6, 62 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 4 | 2 | 2 | 6 | 9 | 3 by 6 | 0.3 |
|
||||
| 7 | 0.4.0 | 6 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 4 | 2 | 2 | 6 | 9 | 3 by 6 | 0.3 |
|
||||
| 46 | 0.4.0 | 7 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 3.2 |
|
||||
| 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 |
|
||||
| 77 | 0.4.0 | | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 3 | 1 | 3 | 5 | 4 | | 0.0 |
|
||||
@@ -56,14 +55,6 @@
|
||||
|
||||
## Details
|
||||
|
||||
### 62. Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.
|
||||
|
||||
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts` and the `mintTaskIds` call.
|
||||
Writing: `spellPortableBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
|
||||
`emit/adf-to-markdown.ts`. And `highlightDelimiter` and `highlightFlanking` move out of
|
||||
`portable/conventions.ts`, which `emit/` imports them from. Every comprehension reader on
|
||||
2026-10-03 named the portable flavour's spread across three directories.
|
||||
|
||||
### 123. Spell a link title holding a backslash, an entity reference or, in a table cell, a pipe, instead of dropping it.
|
||||
|
||||
The portable writer drops a link's title when CommonMark cannot spell it as written: a title
|
||||
|
||||
Reference in New Issue
Block a user