From 613dc0edbb8c17733af97cf7a084cde1f46493ab Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 26 Sep 2026 13:20:16 +0200 Subject: [PATCH 1/5] 10c - adfToPlainMarkdown and plainMarkdownToAdf exported, whitespace-only leaves trimmed at a line end --- AGENTS.md | 3 ++ README.md | 39 +++++++++++++++++++++++ package-tests/consumer.ts | 6 ++-- src/conformance/adf-property.test.ts | 25 +++++++++++++++ src/index.ts | 2 ++ src/markdown/emit/plain-inline.ts | 5 +-- src/markdown/emit/plain-reduction.test.ts | 2 ++ src/markdown/emit/plain-reduction.ts | 14 +++++--- src/markdown/parse/plain-lift.ts | 7 ++++ todo-history.md | 4 +++ todo.md | 5 +-- 11 files changed, 100 insertions(+), 12 deletions(-) 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: -- 2.52.0 From f594dafc5b641d1b413b91096d1c78a91f08437a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 26 Sep 2026 13:26:20 +0200 Subject: [PATCH 2/5] 10c - review: link and code span edges named in the README, 10e filed, leafEdges trail simplified --- README.md | 2 +- src/markdown/emit/plain-inline.ts | 4 ++-- src/markdown/emit/plain-reduction.ts | 2 +- todo.md | 8 ++++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a00569..a690a6f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ does not come back. `_(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 +- 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. diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 47fcb2d..4fec787 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -221,9 +221,9 @@ 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 = text.slice(Math.max(lead.length, text.search(/[ \t]*$/))) + const trail = text.slice(text.search(/[ \t]*$/)) const leadDepth = edgeDepth(marks, previous, lead) - const trailDepth = edgeDepth(marks, next, lead === text ? text : trail) + const trailDepth = edgeDepth(marks, next, 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[] = [] diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index fd7ccca..efd6595 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -1,8 +1,8 @@ import type { AdfDocument, AdfNode } 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 { blockNodeModel } from '../../adf/block-nodes.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' diff --git a/todo.md b/todo.md index fb7016b..1174f33 100644 --- a/todo.md +++ b/todo.md @@ -266,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 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. + - [ ] **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] **11a — The vendored schema.** - [x] **11b — The gate.** -- 2.52.0 From 9e2c0fda15c33c56ba6658e33d2c17434e3fab41 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 26 Sep 2026 13:28:13 +0200 Subject: [PATCH 3/5] 10c - a list standing in a list or task list keeps its numbers as text past the marker cap --- src/markdown/emit/plain-reduction.test.ts | 1 + src/markdown/emit/plain-reduction.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 8b82372..0b94ead 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -306,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(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(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n') 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') }) diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index efd6595..ea7d710 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -78,7 +78,8 @@ function reduceNode(node: AdfNode, reduction: Reduction): Result { } function reduceStanding(node: AdfNode, reduction: Reduction): Result { - 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 { -- 2.52.0 From 326352fa982f5ee6a3e29456ede3ea5af7dda9eb Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 26 Sep 2026 13:32:25 +0200 Subject: [PATCH 4/5] 10c - review: README says which pair saves back, where plainMarkdownToAdf refuses, and the localId the schema wants --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a690a6f..e15d590 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ keeping the content and dropping the rest: attributes, colours, layout, identity 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. +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 | | --- | --- | --- | @@ -127,11 +128,12 @@ does not come back. `_(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. +- 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`. +- 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 @@ -156,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 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 | | --- | --- | --- | @@ -214,7 +216,7 @@ emit refuses: error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in input. - 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. - The emitted formats are semver surface (AGENTS.md §8). - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides -- 2.52.0 From 1f4a94974a6f7924e835cf7cb864924b878a9d7e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 26 Sep 2026 13:33:36 +0200 Subject: [PATCH 5/5] =?UTF-8?q?10c=20-=20prose:=20the=20lossy=20pair=20fol?= =?UTF-8?q?ds=20into=20=C2=A71,=20=C2=A715's=20ask=20rule=20said=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 758876e..73892f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,11 +7,10 @@ that item's entry in `todo-history.md`. ## 1. Three formats, ADF is the hub 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; -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). +through ADF: four conversions exist to keep correct — never write a fifth. The lossy pair wraps +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 @@ -419,18 +418,16 @@ bump on `main` publishes, §9 — every release is the maintainer's) and the `NP ### Ask, don't guess -Any choice where what the maintainer would pick is not near-certain gets asked, and the answer -lands as a decision in this file. The confidence bar is very high — asking too often is the -accepted cost, guessing wrong is not. +Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar +is very high — asking too often is the 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 -alone. Before asking, name the class the question belongs to and the earlier `(the maintainer, …)` -entries of that class; where a rule already decides it, apply it without asking, and where the rule -reads two ways on this input, that reading is the ask. Never ask "A or B?": state the gap, the -earlier asks of its class, the nearest text here, a candidate rule in this file's voice and section, -and the instance it yields, and ask for the rule. The maintainer answers the rule, the rule lands -here, and the instance follows from it in the chunk. A rule that keeps collecting instances is -wrong: rewrite it rather than append to it. +An ask is a gap in this file, and its answer is the rule that closes it, landing here — never the +instance alone. Before asking, name the class the question belongs to and the earlier +`(the maintainer, …)` entries of that class; where a rule already decides it, apply it without +asking, and where the rule reads two ways on this input, that reading is the ask. Never ask "A or +B?": state the gap, the earlier asks of its class, the nearest text here, a candidate rule in this +file's voice and section, and the instance it yields. A rule that keeps collecting instances is +wrong: rewrite it. 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 -- 2.52.0