diff --git a/AGENTS.md b/AGENTS.md index c505a91..758876e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; 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 `markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything diff --git a/README.md b/README.md index 5249f2c..7a00569 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ adfToMarkdown(doc: AdfDocument): Result markdownToAdf(markdown: string): Result isAdfDocument(v: unknown): v is AdfDocument +adfToPlainMarkdown(doc: AdfDocument): Result +plainMarkdownToAdf(markdown: string): Result + adfToHtml(doc: AdfDocument): Result // 0.2.0 htmlToAdf(html: string): Result // 0.2.0 markdownToHtml(markdown: string): Result // 0.2.0, via ADF @@ -94,6 +97,42 @@ htmlToMarkdown(html: string): Result // 0.2.0, via ADF `Result` 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 An ADF node type this version does not know is not an error: it is carried opaquely and restores diff --git a/package-tests/consumer.ts b/package-tests/consumer.ts index 9a03b94..d8d06bd 100644 --- a/package-tests/consumer.ts +++ b/package-tests/consumer.ts @@ -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 emitted: Result = adfToMarkdown(document) const parsed: Result = markdownToAdf('x\n') +const plainEmitted: Result = adfToPlainMarkdown(document) +const plainParsed: Result = plainMarkdownToAdf('x\n') const guarded: boolean = isAdfDocument(document) const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code 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 } diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 31c777a..58712f6 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -4,10 +4,21 @@ import test from 'node:test' import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.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 { plainMarkdownToAdf } from '../markdown/parse/plain-lift.ts' import { toEditorNormal } from '../adf/editor-normal.ts' 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 }, () => { fc.assert( @@ -21,3 +32,17 @@ test('a generated document refuses to emit, or its markdown reads back to it', { 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), + ) +}) diff --git a/src/index.ts b/src/index.ts index d761f20..c3f770a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { JsonValue } from './json-value.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 { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' +export { plainMarkdownToAdf } from './markdown/parse/plain-lift.ts' diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 007ee63..47fcb2d 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -221,10 +221,11 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | const text = leaf.text if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined 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 trailDepth = edgeDepth(marks, next, trail) + const trailDepth = edgeDepth(marks, next, lead === text ? text : trail) 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[] = [] if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth))) const core = text.slice(lead.length, text.length - trail.length) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 6aafd56..8b82372 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -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()), '') 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', {})), item(said('Configure')))), '-\n- Configure\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({ 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(' '), 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(' x ', code))), '` x `\n') assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n') diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 33e935b..fd7ccca 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -2,7 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { alertMarker, foldedAlertMarker, taskMarker } from '../plain-conventions.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 { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' @@ -41,6 +41,11 @@ const blockReducers: Readonly> = { taskList: reduceTaskList, } +export function adfToPlainMarkdown(document: AdfDocument): Result { + const reduced = reduceToPlain(document) + return reduced.ok ? adfToMarkdown(reduced.value) : reduced +} + export function reduceToPlain(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) @@ -224,12 +229,13 @@ function listItem(blocks: Result): Result { // 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 { 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' } } -function blankedLines(code: AdfNode): AdfNode { - return code.text === undefined ? code : { ...code, text: code.text.replace(/^[ \t]+$/gm, '') } +function blankedLines(code: readonly AdfNode[]): AdfNode[] { + const blanked = code.map((leaf) => leaf.text ?? '').join('').replace(/^[ \t]+$/gm, '') + return blanked === '' ? [] : [text(blanked)] } function reduceTaskList(node: AdfNode, reduction: Reduction): Result { diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts index 2a3ed02..fe5d74e 100644 --- a/src/markdown/parse/plain-lift.ts +++ b/src/markdown/parse/plain-lift.ts @@ -2,8 +2,10 @@ import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { isWordCharacter } from '../commonmark/emphasis-matching.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 { 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 } @@ -11,6 +13,11 @@ type MarkerLed = { marker: T; rest: AdfNode[] } const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } +export function plainMarkdownToAdf(markdown: string): Result { + const parsed = markdownToAdf(markdown) + return parsed.ok ? success(liftFromPlain(parsed.value)) : parsed +} + export function liftFromPlain(document: AdfDocument): AdfDocument { return { ...document, content: liftBlocks(nodeContent(document), false) } } diff --git a/todo-history.md b/todo-history.md index 1327c67..4eec577 100644 --- a/todo-history.md +++ b/todo-history.md @@ -1076,6 +1076,10 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li row above. - [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. + - [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` diff --git a/todo.md b/todo.md index 9a4df2f..fb7016b 100644 --- a/todo.md +++ b/todo.md @@ -257,10 +257,7 @@ chunk clearing a §11 seam. and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes. - [x] **10a — The reduction.** - [x] **10b — The lift.** - - [ ] **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. + - [x] **10c — The exports.** - [ ] **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 task list after `plainMarkdownToAdf(adfToPlainMarkdown(doc))`, and a human's `\==x==` too: