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 124 additions and 27 deletions
+13 -13
View File
@@ -7,8 +7,10 @@ that item's entry in `todo-history.md`.
## 1. Three formats, ADF is the hub ## 1. Three formats, ADF is the hub
ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose
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. The lossy pair wraps
each one doubles the directions. two of them, an ADF→ADF reduction ahead of `adfToMarkdown` and an ADF→ADF lift after
`markdownToAdf`, and whatever it adds stays ADF→ADF (the maintainer, 2026-09-14). No fourth format,
ever; each one doubles the directions.
## 2. The round-trip is the product ## 2. The round-trip is the product
@@ -416,18 +418,16 @@ bump on `main` publishes, §9 — every release is the maintainer's) and the `NP
### Ask, don't guess ### Ask, don't guess
Any choice where what the maintainer would pick is not near-certain gets asked, and the answer Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
lands as a decision in this file. The confidence bar is very high — asking too often is the is very high — asking too often is the accepted cost, guessing wrong is not.
accepted cost, guessing wrong is not.
An ask is a gap in this file, and its answer is the rule that closes the gap, never the instance An ask is a gap in this file, and its answer is the rule that closes it, landing here — never the
alone. Before asking, name the class the question belongs to and the earlier `(the maintainer, …)` instance alone. Before asking, name the class the question belongs to and the earlier
entries of that class; where a rule already decides it, apply it without asking, and where the rule `(the maintainer, …)` entries of that class; where a rule already decides it, apply it without
reads two ways on this input, that reading is the ask. Never ask "A or B?": state the gap, the asking, and where the rule reads two ways on this input, that reading is the ask. Never ask "A or
earlier asks of its class, the nearest text here, a candidate rule in this file's voice and section, B?": state the gap, the earlier asks of its class, the nearest text here, a candidate rule in this
and the instance it yields, and ask for the rule. The maintainer answers the rule, the rule lands file's voice and section, and the instance it yields. A rule that keeps collecting instances is
here, and the instance follows from it in the chunk. A rule that keeps collecting instances is wrong: rewrite it.
wrong: rewrite it rather than append to it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than Which output the audience expects — README goal 5 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only asked: three fresh-context readers, one per README persona the conversion serves, each given only
+43 -2
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,44 @@ 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. To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`:
saving what this pair read replaces mentions, attachments and macros with text.
| 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 outside a link or code span 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`, which Atlassian's schema requires on `taskList`,
`taskItem` and `blockTaskItem`: mint one where the receiving site requires it.
## 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
@@ -117,7 +158,7 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
or before the refusal — currently the start of the line the enclosing block begins on; a later or before the refusal — currently the start of the line the enclosing block begins on; a later
minor may narrow that, never widen it. minor may narrow that, never widen it.
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`: Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
@@ -175,7 +216,7 @@ emit refuses:
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
input. input.
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist - Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
is the `taskList` directive. is the `taskList` directive — `plainMarkdownToAdf` lifts the marker.
- A document nested deeper than 500 levels is an error result, not a stack overflow. - A document nested deeper than 500 levels is an error result, not a stack overflow.
- The emitted formats are semver surface (AGENTS.md §8). - The emitted formats are semver surface (AGENTS.md §8).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
+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'
+2 -1
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(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, 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')
@@ -304,6 +306,7 @@ test('drops an empty paragraph and merges adjacent lists of one type', () => {
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n') assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n') assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n') assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
assert.equal(plain(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list) const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n') assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
}) })
+12 -5
View File
@@ -1,8 +1,8 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' 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 { adfToMarkdown, commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.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 { 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, [])
@@ -73,7 +78,8 @@ function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
} }
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
return standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction) const blocks = standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
} }
function standsInline(node: AdfNode): boolean { function standsInline(node: AdfNode): boolean {
@@ -224,12 +230,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`
+9 -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:
@@ -269,6 +266,14 @@ chunk clearing a §11 seam.
it is a gap to ask. The same class runs the other way: a highlighted `=` writes `=====`, which it is a gap to ask. The same class runs the other way: a highlighted `=` writes `=====`, which
reads back as text, and a highlighted `a==b` writes `==a==b==`, highlighting `a` alone; 10c's reads back as text, and a highlighted `a==b` writes `==a==b==`, highlighting `a` alone; 10c's
byte-for-byte property misses both, since the wrong document re-spells to the same bytes. byte-for-byte property misses both, since the wrong document re-spells to the same bytes.
- [ ] **10e — A callout's body stays its body.** `plainMarkdownToAdf` reads Obsidian's own
spelling, `> [!faq]- Why?` with the body on the next `>` line, as an expand titled with the
whole paragraph — `"Why? See the docs and code."` — and an empty body, dropping the link
target and the code mark Goal 5 keeps; an unfolded `> [!tip] Title` merges the title into
the body's first line. Item 10's settled "the rest of the marker's paragraph as its title"
reads against Goal 5 here, and the line edge is gone once `markdownToAdf` has joined the
paragraph, so which seam reads it is part of the gap to ask: candidate rule "the title is
the marker's line; the lines after it open the body" (10c's review, 2026-09-26).
- [x] **11 — Atlassian's ADF schema as the tables' truth.** - [x] **11 — Atlassian's ADF schema as the tables' truth.**
- [x] **11a — The vendored schema.** - [x] **11a — The vendored schema.**
- [x] **11b — The gate.** - [x] **11b — The gate.**