10c - adfToPlainMarkdown and plainMarkdownToAdf exported #130

Merged
lilleman merged 5 commits from 10c into main 2026-09-26 13:35:22 +02:00
11 changed files with 100 additions and 12 deletions
Showing only changes of commit 613dc0edbb - Show all commits
+3
View File
@@ -10,6 +10,9 @@ ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdow
through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever; through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever;
each one doubles the directions. each one doubles the directions.
The lossy pair is no fifth: `adfToPlainMarkdown` reduces ADF→ADF ahead of `adfToMarkdown`, and
`plainMarkdownToAdf` lifts ADF→ADF after `markdownToAdf` (the maintainer, 2026-09-14).
## 2. The round-trip is the product ## 2. The round-trip is the product
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything `markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
+39
View File
@@ -86,6 +86,9 @@ adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
adfToPlainMarkdown(doc: AdfDocument): Result<string>
plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0 adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0 htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
@@ -94,6 +97,42 @@ htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
## Plain markdown
`adfToPlainMarkdown` writes markdown other tools render — GitHub, GitLab, Obsidian and the like —
keeping the content and dropping the rest: attributes, colours, layout, identity. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive. `plainMarkdownToAdf` reads through `markdownToAdf`, refusing what it refuses, and
lifts the conventions below back into nodes, reading other tools' spellings too. Markdown
`adfToPlainMarkdown` wrote reads back and writes again byte for byte; the document it came from
does not come back.
| ADF | Written | Read back |
| --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | GitHub's five words, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word; an expand inside an expand is a `nestedExpand` |
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
| `backgroundColor` | `==text==` | `==text==` bounded outside by whitespace, punctuation or a line edge, in the editor's default highlight |
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
| `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL | — |
| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — |
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
| `placeholder` | nothing | — |
- Content the document only references leaves an italic note naming it where it stood:
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`,
`_(link card not included)_`, `_(extension not included)_`.
- `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and
so does a mark CommonMark cannot spell where it stands.
- A newline in text is a hard break, edge whitespace is trimmed, carriage returns and null
characters are removed, and an empty paragraph drops.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text.
- A node read back carries no `localId`.
## The errors ## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores An ADF node type this version does not know is not an error: it is carried opaquely and restores
+4 -2
View File
@@ -1,11 +1,13 @@
import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec' import { adfToMarkdown, adfToPlainMarkdown, isAdfDocument, markdownToAdf, plainMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const emitted: Result<string> = adfToMarkdown(document) const emitted: Result<string> = adfToMarkdown(document)
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n') const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
const plainEmitted: Result<string> = adfToPlainMarkdown(document)
const plainParsed: Result<AdfDocument, ParseError> = plainMarkdownToAdf('x\n')
const guarded: boolean = isAdfDocument(document) const guarded: boolean = isAdfDocument(document)
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
export const surface = { code, guarded, line } export const surface = { code, guarded, line, plainEmitted, plainParsed }
+25
View File
@@ -4,10 +4,21 @@ import test from 'node:test'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { plainMarkdownToAdf } from '../markdown/parse/plain-lift.ts'
import { toEditorNormal } from '../adf/editor-normal.ts' import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600 const gateRuns = 1600
const renamedPrefix = '!adg:'
// Renaming the prefix changes what markdown reads only where a directive was read.
function readsNoDirective(markdown: string): boolean {
const read = markdownToAdf(markdown)
const renamed = markdownToAdf(markdown.replaceAll(directivePrefix, renamedPrefix))
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
}
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => { test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
fc.assert( fc.assert(
@@ -21,3 +32,17 @@ test('a generated document refuses to emit, or its markdown reads back to it', {
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
}) })
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to itself', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToPlainMarkdown(document)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
const read = plainMarkdownToAdf(written.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
}),
propertyRuns(gateRuns),
)
})
+2
View File
@@ -2,5 +2,7 @@ export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/documen
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { plainMarkdownToAdf } from './markdown/parse/plain-lift.ts'
+3 -2
View File
@@ -221,10 +221,11 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode |
const text = leaf.text const text = leaf.text
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
const lead = text.slice(0, text.search(/[^ \t]|$/)) const lead = text.slice(0, text.search(/[^ \t]|$/))
const trail = lead === text ? '' : text.slice(text.search(/[ \t]*$/)) const trail = text.slice(Math.max(lead.length, text.search(/[ \t]*$/)))
const leadDepth = edgeDepth(marks, previous, lead) const leadDepth = edgeDepth(marks, previous, lead)
const trailDepth = edgeDepth(marks, next, trail) const trailDepth = edgeDepth(marks, next, lead === text ? text : trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined if (leadDepth === marks.length && trailDepth === marks.length) return undefined
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
const edges: AdfNode[] = [] const edges: AdfNode[] = []
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth))) if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
const core = text.slice(lead.length, text.length - trail.length) const core = text.slice(lead.length, text.length - trail.length)
@@ -229,6 +229,7 @@ test('spells a node no row names, or one standing where no spelling holds it, as
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n') assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
assert.equal(plain(bulletList()), '') assert.equal(plain(bulletList()), '')
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n') assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
assert.equal(plain(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n') assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n') assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n') assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
@@ -285,6 +286,7 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n') assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n') assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n') assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
assert.equal(plain(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n') assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n') assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n') assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
+10 -4
View File
@@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, taskMarker } from '../plain-conventions.ts' import { alertMarker, foldedAlertMarker, taskMarker } from '../plain-conventions.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts' import { adfToMarkdown, commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts' import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
@@ -41,6 +41,11 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
taskList: reduceTaskList, taskList: reduceTaskList,
} }
export function adfToPlainMarkdown(document: AdfDocument): Result<string> {
const reduced = reduceToPlain(document)
return reduced.ok ? adfToMarkdown(reduced.value) : reduced
}
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> { export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
@@ -224,12 +229,13 @@ function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way. // A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode { function itemOf(blocks: readonly AdfNode[]): AdfNode {
const rules = blocks.findIndex((block) => block.type !== 'rule') const rules = blocks.findIndex((block) => block.type !== 'rule')
const content = blocks.slice(rules === -1 ? blocks.length : rules).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) const content = blocks.slice(rules === -1 ? blocks.length : rules).map((block) => (block.type === 'codeBlock' ? { ...block, content: blankedLines(nodeContent(block)) } : block))
return { content, type: 'listItem' } return { content, type: 'listItem' }
} }
function blankedLines(code: AdfNode): AdfNode { function blankedLines(code: readonly AdfNode[]): AdfNode[] {
return code.text === undefined ? code : { ...code, text: code.text.replace(/^[ \t]+$/gm, '') } const blanked = code.map((leaf) => leaf.text ?? '').join('').replace(/^[ \t]+$/gm, '')
return blanked === '' ? [] : [text(blanked)]
} }
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
+7
View File
@@ -2,8 +2,10 @@ import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { isWordCharacter } from '../commonmark/emphasis-matching.ts' import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { markdownToAdf } from './markdown-to-adf.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts' import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { success, type ParseError, type Result } from '../../result.ts'
type Delimiter = { closes: boolean; holder: AdfNode; line: number; node: number; offset: number; opens: boolean; position: number } type Delimiter = { closes: boolean; holder: AdfNode; line: number; node: number; offset: number; opens: boolean; position: number }
@@ -11,6 +13,11 @@ type MarkerLed<T> = { marker: T; rest: AdfNode[] }
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const parsed = markdownToAdf(markdown)
return parsed.ok ? success(liftFromPlain(parsed.value)) : parsed
}
export function liftFromPlain(document: AdfDocument): AdfDocument { export function liftFromPlain(document: AdfDocument): AdfDocument {
return { ...document, content: liftBlocks(nodeContent(document), false) } return { ...document, content: liftBlocks(nodeContent(document), false) }
} }
+4
View File
@@ -1076,6 +1076,10 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
row above. row above.
- [x] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads, - [x] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
other tools' spellings included; the editor's default highlight colour looked up and cited. other tools' spellings included; the editor's default highlight colour looked up and cited.
- [x] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
sections, and two properties over 4.2's generators: writing refuses only the guard's codes
and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
+1 -4
View File
@@ -257,10 +257,7 @@ chunk clearing a §11 seam.
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes. and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [x] **10a — The reduction.** - [x] **10a — The reduction.**
- [x] **10b — The lift.** - [x] **10b — The lift.**
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README - [x] **10c — The exports.**
sections, and two properties over 4.2's generators: writing refuses only the guard's codes
and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
- [ ] **10d — A literal marker survives the lossy round trip.** Text reading `==x==`, a quote - [ ] **10d — A literal marker survives the lossy round trip.** Text reading `==x==`, a quote
opening `[!NOTE]` or a list whose items all open `[x] ` comes back as a highlight, panel or opening `[!NOTE]` or a list whose items all open `[x] ` comes back as a highlight, panel or
task list after `plainMarkdownToAdf(adfToPlainMarkdown(doc))`, and a human's `\==x==` too: task list after `plainMarkdownToAdf(adfToPlainMarkdown(doc))`, and a human's `\==x==` too: