From 123a6995e99d88e8ad4eec3c5719f5796d9ff935 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 22 Oct 2026 19:35:37 +0200 Subject: [PATCH] Move the portable flavour's reading and writing into markdown/portable/ --- .oxlintrc.json | 8 +- AGENTS.md | 2 +- ci.sh | 10 +- docs/decisions.md | 22 +- docs/reading-cost.md | 4 + src/conformance/adf-property.test.ts | 3 +- src/conformance/markdown-property.test.ts | 3 +- src/index.ts | 3 +- src/markdown/emit/adf-to-markdown.ts | 110 +++------- src/markdown/emit/inline-line.ts | 2 +- src/markdown/emit/line-escaping.ts | 2 +- src/markdown/flavour.ts | 12 +- src/markdown/highlight-syntax.ts | 22 ++ src/markdown/inline-tokens.ts | 2 +- src/markdown/parse/inline-content.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 3 +- src/markdown/parse/markdown-to-adf.ts | 189 +++--------------- .../portable/adf-to-portable-markdown.test.ts | 2 +- .../portable/adf-to-portable-markdown.ts | 5 +- src/markdown/portable/block-spelling.ts | 80 ++++++++ src/markdown/portable/conventions.ts | 22 -- .../portable-markdown-to-adf.test.ts | 4 +- .../portable/portable-markdown-to-adf.ts | 152 ++++++++++++++ todo.md | 13 +- 24 files changed, 364 insertions(+), 313 deletions(-) create mode 100644 src/markdown/highlight-syntax.ts create mode 100644 src/markdown/portable/block-spelling.ts rename src/markdown/{parse => portable}/portable-markdown-to-adf.test.ts (99%) create mode 100644 src/markdown/portable/portable-markdown-to-adf.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 35f1ccb..48131ae 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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" }] }] } } ], diff --git a/AGENTS.md b/AGENTS.md index c313aa6..f3c0fa3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/ci.sh b/ci.sh index 2079d23..5639d85 100755 --- a/ci.sh +++ b/ci.sh @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index cbbd8bb..f6f4a64 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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()`. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 096d893..db3b710 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -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. diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 03d9946..dbb0ddc 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -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 diff --git a/src/conformance/markdown-property.test.ts b/src/conformance/markdown-property.test.ts index a7ee666..16ba048 100644 --- a/src/conformance/markdown-property.test.ts +++ b/src/conformance/markdown-property.test.ts @@ -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' diff --git a/src/index.ts b/src/index.ts index 1804603..ff76013 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 5045c53..9b95904 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.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 | 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 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 { - return withinEngineLimits(() => writeMarkdown(document, 'lossless')) + return withinEngineLimits(() => writeMarkdown(document, { flavour: 'lossless' })) } -export function writeMarkdown(document: AdfDocument, flavour: WrittenFlavour): Result { +export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Result { 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 { +export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result { let headroom = largestNesting - depth if (headroom < 0) return tooDeep(path) const blocks: PlacedBlock[] = [] @@ -76,7 +82,7 @@ function tooDeep(path: ConvertErrorPath): Result { 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 { +export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { 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 | 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 | 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 { - 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 { - 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 { - 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 { - 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 | 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 } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index d74d469..221ff83 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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' diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index cd6adb2..090071c 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.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' diff --git a/src/markdown/flavour.ts b/src/markdown/flavour.ts index 097e646..e7b3cad 100644 --- a/src/markdown/flavour.ts +++ b/src/markdown/flavour.ts @@ -4,23 +4,21 @@ export type WrittenFlavour = Exclude // 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> = { - 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 }, } diff --git a/src/markdown/highlight-syntax.ts b/src/markdown/highlight-syntax.ts new file mode 100644 index 0000000..68e4800 --- /dev/null +++ b/src/markdown/highlight-syntax.ts @@ -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) +} diff --git a/src/markdown/inline-tokens.ts b/src/markdown/inline-tokens.ts index 284098b..4001774 100644 --- a/src/markdown/inline-tokens.ts +++ b/src/markdown/inline-tokens.ts @@ -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. diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 41ce8e1..3d391b3 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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' diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index bc3a929..17ee554 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.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' } diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 0f09746..11c0771 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 +// 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 + quote: (blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number) => Result +} -// 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 { - return readDocument(markdown, 'commonmark') + return readDocument(markdown, { flavour: 'commonmark' }) } export function losslessMarkdownToAdf(markdown: string): Result { - return readDocument(markdown, 'lossless') + return readDocument(markdown, { flavour: 'lossless' }) } -export function portableMarkdownToAdf(markdown: string): Result { - return readDocument(markdown, 'portable') -} - -function readDocument(markdown: string, flavour: Flavour): Result { +export function readDocument(markdown: string, readAs: ReadAs): Result { return positioned( - withinEngineLimits(() => readMarkdown(markdown, flavour)), + withinEngineLimits(() => readMarkdown(markdown, readAs)), { line: 1, offset: 0 }, ) } -function readMarkdown(markdown: string, flavour: Flavour): Result { +function readMarkdown(markdown: string, readAs: ReadAs): Result { 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 { +export function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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(path: ConvertErrorPath): Result { +export function tooDeep(path: ConvertErrorPath): Result { 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 { +function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result { 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(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 { - 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): Result { - 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 { - 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 | 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 { 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 { +export function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { +export function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { +export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result { const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour) if (!content.ok) return content return success(withContent(node, content.value.nodes)) diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 3ddf016..7b1b89c 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -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' } diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 4d60a14..5473381 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -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> = { export function adfToPortableMarkdown(document: AdfDocument): Result { 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) diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts new file mode 100644 index 0000000..0b2dcd2 --- /dev/null +++ b/src/markdown/portable/block-spelling.ts @@ -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 | 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 { + 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 { + 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 { + 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 { + 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 }) +} diff --git a/src/markdown/portable/conventions.ts b/src/markdown/portable/conventions.ts index 6cca461..8c73b16 100644 --- a/src/markdown/portable/conventions.ts +++ b/src/markdown/portable/conventions.ts @@ -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> = { error: 'CAUTION', @@ -14,9 +11,6 @@ const alertWords: Readonly> = { 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> = { attention: 'warning', bug: 'error', @@ -56,22 +50,6 @@ export function leadingMarker(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]' : '[ ]' } diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/portable/portable-markdown-to-adf.test.ts similarity index 99% rename from src/markdown/parse/portable-markdown-to-adf.test.ts rename to src/markdown/portable/portable-markdown-to-adf.test.ts index 944d3b2..4d79614 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/portable/portable-markdown-to-adf.test.ts @@ -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' } diff --git a/src/markdown/portable/portable-markdown-to-adf.ts b/src/markdown/portable/portable-markdown-to-adf.ts new file mode 100644 index 0000000..58a8e0e --- /dev/null +++ b/src/markdown/portable/portable-markdown-to-adf.ts @@ -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 + +// 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 { + const read = readDocument(markdown, { blocks: portableBlockReaders, flavour: 'portable' }) + if (read.ok) mintTaskIds(read.value, markdown) + return read +} + +function markerLed(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 { + 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): Result { + 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 { + 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 | 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 { + const node = contentNode({ type: 'paragraph' }, paragraph.text, reading, path, 'paragraph') + return node.ok ? success([node.value]) : node +} diff --git a/todo.md b/todo.md index 8829b33..3e97939 100644 --- a/todo.md +++ b/todo.md @@ -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`.** | 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