3
0
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:
2026-10-22 19:35:37 +02:00
parent 5278f68262
commit 123a6995e9
24 changed files with 364 additions and 313 deletions
+7 -1
View File
@@ -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" }] }]
}
}
],
+1 -1
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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()`.
+4
View File
@@ -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.
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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'
+26 -84
View File
@@ -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 }
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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'
+5 -7
View File
@@ -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 },
}
+22
View File
@@ -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)
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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'
+2 -1
View File
@@ -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' }
+27 -162
View File
@@ -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)
+80
View File
@@ -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 })
}
-22
View File
@@ -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,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
}
+2 -11
View File
@@ -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