From e65a2656d33fa7f126645a4c2faf4d81d0609615 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:29:07 +0100 Subject: [PATCH 01/14] Add adfToPlainText, writing a document's text with no markup --- AGENTS.md | 1 + CHANGELOG.md | 1 + README.md | 43 +++++- docs/decisions.md | 25 ++++ docs/reading-cost.md | 16 +++ src/index.ts | 1 + src/plain-text/adf-to-plain-text.test.ts | 176 +++++++++++++++++++++++ src/plain-text/adf-to-plain-text.ts | 118 +++++++++++++++ todo.md | 74 ++++------ 9 files changed, 405 insertions(+), 50 deletions(-) create mode 100644 src/plain-text/adf-to-plain-text.test.ts create mode 100644 src/plain-text/adf-to-plain-text.ts diff --git a/AGENTS.md b/AGENTS.md index 5829458f..16c45a9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ In `docs/decisions.md`: - The comparison tables list every ADF converter that starts and converts - The real-world corpus holds anonymized documents from sites whose licence it records - The feature table is a section per corpus example, led by a summary table +- Plain text is blocks a blank line apart, with `• ` and `1.` markers and nesting indented ## 1. Nothing about any consumer in the library diff --git a/CHANGELOG.md b/CHANGELOG.md index d2732877..bb34438c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `adfToPlainText` writes a document as text with no markup, for a search index, a notification or an email: blocks a blank line apart, `• ` and `1.` list markers with nesting indented, a hard break as a line feed, and a mention, emoji, date or status as the text it shows. It refuses what `adfToHtml` refuses. A table writes its cells as blocks for now. - `readAdfDocument` and the three writers name the node `not-an-adf-document` refuses: `path` leads to a node that is not an ADF node, and the message says what is wrong with it, such as which attribute holds a value that is not JSON. A getter, an object holding itself, a throwing Proxy diff --git a/README.md b/README.md index 7d5e8849..a2fae02c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ products store as ADF. * `adfToPortableMarkdown` — ADF to markdown that GitHub, GitLab and Obsidian render. Keeps the content and drops colours, layout and other attributes. * `adfToHtml` — ADF to HTML. * `htmlToAdf` — HTML to ADF. +* `adfToPlainText` — ADF to text for an email, a notification or a search index: blocks a blank line apart, lists as `• ` and `1.`, no markup. ## Features and perks @@ -164,6 +165,34 @@ const back = html.ok ? htmlToAdf(html.value) : undefined if (back?.ok) console.log(back.value) // deep-equals page ``` +### ADF to plain text + +```ts +import { adfToPlainText, type AdfDocument } from '@larvit/adf-codec' + +const note: AdfDocument = { + content: [ + { content: [{ text: 'Ship on Friday.', type: 'text' }], type: 'paragraph' }, + { + content: [ + { content: [{ content: [{ text: 'Fix login', type: 'text' }], type: 'paragraph' }], type: 'listItem' }, + { content: [{ content: [{ text: 'Fix export', type: 'text' }], type: 'paragraph' }], type: 'listItem' }, + ], + type: 'bulletList', + }, + ], + type: 'doc', + version: 1, +} + +const text = adfToPlainText(note) +if (text.ok) console.log(text.value) +// Ship on Friday. +// +// • Fix login +// • Fix export +``` + ### Handle a result's error Full reference: [The errors](#the-errors). @@ -224,6 +253,10 @@ whatever it costs; the Achieve goals are weighed against each other in the order 7. **Maintain:** Lossy conversion drops form, never content. A link's or an image's title counts as content. 8. **Maintain:** Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect. 9. **Maintain:** Fast, and linear in the document's size as JSON. +10. **Achieve:** Plain text reads like text written for people reading an email. + - Signal: readers shown the candidate texts pick the one the library already writes. + - Counter-signal: issues report plain text that reads as markup or runs blocks together. + - Why: a search index, a notification and an email each show the text as it is, with no renderer to tidy it. ## Audience @@ -315,6 +348,8 @@ readAdfDocument(v: unknown): Result commonMarkToAdf(markdown: string): Result +adfToPlainText(doc: AdfDocument): Result + adfToPortableMarkdown(doc: AdfDocument): Result portableMarkdownToAdf(markdown: string): Result @@ -328,6 +363,7 @@ Each call's name says which format it reads or writes: | --- | --- | --- | | `adfToHtml` | writes HTML: the elements [HTML](#html) lists, and an `adf-carry` element holding everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | +| `adfToPlainText` | writes text with no markup: blocks a blank line apart, list items on a line each under `• ` or `1.`, nesting indented, a hard break a line feed, a mention, emoji, date and status as the text they show | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | @@ -609,7 +645,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r ### Emitting -`adfToLosslessMarkdown`, `adfToPortableMarkdown` and `adfToHtml` raise these, and the codes under +`adfToLosslessMarkdown`, `adfToPlainText`, `adfToPortableMarkdown` and `adfToHtml` raise these, and the codes under [Either direction](#either-direction). `readAdfDocument` raises `not-an-adf-document` and `unsupported-node-shape`, and `engine-limit`. @@ -657,7 +693,7 @@ Every call can raise `engine-limit`, and every conversion `unsupported-nesting-d - **Writing, in `adfToLosslessMarkdown`**: a node not riding the carry that carries one mark type twice. Keep the ADF and show the document read-only; where you built the ADF, fix what the message names. -- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: a shape +- **Writing, in `adfToHtml`, `adfToLosslessMarkdown`, `adfToPlainText` and `adfToPortableMarkdown`**: a shape Atlassian's schema forbids, even inside a node that rides the carry: an inline card a link marks, a known block holding `text`, a known non-text inline node holding `text` or non-empty `content`, or a known block that takes no content, such as `rule`, holding non-empty `content`. Keep the ADF @@ -669,7 +705,7 @@ Every call can raise `engine-limit`, and every conversion `unsupported-nesting-d 10,000,000 JSON values, each object, array, string, number, boolean and null counting one. Keep the ADF and pass the document over or show it read-only; where you built the ADF, split it. - **Past the engine's longest string**: - - a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every + - a writer's markdown, HTML or text would pass 536870888 characters, V8's longest string, on every engine; - in `adfToPortableMarkdown`, the document's text alone would, edge whitespace included; - in `losslessMarkdownToAdf`, a directive's plain CommonMark would; @@ -778,6 +814,7 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do | `SourcePosition` | type | an error's place in the markdown or HTML read | | `adfToHtml` | function | ADF to HTML | | `adfToLosslessMarkdown` | function | ADF to the lossless flavour | +| `adfToPlainText` | function | ADF to text with no markup | | `adfToPortableMarkdown` | function | ADF to portable markdown, lossy | | `commonMarkToAdf` | function | strict CommonMark to ADF | | `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF | diff --git a/docs/decisions.md b/docs/decisions.md index a5e16b30..8603c859 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1296,3 +1296,28 @@ expectations as input. The Date row drew 1 of 3 not reasonable, so four more rea reader of the markdown, export/indexing, viewer/editor) read it, and 5 of 7 found it reasonable. `adfToLosslessMarkdown` keeps writing `!adf:date{timestamp=1756080000000}`. The two who rejected it wanted the day shown, as `!adf:date[2025-08-25]{timestamp=…}`. + +## Plain text is blocks a blank line apart, with `• ` and `1.` markers and nesting indented + +2026-11-14, a reader panel of 7 (export/indexing, LLM/agent, support agent, viewer/editor, bot, +product manager, engineer), then the maintainer. Goal 10. Valid while the audience of the text is +people reading an email. + +Each reader wrote the text they expected, then picked among six shuffled candidates. All seven +expected a blank line between blocks, a hard break as one line feed and a mention as its display +text. Three picked blank lines with no markers and no indent, three picked `• ` bullets with `1.` +numbers kept and nesting indented, one picked `- ` and `1.` markers. Less than five of seven is a +missing goal, so the maintainer chose the second and worded the goal for people reading an email. + +`adfToPlainText` writes: + +- Blocks a blank line apart, an item's blocks and a list's items a line apart. +- `• ` for a bullet list, a task list and a decision list, and `n. ` for an ordered list, counting + from its `order`. A nested list and an item's later lines are indented to the marker's width. +- A hard break as a line feed, a mention, emoji, date and status as the text they show, and marks + as the text they mark. +- A block holding no text as nothing, with no extra blank line. +- A table as its cells written as blocks, until a plan item gives it a spelling. + +The depth and length refusals are `adfToHtml`'s: a document nesting past 500 levels, or text past +V8's longest string, indentation included. diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 36ee8702..de151706 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -1060,3 +1060,19 @@ Read most (opens and searches): - `README.md` 1 - `docs/decisions.md` 1 - `src/markdown/parse/markdown-to-adf.ts` 1 + +## Reading cost, chunk 1125f4a3a4ad, finished 2026-11-14T10:38:41Z + +124303 new input tokens before the first commit. + +Read most (opens and searches): + +- `todo.md` 10 +- `README.md` 5 +- `src/adf/shown-text.ts` 3 +- `docs/decisions.md` 2 +- `src/index.ts` 2 +- `AGENTS.md` 1 +- `docs/score-anchors.md` 1 +- `src/adf/inline-nodes.ts` 1 +- `src/html/emit/adf-to-html.ts` 1 diff --git a/src/index.ts b/src/index.ts index a8b2de42..37332eab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Resu export type { JsonValue } from './json-value.ts' export { adfToHtml } from './html/emit/adf-to-html.ts' export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts' +export { adfToPlainText } from './plain-text/adf-to-plain-text.ts' export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts' export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { htmlToAdf } from './html/parse/html-to-adf.ts' diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts new file mode 100644 index 00000000..6ccfe239 --- /dev/null +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AdfDocument, AdfMark, AdfNode } from '../adf/document.ts' +import type { Result } from '../result.ts' +import { adfToHtml, adfToPlainText } from '../index.ts' +import { largestNesting } from '../nesting.ts' + +function document(...content: AdfNode[]): AdfDocument { + return { content, type: 'doc', version: 1 } +} + +function plain(result: Result): string { + return result.ok ? result.value : `${result.error.code}: ${result.error.message}` +} + +function refusal(result: Result): string { + return result.ok ? 'written' : `${result.error.code} at /${result.error.path.join('/')}` +} + +function paragraph(...content: AdfNode[]): AdfNode { + return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' } +} + +function text(value: string, ...marks: AdfMark[]): AdfNode { + return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' } +} + +function item(...content: AdfNode[]): AdfNode { + return { content, type: 'listItem' } +} + +function bullets(...items: AdfNode[]): AdfNode { + return { content: items, type: 'bulletList' } +} + +function numbers(...items: AdfNode[]): AdfNode { + return { content: items, type: 'orderedList' } +} + +function line(value: string): AdfNode { + return paragraph(text(value)) +} + +test('writes blocks as their text, a blank line apart, with bullets, numbers and nesting indented', () => { + const mention: AdfNode = { attrs: { id: '557058:ann', text: '@Ann Lee' }, type: 'mention' } + const written = adfToPlainText( + document( + { attrs: { level: 2 }, content: [text('Release notes')], type: 'heading' }, + line('Ship on Friday.'), + paragraph(text('Ask '), mention, text(' first.'), { type: 'hardBreak' }, text('Then ship.')), + bullets(item(line('Fix login')), item(line('Fix export'), bullets(item(line('CSV')), item(line('JSON')))), item(line('Docs'))), + numbers(item(line('Build')), item(line('Test')), item(line('Ship'))), + { content: [line('Measure twice.')], type: 'blockquote' }, + { content: [text('npm test')], type: 'codeBlock' }, + line('Done.'), + ), + ) + assert.equal( + plain(written), + [ + 'Release notes', + '', + 'Ship on Friday.', + '', + 'Ask @Ann Lee first.', + 'Then ship.', + '', + '• Fix login', + '• Fix export', + ' • CSV', + ' • JSON', + '• Docs', + '', + '1. Build', + '2. Test', + '3. Ship', + '', + 'Measure twice.', + '', + 'npm test', + '', + 'Done.', + ].join('\n'), + ) +}) + +test('writes marks as the text they mark, and an empty document as nothing', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/', title: 'Home' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph(text('a ', { type: 'strong' }), text('b', link, { type: 'em' }), text(' c', { type: 'code' }))))), 'a b c') + assert.equal(plain(adfToPlainText(document())), '') + assert.equal(plain(adfToPlainText({ type: 'doc', version: 1 })), '') +}) + +test('writes nothing for a block holding no text, and parts the blocks around it once', () => { + assert.equal(plain(adfToPlainText(document(paragraph(), line('a'), { type: 'rule' }, paragraph(), line('b'), bullets(item())))), 'a\n\nb') +}) + +test('numbers an ordered list from its first number, and widens the indent to the marker', () => { + const list: AdfNode = { attrs: { order: 9 }, content: [item(line('nine'), paragraph(text('a'), { type: 'hardBreak' }), line('b')), item(line('ten'), bullets(item(line('x'))))], type: 'orderedList' } + assert.equal(plain(adfToPlainText(document(list))), '9. nine\n a\n b\n10. ten\n • x') + assert.equal(plain(adfToPlainText(document({ attrs: { order: 1.5 }, content: [item(line('a'))], type: 'orderedList' }))), '1. a') +}) + +test('keeps a marker for an item whose first content is a nested list', () => { + assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item(line('x')))), item(line('y')))))), '• • x\n• y') +}) + +test('writes a task list and a decision list as bullets', () => { + const tasks: AdfNode = { content: [{ attrs: { state: 'DONE' }, content: [text('Write the spec')], type: 'taskItem' }, { attrs: { state: 'TODO' }, content: [text('Ship')], type: 'taskItem' }], type: 'taskList' } + const decisions: AdfNode = { content: [{ attrs: { state: 'DECIDED' }, content: [text('Use Node')], type: 'decisionItem' }], type: 'decisionList' } + assert.equal(plain(adfToPlainText(document(tasks, decisions))), '• Write the spec\n• Ship\n\n• Use Node') +}) + +test('writes a mention, an emoji, a date and a status as the text they show', () => { + const nodes: AdfNode[] = [ + { attrs: { id: '557058:ann' }, type: 'mention' }, + { attrs: { shortName: ':smile:', text: '😄' }, type: 'emoji' }, + { attrs: { timestamp: '1756080000000' }, type: 'date' }, + { attrs: { color: 'green', text: 'Done' }, type: 'status' }, + ] + assert.equal(plain(adfToPlainText(document(paragraph(...nodes.flatMap((node) => [node, text(' ')]))))), '@557058:ann 😄 2025-08-25 Done ') +}) + +test('writes a table\'s cells as blocks, until a table has its own spelling', () => { + const cell = (value: string): AdfNode => ({ content: [line(value)], type: 'tableCell' }) + const table: AdfNode = { content: [{ content: [cell('a'), cell('b')], type: 'tableRow' }, { content: [cell('c'), cell('d')], type: 'tableRow' }], type: 'table' } + assert.equal(plain(adfToPlainText(document(table))), 'a\n\nb\n\nc\n\nd') +}) + +test('writes a node of an unknown type as the text it holds, its children a blank line apart', () => { + const unknown: AdfNode = { content: [line('one'), { content: [text('two'), text(' three')], type: 'unknownBlock' }], type: 'unknownContainer' } + assert.equal(plain(adfToPlainText(document(unknown, { text: 'loose', type: 'unknownText' }, paragraph({ content: [text('in'), text(' line')], type: 'unknownInline' })))), 'one\n\ntwo three\n\nloose\n\nin line') +}) + +test('writes a code block\'s lines and a list item\'s continuation lines under their indent, never a blank line with spaces', () => { + const code: AdfNode = { content: [text('a\n\nb')], type: 'codeBlock' } + assert.equal(plain(adfToPlainText(document(bullets(item(code))))), '• a\n\n b') +}) + +test('refuses what adfToHtml refuses, with the same code and path', () => { + const deep = (levels: number): AdfNode => { + let node = line('x') + for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } + return node + } + const refused: AdfDocument[] = [ + { type: 'doc', version: Number.NaN }, + { type: 'doc', version: 2 }, + document(deep(largestNesting + 1)), + document(paragraph(text('See '), { attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })), + ] + for (const input of refused) { + assert.notEqual(refusal(adfToHtml(input)), 'written') + assert.equal(refusal(adfToPlainText(input)), refusal(adfToHtml(input))) + } +}) + +test('writes blocks nested 500 levels', () => { + let node = line('x') + for (let level = 0; level < largestNesting; level += 1) node = { content: [node], type: 'blockquote' } + assert.equal(plain(adfToPlainText(document(node))), 'x') + let list = line('x') + for (let level = 0; level < largestNesting; level += 1) list = bullets(item(list)) + assert.equal(plain(adfToPlainText(document(list))), `${'• '.repeat(largestNesting)}x`) + assert.equal(refusal(adfToPlainText(document(bullets(item(list))))), refusal(adfToHtml(document(bullets(item(list)))))) + assert.equal(refusal(adfToPlainText(document(bullets(item(list))))).startsWith('unsupported-nesting-depth at /content/0/content/0/content/0'), true) +}) + +test('refuses text past V8\'s longest string, which the indent of a deep list can add', () => { + let list: AdfNode = { content: [text('a\n'.repeat(400000))], type: 'codeBlock' } + for (let level = 0; level < 400; level += 1) list = bullets(item(list)) + const refused = adfToPlainText(document(list, list)) + assert.equal(!refused.ok && refused.error.code, 'unsupported-node-shape') + assert.equal(!refused.ok && refused.error.message.includes('536870888'), true) +}) diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts new file mode 100644 index 00000000..e9603096 --- /dev/null +++ b/src/plain-text/adf-to-plain-text.ts @@ -0,0 +1,118 @@ +import type { AdfDocument, AdfNode } from '../adf/document.ts' +import { blockNodeModel } from '../adf/block-nodes.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' +import { inlineNodeModel } from '../adf/inline-nodes.ts' +import { largestNesting } from '../nesting.ts' +import { longestOutput, tooLong, withinEngineLimits } from '../length-bound.ts' +import { nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../adf/document.ts' +import { shownText } from '../adf/shown-text.ts' + +// `gap`: the line feeds the next text needs before it, at least; `leave`: the state a list item restores; `marker`: the list marker of the item a node is; `depth`: the nesting level, as `largestNesting` counts it; `parent`: the node holding this one, from which its path is built when a refusal needs it. +type Place = { index: number; parent: Place | undefined } +type Step = + | { kind: 'gap'; lines: number } + | { kind: 'leave'; indent: string; lead: string | undefined; written: number } + | { depth: number; kind: 'node'; marker: string | undefined; node: AdfNode; place: Place | undefined } + +// `lines`: the line feeds between a node's children; `level`: how much deeper they nest. +type Layout = { level: number; lines: number } + +const bulletLists = ['bulletList', 'decisionList', 'taskList'] +const itemTypes = ['blockTaskItem', 'listItem'] +const shownTextTypes = ['date', 'emoji', 'mention', 'status'] + +export function adfToPlainText(document: AdfDocument): Result { + return withinEngineLimits(() => { + const read = readAdfDocument(document) + if (!read.ok) return read + const content = writableContent(read.value) + return content.ok ? writeText(content.value ?? []) : content + }) +} + +// A stack rather than recursion (docs/decisions.md §Nothing recurses unbounded). Text is written the moment it is met: `gap` holds the line feeds it owes first, `lead` the marker of the item it opens, `indent` the margin of every line after that. +function writeText(content: readonly AdfNode[]): Result { + const pieces: string[] = [] + let length = 0 + let gap = 0 + let indent = '' + let lead: string | undefined + let written = 0 + + function emit(text: string): void { + if (text === '') return + if (lead !== undefined && gap === 0) gap = 1 + const atLineStart = written === 0 || gap > 0 + const head = atLineStart ? `${written === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' + const body = indent === '' ? text : text.replaceAll(/\n(?=[^\n])/g, `\n${indent}`) + pieces.push(head, body) + length += head.length + body.length + gap = 0 + lead = undefined + written += 1 + } + + const pending: Step[] = childSteps(content, { level: 1, lines: 2 }, 0, undefined, () => undefined).reverse() + for (let step = pending.pop(); step !== undefined; step = pending.pop()) { + if (step.kind === 'gap') { + gap = Math.max(gap, step.lines) + continue + } + if (step.kind === 'leave') { + indent = step.indent + lead = step.written === written ? step.lead : undefined + continue + } + const { depth, marker, node, place } = step + if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, pathTo(place)) + if (marker !== undefined) { + pending.push({ indent, kind: 'leave', lead, written }) + lead = lead === undefined ? `${indent}${marker}` : `${lead}${marker}` + indent = `${indent}${' '.repeat(marker.length)}` + } + if (node.type === 'hardBreak') gap = Math.max(gap, 1) + else if (node.type === 'text') emit(node.text ?? '') + else if (shownTextTypes.includes(node.type)) emit(shownText(node) ?? '') + else { + if (typeof node.text === 'string') emit(node.text) + const layout = layoutOf(node) + for (const child of childSteps(nodeContent(node), layout, depth + layout.level, place, markerFor(node)).reverse()) pending.push(child) + } + if (length > longestOutput) return tooLong() + } + return success(pieces.join('')) +} + +// The steps for `nodes` in document order, `layout.lines` line feeds apart. +function childSteps(nodes: readonly AdfNode[], layout: Layout, depth: number, parent: Place | undefined, markerFor: (index: number) => string | undefined): Step[] { + const steps: Step[] = [] + for (const [index, node] of nodes.entries()) { + if (index > 0 && layout.lines > 0) steps.push({ kind: 'gap', lines: layout.lines }) + steps.push({ depth, kind: 'node', marker: markerFor(index), node, place: { index, parent } }) + } + return steps +} + +// Children are written in a line where they are inline, a line apart inside a list and an item, a blank line apart between blocks. A list's items nest one level below it, an item's blocks at its own. +function layoutOf(node: AdfNode): Layout { + if (bulletLists.includes(node.type) || node.type === 'orderedList') return { level: 1, lines: 1 } + if (itemTypes.includes(node.type)) return { level: 0, lines: 1 } + if (node.type === 'tableCell' || node.type === 'tableHeader') return { level: 0, lines: 2 } + const model = blockNodeModel(node.type) + if (model !== undefined) return model.contentModel === 'block' ? { level: 1, lines: 2 } : { level: 0, lines: 0 } + return nodeContent(node).every((child) => child.type === 'text' || inlineNodeModel(child.type) !== undefined) ? { level: 0, lines: 0 } : { level: 1, lines: 2 } +} + +function markerFor(node: AdfNode): (index: number) => string | undefined { + if (bulletLists.includes(node.type)) return () => '• ' + if (node.type !== 'orderedList') return () => undefined + const order = nodeAttrs(node)['order'] + const first = typeof order === 'number' && Number.isSafeInteger(order) ? order : 1 + return (index) => `${first + index}. ` +} + +function pathTo(place: Place | undefined): ConvertErrorPath { + const indexes: number[] = [] + for (let held = place; held !== undefined; held = held.parent) indexes.push(held.index) + return indexes.reverse().flatMap((index) => ['content', index]) +} diff --git a/todo.md b/todo.md index ab6ba1b1..5b9b41a3 100644 --- a/todo.md +++ b/todo.md @@ -12,38 +12,37 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 179 | 0.4.0 | | | **Ship `adfToPlainText`, writing a document's text with no markup, under a README goal for plain text.** | 2 | 2 | 2 | 4 | 5 | 3 by 4, 6 by 2 | 3.7 | -| 180 | 0.4.0 | 179 | | **Write a table in `adfToPlainText` as one line per row, so a CSV export or a search index reads its cells.** | 2 | 2 | 2 | 4 | 3 | 3 by 1, 6 by 3 | 6.1 | -| 181 | 0.4.0 | 179 | | **Write media, link cards, extensions and expand titles in `adfToPlainText` as the text each shows, and answer developer-community topic 41858.** | 2 | 2 | 2 | 4 | 4 | 3 by 2, 6 by 2 | 4.1 | -| 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 4.0 | -| 135 | 0.4.0 | | | **Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 | -| 136 | 0.4.0 | | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 2.0 | -| 134 | 0.4.0 | | decision | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.8 | -| 46 | 0.4.0 | 134, 135, 136 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 3.2 | -| 174 | 0.4.0 | | defect | **Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasized blanks trims in linear time.** | 3 | 1 | 2 | 4 | 7 | 9 by 3 | 2.3 | -| 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 | -| 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | -| 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | -| 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.8 | +| 180 | 0.4.0 | | | **Write a table in `adfToPlainText` as one line per row, so a CSV export or a search index reads its cells.** | 2 | 2 | 2 | 4 | 3 | 3 by 1, 6 by 3 | 4.9 | +| 181 | 0.4.0 | | | **Write media, link cards, extensions and expand titles in `adfToPlainText` as the text each shows, and answer developer-community topic 41858.** | 2 | 2 | 2 | 4 | 4 | 3 by 2, 6 by 2 | 3.3 | +| 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 3.2 | +| 135 | 0.4.0 | | | **Write media, media groups and captions as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 1.6 | +| 136 | 0.4.0 | | | **Write underline, text and background colours, subscript, superscript, alignment and indentation as HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 2 | 1.6 | +| 134 | 0.4.0 | | decision | **Write panels, expands, task lists and decision lists as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 3 | 1.5 | +| 46 | 0.4.0 | 134, 135, 136 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 2 | 1 | 3 | 4 | 3 by 3 | 2.6 | +| 174 | 0.4.0 | | defect | **Bound the passes `trimmedEdges` takes by a constant, so a line of alternating code and emphasized blanks trims in linear time.** | 3 | 1 | 2 | 4 | 7 | 9 by 3 | 1.9 | +| 51 | 0.4.0 | | | **Match a reference label to its definition under Unicode case folding.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 1.7 | +| 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.8 | +| 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.8 | +| 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.6 | | 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 | | 170 | 0.4.0 | | owner | **Bundle the packed tarball with webpack, esbuild, Rollup and Bun in the gate, failing on any bundler warning, and run each bundle's round-trip in Node.** | 3 | 1 | 1 | 3 | 5 | | 0.0 | -| 42 | 0.5.0 | | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 1 | 2 | 2 | 2 | 9 by 3 | 26.2 | -| 31 | 0.5.0 | | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | -| 57 | 0.5.0 | | | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | -| 38 | 0.5.0 | | defect | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 1 | 1 | 2 | 2 | 2 | 1 by 2 | 17.4 | -| 76 | 0.5.0 | | | **State the files the package does not ship once, so the build, the lint ceiling, the coverage exclusions and the mutation run cannot drift apart.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | -| 56 | 0.5.0 | | | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 3 | 1 | 2 | 4 | 5 | code health by 5 | 13.1 | -| 118 | 0.5.0 | | | **Spell a portable link's title through `oneLine`.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 | -| 82 | 0.5.0 | | | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 2 | 1 | 1 | 2 | 3 | code health by 1 | 6.5 | -| 178 | 0.5.0 | | owner | **Reach the comprehension floor: move the reasoning behind the emitter's fallback and escape loops into the code that runs them, so each reads on its own.** | 3 | 1 | 2 | 4 | 7 | code health by 8 | 6.2 | -| 58 | 0.5.0 | | | **Port `ci.sh`, `publish.sh`, `docker-runner.sh` and `mutation-tests/run.sh` to standalone Python scripts.** | 4 | 2 | 2 | 6 | 6 | code health by 3 | 4.4 | -| 9 | 0.5.0 | | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 3 | 2 | 1 | 4 | 6 | 3 by 6 | 2.1 | -| 34 | 0.5.0 | | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 1 | 2 | 3 | 3 | 4 by 1 | 1.5 | +| 42 | 0.5.0 | | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 1 | 2 | 2 | 2 | 9 by 3 | 21.2 | +| 31 | 0.5.0 | | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 15.9 | +| 57 | 0.5.0 | | | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 15.9 | +| 38 | 0.5.0 | | defect | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 1 | 1 | 2 | 2 | 2 | 1 by 2 | 14.2 | +| 76 | 0.5.0 | | | **State the files the package does not ship once, so the build, the lint ceiling, the coverage exclusions and the mutation run cannot drift apart.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 14.2 | +| 56 | 0.5.0 | | | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 3 | 1 | 2 | 4 | 5 | code health by 5 | 10.6 | +| 118 | 0.5.0 | | | **Spell a portable link's title through `oneLine`.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 8.5 | +| 82 | 0.5.0 | | | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 2 | 1 | 1 | 2 | 3 | code health by 1 | 5.3 | +| 178 | 0.5.0 | | owner | **Reach the comprehension floor: move the reasoning behind the emitter's fallback and escape loops into the code that runs them, so each reads on its own.** | 3 | 1 | 2 | 4 | 7 | code health by 8 | 5.0 | +| 58 | 0.5.0 | | | **Port `ci.sh`, `publish.sh`, `docker-runner.sh` and `mutation-tests/run.sh` to standalone Python scripts.** | 4 | 2 | 2 | 6 | 6 | code health by 3 | 3.5 | +| 9 | 0.5.0 | | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 3 | 2 | 1 | 4 | 6 | 3 by 6 | 1.7 | +| 34 | 0.5.0 | | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 1 | 2 | 3 | 3 | 4 by 1 | 1.2 | | 74 | 0.5.0 | | owner | **Keep the release path publishing after npm retires bypass-2FA tokens.** | 3 | 2 | 4 | 7 | 4 | | 0.0 | | 8 | 0.6.0 | | owner | **Ship a CLI.** | 3 | 2 | 1 | 4 | 7 | | 0.0 | -| 139 | 0.7.0 | | decision | **Ship the calls between markdown and HTML, composed through ADF and each named by its flavour.** | 2 | 1 | 2 | 3 | 4 | 3 by 3 | 3.2 | -| 137 | 0.7.0 | | decision | **Read foreign HTML in `htmlToAdf` the three ways `docs/decisions.md` §Foreign HTML sorts three ways states.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 2.1 | -| 138 | 0.7.0 | 137 | | **Read raw HTML in every markdown reader through `htmlToAdf`'s foreign-element mapping, in place of refusing it as `unmappable-html`.** | 3 | 1 | 3 | 5 | 5 | 4 by 4 | 2.5 | +| 139 | 0.7.0 | | decision | **Ship the calls between markdown and HTML, composed through ADF and each named by its flavour.** | 2 | 1 | 2 | 3 | 4 | 3 by 3 | 2.6 | +| 137 | 0.7.0 | | decision | **Read foreign HTML in `htmlToAdf` the three ways `docs/decisions.md` §Foreign HTML sorts three ways states.** | 2 | 1 | 2 | 3 | 4 | 4 by 2 | 1.7 | +| 138 | 0.7.0 | 137 | | **Read raw HTML in every markdown reader through `htmlToAdf`'s foreign-element mapping, in place of refusing it as `unmappable-html`.** | 3 | 1 | 3 | 5 | 5 | 4 by 4 | 2.0 | ## Renamed @@ -51,25 +50,6 @@ ## Details -### 179. Ship `adfToPlainText`, writing a document's text with no markup, under a README goal for plain text. - -Asked on the Atlassian developer community for search indexing, notifications, emails and CSV -exports, where markdown's markers are noise. Add the goal to the README's `## Goals` in the same -change. Its wording and rank among the goals are the maintainer's to approve in the PR. The -maintainer chose 0.4.0 for it, to ship what exists first. - -The call takes an `AdfDocument` and returns `Result`. It refuses what `adfToHtml` refuses, -with the same codes: `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, -`unsupported-nesting-depth` and `unsupported-node-shape`. It has no reader, so goal 1's -losslessness can't apply, but it still returns a result and never throws. Goal 7 applies: the call -drops markup, never content. A node writes its text, and a container its children. A hard break is -a newline. A mention writes `@Display Name` from its `text`, as `src/adf/shown-text.ts` does, which -falls back to `@` only where the mention has no text. A node of an unknown type writes the text -it holds, so no content is lost before items 180 and 181 refine the nodes. - -A reader panel (`AGENTS.md`, "Ask, don't guess") picks how blocks part and how a list's items read: -markers, indentation or numbers. The verdict lands in `docs/decisions.md`. - ### 180. Write a table in `adfToPlainText` as one line per row, so a CSV export or a search index reads its cells. A reader panel picks the cell separator, and what a header row, a merged cell and a block nested in From 05dea24053df2c937fd7b2f5f3989c0c0fbf9e5b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:33:00 +0100 Subject: [PATCH 02/14] Kill adfToPlainText's surviving mutants and drop the conditions they showed redundant --- src/plain-text/adf-to-plain-text.test.ts | 40 +++++++++++++++++++++++- src/plain-text/adf-to-plain-text.ts | 19 +++++------ 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index 6ccfe239..df610e28 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -96,6 +96,19 @@ test('writes nothing for a block holding no text, and parts the blocks around it assert.equal(plain(adfToPlainText(document(paragraph(), line('a'), { type: 'rule' }, paragraph(), line('b'), bullets(item())))), 'a\n\nb') }) +test('writes a text node holding nothing as nothing', () => { + assert.equal(plain(adfToPlainText(document(line('a'), paragraph(text('')), line('b')))), 'a\n\nb') +}) + +test('keeps the text of a paragraph on its line, and an item\'s blocks a line apart, whatever they hold', () => { + assert.equal(plain(adfToPlainText(document(bullets(item(paragraph(text('a'), text('b'))))))), '• ab') + assert.equal(plain(adfToPlainText(document(bullets(item(line('a')), item({ content: [line('b')], type: 'blockquote' }))))), '• a\n• b') + assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item(line('x'))), line('z')))))), '• • x\n z') + assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item()), line('x')))))), '• x') + const inline: AdfNode = paragraph({ text: 'a', type: 'unknownInline' }, { text: 'b', type: 'unknownInline' }) + assert.equal(plain(adfToPlainText(document(inline))), 'ab') +}) + test('numbers an ordered list from its first number, and widens the indent to the marker', () => { const list: AdfNode = { attrs: { order: 9 }, content: [item(line('nine'), paragraph(text('a'), { type: 'hardBreak' }), line('b')), item(line('ten'), bullets(item(line('x'))))], type: 'orderedList' } assert.equal(plain(adfToPlainText(document(list))), '9. nine\n a\n b\n10. ten\n • x') @@ -106,6 +119,11 @@ test('keeps a marker for an item whose first content is a nested list', () => { assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item(line('x')))), item(line('y')))))), '• • x\n• y') }) +test('writes a task item holding blocks as an item', () => { + const tasks: AdfNode = { content: [{ attrs: { localId: 'a', state: 'TODO' }, content: [line('a'), line('b')], type: 'blockTaskItem' }], type: 'taskList' } + assert.equal(plain(adfToPlainText(document(tasks))), '• a\n b') +}) + test('writes a task list and a decision list as bullets', () => { const tasks: AdfNode = { content: [{ attrs: { state: 'DONE' }, content: [text('Write the spec')], type: 'taskItem' }, { attrs: { state: 'TODO' }, content: [text('Ship')], type: 'taskItem' }], type: 'taskList' } const decisions: AdfNode = { content: [{ attrs: { state: 'DECIDED' }, content: [text('Use Node')], type: 'decisionItem' }], type: 'decisionList' } @@ -148,6 +166,7 @@ test('refuses what adfToHtml refuses, with the same code and path', () => { { type: 'doc', version: Number.NaN }, { type: 'doc', version: 2 }, document(deep(largestNesting + 1)), + document(line('a'), deep(largestNesting + 1)), document(paragraph(text('See '), { attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })), ] for (const input of refused) { @@ -156,6 +175,23 @@ test('refuses what adfToHtml refuses, with the same code and path', () => { } }) +test('counts a table\'s levels as adfToHtml does', () => { + const deep = (levels: number): AdfNode => { + let node = line('x') + for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } + return node + } + const outcomes = new Set() + for (const cell of ['tableCell', 'tableHeader']) { + for (let levels = largestNesting - 3; levels <= largestNesting + 1; levels += 1) { + const table: AdfNode = { content: [{ content: [{ content: [deep(levels)], type: cell }], type: 'tableRow' }], type: 'table' } + assert.equal(refusal(adfToPlainText(document(table))), refusal(adfToHtml(document(table)))) + outcomes.add(refusal(adfToPlainText(document(table))).slice(0, 12)) + } + } + assert.deepEqual([...outcomes].sort(), ['unsupported-', 'written']) +}) + test('writes blocks nested 500 levels', () => { let node = line('x') for (let level = 0; level < largestNesting; level += 1) node = { content: [node], type: 'blockquote' } @@ -170,7 +206,9 @@ test('writes blocks nested 500 levels', () => { test('refuses text past V8\'s longest string, which the indent of a deep list can add', () => { let list: AdfNode = { content: [text('a\n'.repeat(400000))], type: 'codeBlock' } for (let level = 0; level < 400; level += 1) list = bullets(item(list)) - const refused = adfToPlainText(document(list, list)) + let tooDeep: AdfNode = line('x') + for (let level = 0; level <= largestNesting; level += 1) tooDeep = { content: [tooDeep], type: 'blockquote' } + const refused = adfToPlainText(document(list, list, tooDeep)) assert.equal(!refused.ok && refused.error.code, 'unsupported-node-shape') assert.equal(!refused.ok && refused.error.message.includes('536870888'), true) }) diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts index e9603096..69ac1c15 100644 --- a/src/plain-text/adf-to-plain-text.ts +++ b/src/plain-text/adf-to-plain-text.ts @@ -7,11 +7,11 @@ import { longestOutput, tooLong, withinEngineLimits } from '../length-bound.ts' import { nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../adf/document.ts' import { shownText } from '../adf/shown-text.ts' -// `gap`: the line feeds the next text needs before it, at least; `leave`: the state a list item restores; `marker`: the list marker of the item a node is; `depth`: the nesting level, as `largestNesting` counts it; `parent`: the node holding this one, from which its path is built when a refusal needs it. +// `gap`: the line feeds the next text needs before it, at least; `leave`: the state a list item restores, its `length` telling whether the item wrote anything; `marker`: the list marker of the item a node is; `depth`: the nesting level, as `largestNesting` counts it; `parent`: the node holding this one, from which its path is built when a refusal needs it. type Place = { index: number; parent: Place | undefined } type Step = | { kind: 'gap'; lines: number } - | { kind: 'leave'; indent: string; lead: string | undefined; written: number } + | { kind: 'leave'; indent: string; lead: string | undefined; length: number } | { depth: number; kind: 'node'; marker: string | undefined; node: AdfNode; place: Place | undefined } // `lines`: the line feeds between a node's children; `level`: how much deeper they nest. @@ -37,19 +37,15 @@ function writeText(content: readonly AdfNode[]): Result { let gap = 0 let indent = '' let lead: string | undefined - let written = 0 function emit(text: string): void { if (text === '') return - if (lead !== undefined && gap === 0) gap = 1 - const atLineStart = written === 0 || gap > 0 - const head = atLineStart ? `${written === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' - const body = indent === '' ? text : text.replaceAll(/\n(?=[^\n])/g, `\n${indent}`) + const head = length === 0 || gap > 0 ? `${length === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' + const body = text.replaceAll(/\n(?=[^\n])/g, `\n${indent}`) pieces.push(head, body) length += head.length + body.length gap = 0 lead = undefined - written += 1 } const pending: Step[] = childSteps(content, { level: 1, lines: 2 }, 0, undefined, () => undefined).reverse() @@ -60,18 +56,17 @@ function writeText(content: readonly AdfNode[]): Result { } if (step.kind === 'leave') { indent = step.indent - lead = step.written === written ? step.lead : undefined + lead = step.length === length ? step.lead : undefined continue } const { depth, marker, node, place } = step if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, pathTo(place)) if (marker !== undefined) { - pending.push({ indent, kind: 'leave', lead, written }) + pending.push({ indent, kind: 'leave', lead, length }) lead = lead === undefined ? `${indent}${marker}` : `${lead}${marker}` indent = `${indent}${' '.repeat(marker.length)}` } if (node.type === 'hardBreak') gap = Math.max(gap, 1) - else if (node.type === 'text') emit(node.text ?? '') else if (shownTextTypes.includes(node.type)) emit(shownText(node) ?? '') else { if (typeof node.text === 'string') emit(node.text) @@ -87,7 +82,7 @@ function writeText(content: readonly AdfNode[]): Result { function childSteps(nodes: readonly AdfNode[], layout: Layout, depth: number, parent: Place | undefined, markerFor: (index: number) => string | undefined): Step[] { const steps: Step[] = [] for (const [index, node] of nodes.entries()) { - if (index > 0 && layout.lines > 0) steps.push({ kind: 'gap', lines: layout.lines }) + if (index > 0) steps.push({ kind: 'gap', lines: layout.lines }) steps.push({ depth, kind: 'node', marker: markerFor(index), node, place: { index, parent } }) } return steps From 18762bd134b0566159e230d205ffa5da79c989a1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:35:02 +0100 Subject: [PATCH 03/14] Test unknown mixed content and a mention with nothing to show --- src/plain-text/adf-to-plain-text.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index df610e28..85faa197 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -151,6 +151,11 @@ test('writes a node of an unknown type as the text it holds, its children a blan assert.equal(plain(adfToPlainText(document(unknown, { text: 'loose', type: 'unknownText' }, paragraph({ content: [text('in'), text(' line')], type: 'unknownInline' })))), 'one\n\ntwo three\n\nloose\n\nin line') }) +test('writes a node of an unknown type holding text and blocks as blocks, and a mention with nothing to show as nothing', () => { + assert.equal(plain(adfToPlainText(document({ content: [text('a'), line('b')], type: 'unknownContainer' }))), 'a\n\nb') + assert.equal(plain(adfToPlainText(document(paragraph(text('a'), { type: 'mention' }, text('b'))))), 'ab') +}) + test('writes a code block\'s lines and a list item\'s continuation lines under their indent, never a blank line with spaces', () => { const code: AdfNode = { content: [text('a\n\nb')], type: 'codeBlock' } assert.equal(plain(adfToPlainText(document(bullets(item(code))))), '• a\n\n b') From dc6c84f8b2ab28ab68fb5ca7603c44ed0682bc47 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:37:03 +0100 Subject: [PATCH 04/14] Test an unknown container holding known inline nodes --- src/plain-text/adf-to-plain-text.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index 85faa197..f75d3000 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -154,6 +154,7 @@ test('writes a node of an unknown type as the text it holds, its children a blan test('writes a node of an unknown type holding text and blocks as blocks, and a mention with nothing to show as nothing', () => { assert.equal(plain(adfToPlainText(document({ content: [text('a'), line('b')], type: 'unknownContainer' }))), 'a\n\nb') assert.equal(plain(adfToPlainText(document(paragraph(text('a'), { type: 'mention' }, text('b'))))), 'ab') + assert.equal(plain(adfToPlainText(document({ content: [text('a'), { attrs: { text: '😄' }, type: 'emoji' }, text('b')], type: 'unknownContainer' }))), 'a😄b') }) test('writes a code block\'s lines and a list item\'s continuation lines under their indent, never a blank line with spaces', () => { From 50715131e0f471597e3d05dc667bc67ec253372b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:47:07 +0100 Subject: [PATCH 05/14] =?UTF-8?q?Answer=20stability=20round=201:=202=20?= =?UTF-8?q?=F0=9F=94=B4,=206=20=F0=9F=9F=A1=20(0=20misread);=20write=20a?= =?UTF-8?q?=20link's=20address=20and=20title,=20=E2=98=91=20and=20?= =?UTF-8?q?=E2=98=90=20for=20tasks,=20a=20nested=20task=20list=20under=20i?= =?UTF-8?q?ts=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/conformance/adf-property.test.ts | 19 ++++ src/plain-text/adf-to-plain-text.test.ts | 125 ++++++++++++++++------- src/plain-text/adf-to-plain-text.ts | 73 +++++++++---- 3 files changed, 161 insertions(+), 56 deletions(-) diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 398aea77..d203e610 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -6,6 +6,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/docume import { adfDocument, blockTaskItemChildren, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfToHtml } from '../html/emit/adf-to-html.ts' import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts' +import { adfToPlainText } from '../plain-text/adf-to-plain-text.ts' import { adfToPortableMarkdown, reduceToPortable } from '../markdown/portable/adf-to-portable-markdown.ts' import { centeredImage, externalMedia } from '../adf/plain-image.ts' import { htmlToAdf } from '../html/parse/html-to-adf.ts' @@ -158,6 +159,24 @@ test('a generated document writes HTML holding no character HTML cannot hold, an ) }) +test('a generated document writes plain text showing every text node\'s text in document order, whatever the whitespace between', { timeout: propertyTimeout }, () => { + fc.assert( + fc.property(adfDocument, (document) => { + const written = adfToPlainText(document) + assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`) + const page = written.value.replaceAll(/\s/g, '') + let from = 0 + for (const text of documentTexts(document.content ?? [])) { + const shown = text.replaceAll(/\s/g, '') + const at = page.indexOf(shown, from) + assert.ok(at >= 0, `writing ${JSON.stringify(document)}: ${JSON.stringify(shown)} is not shown after ${from}`) + from = at + shown.length + } + }), + propertyRuns(gateRuns), + ) +}) + test('a generated document writes HTML that reads back to it', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index f75d3000..2e6e8464 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -85,13 +85,38 @@ test('writes blocks as their text, a blank line apart, with bullets, numbers and ) }) -test('writes marks as the text they mark, and an empty document as nothing', () => { - const link: AdfMark = { attrs: { href: 'https://example.com/', title: 'Home' }, type: 'link' } - assert.equal(plain(adfToPlainText(document(paragraph(text('a ', { type: 'strong' }), text('b', link, { type: 'em' }), text(' c', { type: 'code' }))))), 'a b c') +test('writes marks other than a link as the text they mark', () => { + assert.equal(plain(adfToPlainText(document(paragraph(text('a ', { type: 'strong' }), text('b', { type: 'em' }), text(' c', { type: 'code' }))))), 'a b c') +}) + +test('writes an empty document as nothing', () => { assert.equal(plain(adfToPlainText(document())), '') assert.equal(plain(adfToPlainText({ type: 'doc', version: 1 })), '') }) +test('writes a link\'s address and title after the text it covers, once for a run of text nodes', () => { + const link = (href: string, title?: string): AdfMark => ({ attrs: title === undefined ? { href } : { href, title }, type: 'link' }) + const written = adfToPlainText( + document( + paragraph(text('Read the '), text('do', link('https://example.com/docs', 'Guide')), text('cs', link('https://example.com/docs', 'Guide'), { type: 'strong' }), text(' and the '), text('changelog', link('https://example.com/changes')), text('.')), + paragraph(text('https://example.com/', link('https://example.com/'))), + paragraph(text('a', link('https://one.example/')), text('b', link('https://two.example/'))), + ), + ) + assert.equal(plain(written), 'Read the docs (https://example.com/docs, "Guide") and the changelog (https://example.com/changes).\n\nhttps://example.com/\n\na (https://one.example/)b (https://two.example/)') +}) + +test('writes a link with a title after its address even where the text is the address', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/', title: 'Home' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph(text('https://example.com/', link))))), 'https://example.com/ (https://example.com/, "Home")') +}) + +test('writes a link whose text is empty as nothing, and a link mark with no address as its text', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph(text('a'), text('', link), text('b'))))), 'ab') + assert.equal(plain(adfToPlainText(document(paragraph(text('a', { attrs: { title: 'x' }, type: 'link' }))))), 'a') +}) + test('writes nothing for a block holding no text, and parts the blocks around it once', () => { assert.equal(plain(adfToPlainText(document(paragraph(), line('a'), { type: 'rule' }, paragraph(), line('b'), bullets(item())))), 'a\n\nb') }) @@ -100,11 +125,23 @@ test('writes a text node holding nothing as nothing', () => { assert.equal(plain(adfToPlainText(document(line('a'), paragraph(text('')), line('b')))), 'a\n\nb') }) -test('keeps the text of a paragraph on its line, and an item\'s blocks a line apart, whatever they hold', () => { +test('writes the text nodes of an item\'s paragraph on one line', () => { assert.equal(plain(adfToPlainText(document(bullets(item(paragraph(text('a'), text('b'))))))), '• ab') +}) + +test('writes the items of a list a line feed apart, whatever blocks they hold', () => { assert.equal(plain(adfToPlainText(document(bullets(item(line('a')), item({ content: [line('b')], type: 'blockquote' }))))), '• a\n• b') +}) + +test('writes the blocks of one item a line feed apart, under its marker', () => { assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item(line('x'))), line('z')))))), '• • x\n z') +}) + +test('writes an item holding an empty list before its text as that text', () => { assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item()), line('x')))))), '• x') +}) + +test('writes the nodes of an unknown inline container on one line', () => { const inline: AdfNode = paragraph({ text: 'a', type: 'unknownInline' }, { text: 'b', type: 'unknownInline' }) assert.equal(plain(adfToPlainText(document(inline))), 'ab') }) @@ -119,15 +156,29 @@ test('keeps a marker for an item whose first content is a nested list', () => { assert.equal(plain(adfToPlainText(document(bullets(item(bullets(item(line('x')))), item(line('y')))))), '• • x\n• y') }) +test('writes a task list nested in a task list under its parent item, with no marker of its own', () => { + const task = (value: string): AdfNode => ({ attrs: { state: 'TODO' }, content: [text(value)], type: 'taskItem' }) + const nested: AdfNode = { content: [task('a'), { content: [task('b')], type: 'taskList' }], type: 'taskList' } + assert.equal(plain(adfToPlainText(document(nested))), '☐ a\n ☐ b') +}) + +test('writes two hard breaks in a row as a blank line', () => { + assert.equal(plain(adfToPlainText(document(paragraph(text('a'), { type: 'hardBreak' }, { type: 'hardBreak' }, text('b'))))), 'a\n\nb') +}) + +test('writes a code block ending in a line feed as one block, a blank line from the next', () => { + assert.equal(plain(adfToPlainText(document({ content: [text('a\n')], type: 'codeBlock' }, line('b')))), 'a\n\nb') +}) + test('writes a task item holding blocks as an item', () => { const tasks: AdfNode = { content: [{ attrs: { localId: 'a', state: 'TODO' }, content: [line('a'), line('b')], type: 'blockTaskItem' }], type: 'taskList' } - assert.equal(plain(adfToPlainText(document(tasks))), '• a\n b') + assert.equal(plain(adfToPlainText(document(tasks))), '☐ a\n b') }) -test('writes a task list and a decision list as bullets', () => { +test('writes a task list with a box checked for a done item, and a decision list as bullets', () => { const tasks: AdfNode = { content: [{ attrs: { state: 'DONE' }, content: [text('Write the spec')], type: 'taskItem' }, { attrs: { state: 'TODO' }, content: [text('Ship')], type: 'taskItem' }], type: 'taskList' } const decisions: AdfNode = { content: [{ attrs: { state: 'DECIDED' }, content: [text('Use Node')], type: 'decisionItem' }], type: 'decisionList' } - assert.equal(plain(adfToPlainText(document(tasks, decisions))), '• Write the spec\n• Ship\n\n• Use Node') + assert.equal(plain(adfToPlainText(document(tasks, decisions))), '☑ Write the spec\n☐ Ship\n\n• Use Node') }) test('writes a mention, an emoji, a date and a status as the text they show', () => { @@ -162,41 +213,39 @@ test('writes a code block\'s lines and a list item\'s continuation lines under t assert.equal(plain(adfToPlainText(document(bullets(item(code))))), '• a\n\n b') }) -test('refuses what adfToHtml refuses, with the same code and path', () => { - const deep = (levels: number): AdfNode => { - let node = line('x') - for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } - return node - } - const refused: AdfDocument[] = [ - { type: 'doc', version: Number.NaN }, - { type: 'doc', version: 2 }, - document(deep(largestNesting + 1)), - document(line('a'), deep(largestNesting + 1)), - document(paragraph(text('See '), { attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })), - ] - for (const input of refused) { +function deepQuote(levels: number): AdfNode { + let node = line('x') + for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } + return node +} + +const refusedDocuments: Record = { + 'a document version that is no number': { type: 'doc', version: Number.NaN }, + 'a document version 2': { type: 'doc', version: 2 }, + 'nesting past the limit': document(deepQuote(largestNesting + 1)), + 'nesting past the limit after a block': document(line('a'), deepQuote(largestNesting + 1)), + 'a card with a link mark': document(paragraph(text('See '), { attrs: { url: 'https://example.com/' }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })), +} + +for (const [name, input] of Object.entries(refusedDocuments)) { + test(`refuses ${name} as adfToHtml does, with the same code and path`, () => { assert.notEqual(refusal(adfToHtml(input)), 'written') assert.equal(refusal(adfToPlainText(input)), refusal(adfToHtml(input))) - } -}) + }) +} -test('counts a table\'s levels as adfToHtml does', () => { - const deep = (levels: number): AdfNode => { - let node = line('x') - for (let level = 0; level < levels; level += 1) node = { content: [node], type: 'blockquote' } - return node - } - const outcomes = new Set() - for (const cell of ['tableCell', 'tableHeader']) { - for (let levels = largestNesting - 3; levels <= largestNesting + 1; levels += 1) { - const table: AdfNode = { content: [{ content: [{ content: [deep(levels)], type: cell }], type: 'tableRow' }], type: 'table' } - assert.equal(refusal(adfToPlainText(document(table))), refusal(adfToHtml(document(table)))) - outcomes.add(refusal(adfToPlainText(document(table))).slice(0, 12)) - } +for (const cell of ['tableCell', 'tableHeader']) { + const table = (levels: number): AdfDocument => document({ content: [{ content: [{ content: [deepQuote(levels)], type: cell }], type: 'tableRow' }], type: 'table' }) + for (let levels = largestNesting - 3; levels <= largestNesting + 1; levels += 1) { + test(`counts the levels of a ${cell} holding ${levels} levels as adfToHtml does`, () => { + assert.equal(refusal(adfToPlainText(table(levels))), refusal(adfToHtml(table(levels)))) + }) } - assert.deepEqual([...outcomes].sort(), ['unsupported-', 'written']) -}) + test(`writes a ${cell} just inside the limit and refuses one past it`, () => { + assert.equal(refusal(adfToPlainText(table(largestNesting - 3))), 'written') + assert.equal(refusal(adfToPlainText(table(largestNesting + 1))).startsWith('unsupported-nesting-depth'), true) + }) +} test('writes blocks nested 500 levels', () => { let node = line('x') diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts index 69ac1c15..8c8a9503 100644 --- a/src/plain-text/adf-to-plain-text.ts +++ b/src/plain-text/adf-to-plain-text.ts @@ -2,23 +2,25 @@ import type { AdfDocument, AdfNode } from '../adf/document.ts' import { blockNodeModel } from '../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { inlineNodeModel } from '../adf/inline-nodes.ts' +import { linkAttrs, type LinkAttrs } from '../adf/mark-attributes.ts' import { largestNesting } from '../nesting.ts' import { longestOutput, tooLong, withinEngineLimits } from '../length-bound.ts' -import { nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../adf/document.ts' +import { nodeAttrs, nodeContent, nodeMarks, readAdfDocument, writableContent } from '../adf/document.ts' import { shownText } from '../adf/shown-text.ts' -// `gap`: the line feeds the next text needs before it, at least; `leave`: the state a list item restores, its `length` telling whether the item wrote anything; `marker`: the list marker of the item a node is; `depth`: the nesting level, as `largestNesting` counts it; `parent`: the node holding this one, from which its path is built when a refusal needs it. +// `gap`: the line feeds the next text needs before it at least; `leave`: the state to restore after a list item, and its `length` at the start, which shows whether it wrote anything; `marker`: the marker this node has in its list; `suffix`: text written right after this node; `depth`: the nesting level as `largestNesting` counts it; `parent`: the node holding this one, from which a refusal's path is built. type Place = { index: number; parent: Place | undefined } type Step = | { kind: 'gap'; lines: number } | { kind: 'leave'; indent: string; lead: string | undefined; length: number } - | { depth: number; kind: 'node'; marker: string | undefined; node: AdfNode; place: Place | undefined } + | { depth: number; kind: 'node'; marker: string | undefined; node: AdfNode; place: Place | undefined; suffix: string | undefined } // `lines`: the line feeds between a node's children; `level`: how much deeper they nest. type Layout = { level: number; lines: number } const bulletLists = ['bulletList', 'decisionList', 'taskList'] const itemTypes = ['blockTaskItem', 'listItem'] +const markedItems = ['blockTaskItem', 'decisionItem', 'listItem', 'taskItem'] const shownTextTypes = ['date', 'emoji', 'mention', 'status'] export function adfToPlainText(document: AdfDocument): Result { @@ -39,13 +41,17 @@ function writeText(content: readonly AdfNode[]): Result { let lead: string | undefined function emit(text: string): void { - if (text === '') return - const head = length === 0 || gap > 0 ? `${length === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' - const body = text.replaceAll(/\n(?=[^\n])/g, `\n${indent}`) - pieces.push(head, body) - length += head.length + body.length - gap = 0 - lead = undefined + let end = text.length + while (end > 0 && text[end - 1] === '\n') end -= 1 + if (end > 0) { + const head = length === 0 || gap > 0 ? `${length === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' + const body = text.slice(0, end).replaceAll(/\n(?=[^\n])/g, `\n${indent}`) + pieces.push(head, body) + length += head.length + body.length + gap = 0 + lead = undefined + } + gap = Math.max(gap, text.length - end) } const pending: Step[] = childSteps(content, { level: 1, lines: 2 }, 0, undefined, () => undefined).reverse() @@ -59,17 +65,18 @@ function writeText(content: readonly AdfNode[]): Result { lead = step.length === length ? step.lead : undefined continue } - const { depth, marker, node, place } = step + const { depth, marker, node, place, suffix } = step if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the writer carries`, pathTo(place)) if (marker !== undefined) { pending.push({ indent, kind: 'leave', lead, length }) lead = lead === undefined ? `${indent}${marker}` : `${lead}${marker}` indent = `${indent}${' '.repeat(marker.length)}` } - if (node.type === 'hardBreak') gap = Math.max(gap, 1) + if (node.type === 'hardBreak') gap += 1 else if (shownTextTypes.includes(node.type)) emit(shownText(node) ?? '') else { if (typeof node.text === 'string') emit(node.text) + if (suffix !== undefined) emit(suffix) const layout = layoutOf(node) for (const child of childSteps(nodeContent(node), layout, depth + layout.level, place, markerFor(node)).reverse()) pending.push(child) } @@ -79,16 +86,37 @@ function writeText(content: readonly AdfNode[]): Result { } // The steps for `nodes` in document order, `layout.lines` line feeds apart. -function childSteps(nodes: readonly AdfNode[], layout: Layout, depth: number, parent: Place | undefined, markerFor: (index: number) => string | undefined): Step[] { +function childSteps(nodes: readonly AdfNode[], layout: Layout, depth: number, parent: Place | undefined, markerFor: (index: number, node: AdfNode) => string | undefined): Step[] { const steps: Step[] = [] + const links = nodes.map(linkOf) + let run = '' for (const [index, node] of nodes.entries()) { if (index > 0) steps.push({ kind: 'gap', lines: layout.lines }) - steps.push({ depth, kind: 'node', marker: markerFor(index), node, place: { index, parent } }) + const link = links[index] + const before = links[index - 1] + run = link !== undefined && before !== undefined && sameLink(link, before) ? `${run}${node.text ?? ''}` : node.text ?? '' + const after = links[index + 1] + const ends = link !== undefined && !(after !== undefined && sameLink(link, after)) + steps.push({ depth, kind: 'node', marker: markerFor(index, node), node, place: { index, parent }, suffix: ends && run !== '' ? linkSuffix(link, run) : undefined }) } return steps } -// Children are written in a line where they are inline, a line apart inside a list and an item, a blank line apart between blocks. A list's items nest one level below it, an item's blocks at its own. +function linkOf(node: AdfNode): LinkAttrs | undefined { + const mark = nodeMarks(node).find((held) => held.type === 'link') + return node.type === 'text' && mark !== undefined ? linkAttrs(nodeAttrs(mark)) : undefined +} + +function sameLink(left: LinkAttrs, right: LinkAttrs): boolean { + return left.href === right.href && left.title === right.title +} + +function linkSuffix(link: LinkAttrs, text: string): string | undefined { + if (link.title === undefined) return text === link.href ? undefined : ` (${link.href})` + return ` (${link.href}, "${link.title}")` +} + +// Children stand on one line where they are inline, a line feed apart in a list and an item, a blank line apart between blocks. A list's items nest one level below it, an item's blocks at its own. function layoutOf(node: AdfNode): Layout { if (bulletLists.includes(node.type) || node.type === 'orderedList') return { level: 1, lines: 1 } if (itemTypes.includes(node.type)) return { level: 0, lines: 1 } @@ -98,12 +126,21 @@ function layoutOf(node: AdfNode): Layout { return nodeContent(node).every((child) => child.type === 'text' || inlineNodeModel(child.type) !== undefined) ? { level: 0, lines: 0 } : { level: 1, lines: 2 } } -function markerFor(node: AdfNode): (index: number) => string | undefined { - if (bulletLists.includes(node.type)) return () => '• ' +// A child of a list that is no item, such as a task list nested in one, gets blank margin so it stands under the item before it. +function markerFor(node: AdfNode): (index: number, child: AdfNode) => string | undefined { + if (bulletLists.includes(node.type)) return (_index, child) => (markedItems.includes(child.type) ? bulletOf(child) : ' ') if (node.type !== 'orderedList') return () => undefined const order = nodeAttrs(node)['order'] const first = typeof order === 'number' && Number.isSafeInteger(order) ? order : 1 - return (index) => `${first + index}. ` + return (index, child) => { + const number = `${first + index}. ` + return markedItems.includes(child.type) ? number : ' '.repeat(number.length) + } +} + +function bulletOf(item: AdfNode): string { + if (item.type !== 'blockTaskItem' && item.type !== 'taskItem') return '• ' + return nodeAttrs(item)['state'] === 'DONE' ? '☑ ' : '☐ ' } function pathTo(place: Place | undefined): ConvertErrorPath { From 2050b8ae82985cbc4baa157c62e0e8d389f8332b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:47:07 +0100 Subject: [PATCH 06/14] =?UTF-8?q?Answer=20product-owner=20round=201:=201?= =?UTF-8?q?=20=F0=9F=94=B4,=205=20=F0=9F=9F=A1=20(0=20misread);=20name=20p?= =?UTF-8?q?lain=20text=20in=20the=20title,=20tagline=20and=20keywords,=20a?= =?UTF-8?q?nd=20the=20four=20writers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++-- README.md | 20 +++++++++++--------- package.json | 3 ++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb34438c..f6cefd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## Unreleased -- `adfToPlainText` writes a document as text with no markup, for a search index, a notification or an email: blocks a blank line apart, `• ` and `1.` list markers with nesting indented, a hard break as a line feed, and a mention, emoji, date or status as the text it shows. It refuses what `adfToHtml` refuses. A table writes its cells as blocks for now. -- `readAdfDocument` and the three writers name the node `not-an-adf-document` refuses: `path` leads +- `adfToPlainText` writes a document as text with no markup, for a search index, a notification or an + email: blocks a blank line apart; `• ` and `1.` list markers, `☑ ` and `☐ ` for a done and an open + task, nesting indented; a link's address and title in parentheses after its text; a hard break as a + line feed; and a mention, emoji, date or status as the text it shows. It refuses what `adfToHtml` + refuses. A table is written as its cells, as blocks, for now. +- `readAdfDocument` and the four writers name the node `not-an-adf-document` refuses: `path` leads to a node that is not an ADF node, and the message says what is wrong with it, such as which attribute holds a value that is not JSON. A getter, an object holding itself, a throwing Proxy and a bad array length are still refused at the empty path. diff --git a/README.md b/README.md index a2fae02c..c263c299 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# @larvit/adf-codec — ADF to markdown and HTML, markdown to ADF +# @larvit/adf-codec — ADF to markdown, HTML and plain text, markdown to ADF [![npm](https://img.shields.io/npm/v/@larvit/adf-codec)](https://www.npmjs.com/package/@larvit/adf-codec) [![CI](https://github.com/larvit/adf-codec/actions/workflows/ci.yml/badge.svg)](https://github.com/larvit/adf-codec/actions/workflows/ci.yml) -Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML. +Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML, or write it as plain text. For application developers whose app shows, edits, exports or generates the rich text Atlassian products store as ADF. @@ -167,6 +167,8 @@ if (back?.ok) console.log(back.value) // deep-equals page ### ADF to plain text +Full reference: [The calls](#the-calls). + ```ts import { adfToPlainText, type AdfDocument } from '@larvit/adf-codec' @@ -256,7 +258,7 @@ whatever it costs; the Achieve goals are weighed against each other in the order 10. **Achieve:** Plain text reads like text written for people reading an email. - Signal: readers shown the candidate texts pick the one the library already writes. - Counter-signal: issues report plain text that reads as markup or runs blocks together. - - Why: a search index, a notification and an email each show the text as it is, with no renderer to tidy it. + - Why: a spelling that looks tidy to its author can read as markup to its readers, and only their picks and reports show it. ## Audience @@ -337,9 +339,9 @@ documentation says, on 2026-10-28. ## The calls -Serves [Goals](#goals) 1, 2 and 8. Pure functions, each taking a whole document and returning a +Serves [Goals](#goals) 1, 2, 8 and 10. Pure functions, each taking a whole document and returning a whole result; no I/O, no configuration. [What each call writes](https://github.com/larvit/adf-codec/blob/main/docs/feature-table.md) -sets example documents beside each writer's output and says whether that output reads back the same. +sets example documents beside the markdown and HTML writers' output and says whether that output reads back the same. ```ts adfToLosslessMarkdown(doc: AdfDocument): Result @@ -363,7 +365,7 @@ Each call's name says which format it reads or writes: | --- | --- | --- | | `adfToHtml` | writes HTML: the elements [HTML](#html) lists, and an `adf-carry` element holding everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | -| `adfToPlainText` | writes text with no markup: blocks a blank line apart, list items on a line each under `• ` or `1.`, nesting indented, a hard break a line feed, a mention, emoji, date and status as the text they show | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | +| `adfToPlainText` | writes text with no markup: blocks a blank line apart; list items under `• ` or `1.`, a task item under `☑ ` or `☐ `, nesting indented; a link's address and title in parentheses after its text; a hard break a line feed; a mention, emoji, date and status as the text they show; a table as its cells written as blocks | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | @@ -373,7 +375,7 @@ Each call's name says which format it reads or writes: `readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It copies own enumerable keys and data properties alone, so an accessor property or a Proxy that throws is -`not-an-adf-document` and never a throw. The three writers read their document the same way. It checks the shape the converters require: `type: 'doc'`, a finite +`not-an-adf-document` and never a throw. The four writers read their document the same way. It checks the shape the converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no object holding itself. It reads a document nesting past 500 levels; the converters refuse that one with `unsupported-nesting-depth`. @@ -645,7 +647,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r ### Emitting -`adfToLosslessMarkdown`, `adfToPlainText`, `adfToPortableMarkdown` and `adfToHtml` raise these, and the codes under +`adfToHtml`, `adfToLosslessMarkdown`, `adfToPlainText` and `adfToPortableMarkdown` raise these, and the codes under [Either direction](#either-direction). `readAdfDocument` raises `not-an-adf-document` and `unsupported-node-shape`, and `engine-limit`. @@ -701,7 +703,7 @@ Every call can raise `engine-limit`, and every conversion `unsupported-nesting-d `path` and message name. - **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell. This is a bug in this library: report it. -- **Reading a document, in `readAdfDocument` and the three writers**: the document holds more than +- **Reading a document, in `readAdfDocument` and the four writers**: the document holds more than 10,000,000 JSON values, each object, array, string, number, boolean and null counting one. Keep the ADF and pass the document over or show it read-only; where you built the ADF, split it. - **Past the engine's longest string**: diff --git a/package.json b/package.json index 4974d23b..75d0075d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "@larvit/adf-codec", "version": "0.3.1", - "description": "Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML.", + "description": "Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML, or write it as plain text.", "keywords": [ "adf", "adf-to-html", "adf-to-markdown", + "adf-to-plain-text", "atlassian", "atlassian-document-format", "atlassian-forge", From e079f55d1ab1be528318edd5624be70970e8b180 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:47:07 +0100 Subject: [PATCH 07/14] =?UTF-8?q?Answer=20prose=20round=201:=201=20?= =?UTF-8?q?=F0=9F=94=B4,=207=20=F0=9F=9F=A1=20(0=20misread);=20record=20th?= =?UTF-8?q?e=20links=20and=20task-state=20panel,=20file=20the=20audience?= =?UTF-8?q?=20question?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions.md | 40 +++++++++++++++++++++++++--------------- todo.md | 22 +++++++++++++++++----- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 8603c859..3c6e48b0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1300,24 +1300,34 @@ it wanted the day shown, as `!adf:date[2025-08-25]{timestamp=…}`. ## Plain text is blocks a blank line apart, with `• ` and `1.` markers and nesting indented 2026-11-14, a reader panel of 7 (export/indexing, LLM/agent, support agent, viewer/editor, bot, -product manager, engineer), then the maintainer. Goal 10. Valid while the audience of the text is +product manager, engineer), then the maintainer; a second panel of 3 (support agent, product +manager, export/indexing) for links and task state. Goal 10. Valid while the audience of the text is people reading an email. -Each reader wrote the text they expected, then picked among six shuffled candidates. All seven -expected a blank line between blocks, a hard break as one line feed and a mention as its display -text. Three picked blank lines with no markers and no indent, three picked `• ` bullets with `1.` -numbers kept and nesting indented, one picked `- ` and `1.` markers. Less than five of seven is a -missing goal, so the maintainer chose the second and worded the goal for people reading an email. +Each reader of the first panel wrote the text they expected, then picked among six shuffled +candidates. All seven expected a blank line between blocks, a hard break as one line feed and a +mention as its display text. Three picked blank lines with no markers and no indent, three picked +`• ` bullets with `1.` numbers kept and nesting indented, one picked `- ` and `1.` markers. Fewer +than five of seven is a missing goal, so the maintainer chose the `• ` bullets and worded the goal +for people reading an email. + +The second panel's readers each wrote the text they expected, then picked among four shuffled +candidates for the links and four for task state. All three picked a link's address in parentheses +after its text, with the title after the address, and `☑` and `☐` in place of the bullet. All three +had expected no title, and goal 7 counts a title as content, so every candidate kept it. `adfToPlainText` writes: -- Blocks a blank line apart, an item's blocks and a list's items a line apart. -- `• ` for a bullet list, a task list and a decision list, and `n. ` for an ordered list, counting - from its `order`. A nested list and an item's later lines are indented to the marker's width. -- A hard break as a line feed, a mention, emoji, date and status as the text they show, and marks - as the text they mark. +- Blocks a blank line apart; an item's blocks and a list's items one line feed apart. +- `• ` for a bullet list and a decision list, and `n. ` for an ordered list, counting from its + `order`. +- `☑ ` for a done task item and `☐ ` for any other, in place of the bullet. +- A nested list and an item's later lines indented to the marker's width. +- A list child that is no item, such as a task list nested in one, under the item before it. +- A hard break as a line feed, two in a row as a blank line. +- A mention, emoji, date and status as the text they show. +- A link's text, then ` (address)` or ` (address, "title")`, once after a run of text nodes sharing + the link; nothing after a link whose text is its address and that holds no title. +- Any other mark as the text it marks. - A block holding no text as nothing, with no extra blank line. -- A table as its cells written as blocks, until a plan item gives it a spelling. - -The depth and length refusals are `adfToHtml`'s: a document nesting past 500 levels, or text past -V8's longest string, indentation included. +- A table as its cells written as blocks, until item 180 gives it a spelling. diff --git a/todo.md b/todo.md index 5b9b41a3..02771944 100644 --- a/todo.md +++ b/todo.md @@ -6,12 +6,13 @@ `Bar = 0.1` -`Next ID = 182` +`Next ID = 183` ## Items | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| +| 182 | 0.4.0 | | question | **Decide whether the audience names the people reading plain text, and whether goal 10 is a sub-goal of goal 6.** | 1 | 1 | 1 | 1 | 1 | 10 by 3 | 5.6 | | 180 | 0.4.0 | | | **Write a table in `adfToPlainText` as one line per row, so a CSV export or a search index reads its cells.** | 2 | 2 | 2 | 4 | 3 | 3 by 1, 6 by 3 | 4.9 | | 181 | 0.4.0 | | | **Write media, link cards, extensions and expand titles in `adfToPlainText` as the text each shows, and answer developer-community topic 41858.** | 2 | 2 | 2 | 4 | 4 | 3 by 2, 6 by 2 | 3.3 | | 141 | 0.4.0 | | decision | **Write `strike` as HTML in `adfToHtml` and read it back in `htmlToAdf`, in place of the carry element.** | 1 | 1 | 1 | 1 | 2 | 6 by 2 | 3.2 | @@ -50,11 +51,22 @@ ## Details +### 182. Decide whether the audience names the people reading plain text, and whether goal 10 is a sub-goal of goal 6. + +`## Audience` lists no one who sends notifications or emails, and goal 10 reads like a narrower case of +goal 6 (what output the audience expects), with the same signal. Two choices are the maintainer's: + +- Add the readers of an email, a notification and a search hit to `## Audience`, so goal 10's + readers are named there. +- Keep goal 10 as it is, or make it goal 6.2 and cite 6.2 in the Moves of items 180 and 181. + +Raised by the product-owner and prose reviews of the chunk that shipped `adfToPlainText`. + ### 180. Write a table in `adfToPlainText` as one line per row, so a CSV export or a search index reads its cells. A reader panel picks the cell separator, and what a header row, a merged cell and a block nested in -a cell read as. The verdict lands in `docs/decisions.md`. Until this item ships, item 179 -writes a table's cells as blocks. +a cell read as. The verdict lands in `docs/decisions.md`. Until this item ships, +`adfToPlainText` writes a table's cells as blocks. ### 181. Write media, link cards, extensions and expand titles in `adfToPlainText` as the text each shows, and answer developer-community topic 41858. @@ -64,8 +76,8 @@ For a node that shows no text, its `shownNote` names the node instead, such as ` extension's key. A reader panel picks which texts plain text keeps, and whether it writes those notes. The verdict lands in `docs/decisions.md`. -This item doesn't read item 180's table spelling, so the two land in either order. Both change item -179's call, so the second to merge may meet a conflict; neither waits for the other. +This item doesn't read item 180's table spelling, so the two land in either order. Both change +`adfToPlainText`, so the second to merge may meet a conflict; neither waits for the other. Topic 41858 asks for ADF as plain text. Answer it once item 181 ships (the maintainer, 2026-10-28). From 8dc31f28638fb6b341b52e6d7e2ebf396f60a88c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:50:55 +0100 Subject: [PATCH 08/14] Kill adfToPlainText's link-run and blank-margin mutants --- src/plain-text/adf-to-plain-text.test.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index 2e6e8464..25206f74 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -106,6 +106,26 @@ test('writes a link\'s address and title after the text it covers, once for a ru assert.equal(plain(written), 'Read the docs (https://example.com/docs, "Guide") and the changelog (https://example.com/changes).\n\nhttps://example.com/\n\na (https://one.example/)b (https://two.example/)') }) +test('writes no address after a link whose text, over several text nodes, is its address', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph(text('https://exa', link), text('mple.com/', link))))), 'https://example.com/') +}) + +test('writes an address after each of two links to one address with different titles', () => { + const link = (title: string): AdfMark => ({ attrs: { href: 'https://example.com/', title }, type: 'link' }) + assert.equal(plain(adfToPlainText(document(paragraph(text('a', link('x')), text('b', link('y')))))), 'a (https://example.com/, "x")b (https://example.com/, "y")') +}) + +test('finds a link among the other marks of a text node', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph(text('a', { type: 'strong' }, link))))), 'a (https://example.com/)') +}) + +test('writes no address after a node that is no text node, whatever marks it holds', () => { + const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' } + assert.equal(plain(adfToPlainText(document(paragraph({ marks: [link], text: 'a', type: 'unknownInline' })))), 'a') +}) + test('writes a link with a title after its address even where the text is the address', () => { const link: AdfMark = { attrs: { href: 'https://example.com/', title: 'Home' }, type: 'link' } assert.equal(plain(adfToPlainText(document(paragraph(text('https://example.com/', link))))), 'https://example.com/ (https://example.com/, "Home")') @@ -166,6 +186,14 @@ test('writes two hard breaks in a row as a blank line', () => { assert.equal(plain(adfToPlainText(document(paragraph(text('a'), { type: 'hardBreak' }, { type: 'hardBreak' }, text('b'))))), 'a\n\nb') }) +test('keeps the larger of the line feeds a text ends in and the ones owed before it', () => { + assert.equal(plain(adfToPlainText(document(paragraph(text('a'), { type: 'hardBreak' }, text('\n\n'), text('b'))))), 'a\n\nb') +}) + +test('writes a block that is no item in an ordered list under the item before it', () => { + assert.equal(plain(adfToPlainText(document(numbers(item(line('a')), bullets(item(line('b'))))))), '1. a\n • b') +}) + test('writes a code block ending in a line feed as one block, a blank line from the next', () => { assert.equal(plain(adfToPlainText(document({ content: [text('a\n')], type: 'codeBlock' }, line('b')))), 'a\n\nb') }) From 3720ea0641d46660ca9b1bb87139e728c134d17d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:55:07 +0100 Subject: [PATCH 09/14] =?UTF-8?q?Answer=20product-owner=20round=202:=200?= =?UTF-8?q?=20=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 93714eda56c235b31869c23861dd71024ec34125 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 15:55:38 +0100 Subject: [PATCH 10/14] =?UTF-8?q?Answer=20stability=20round=202:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=200=20=F0=9F=9F=A1=20(0=20misread);=20approved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f0ce2638e204342eaaea79bbee295c359f4486f4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 16:06:58 +0100 Subject: [PATCH 11/14] Write the line feeds of consecutive code fragments as one text holds them --- src/plain-text/adf-to-plain-text.test.ts | 6 ++++++ src/plain-text/adf-to-plain-text.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index 25206f74..1a336793 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -198,6 +198,12 @@ test('writes a code block ending in a line feed as one block, a blank line from assert.equal(plain(adfToPlainText(document({ content: [text('a\n')], type: 'codeBlock' }, line('b')))), 'a\n\nb') }) +test('writes the line feeds of consecutive code fragments as the one fragment holding them all', () => { + const split: AdfNode = { content: [text('a\n'), text('\n'), text('b')], type: 'codeBlock' } + const whole: AdfNode = { content: [text('a\n\nb')], type: 'codeBlock' } + assert.equal(plain(adfToPlainText(document(split))), plain(adfToPlainText(document(whole)))) +}) + test('writes a task item holding blocks as an item', () => { const tasks: AdfNode = { content: [{ attrs: { localId: 'a', state: 'TODO' }, content: [line('a'), line('b')], type: 'blockTaskItem' }], type: 'taskList' } assert.equal(plain(adfToPlainText(document(tasks))), '☐ a\n b') diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts index 8c8a9503..ad2dfcaa 100644 --- a/src/plain-text/adf-to-plain-text.ts +++ b/src/plain-text/adf-to-plain-text.ts @@ -32,26 +32,29 @@ export function adfToPlainText(document: AdfDocument): Result { }) } -// A stack rather than recursion (docs/decisions.md §Nothing recurses unbounded). Text is written the moment it is met: `gap` holds the line feeds it owes first, `lead` the marker of the item it opens, `indent` the margin of every line after that. +// A stack rather than recursion (docs/decisions.md §Nothing recurses unbounded). Text is written the moment it is met: `gap` holds the line feeds the layout owes first and `literal` those the text before ended in, of which the larger is written, `lead` the marker of the item it opens, `indent` the margin of every line after that. function writeText(content: readonly AdfNode[]): Result { const pieces: string[] = [] let length = 0 let gap = 0 let indent = '' let lead: string | undefined + let literal = 0 function emit(text: string): void { let end = text.length while (end > 0 && text[end - 1] === '\n') end -= 1 if (end > 0) { - const head = length === 0 || gap > 0 ? `${length === 0 ? '' : '\n'.repeat(gap)}${lead ?? indent}` : '' + const owed = Math.max(gap, literal) + const head = length === 0 || owed > 0 ? `${length === 0 ? '' : '\n'.repeat(owed)}${lead ?? indent}` : '' const body = text.slice(0, end).replaceAll(/\n(?=[^\n])/g, `\n${indent}`) pieces.push(head, body) length += head.length + body.length gap = 0 lead = undefined + literal = 0 } - gap = Math.max(gap, text.length - end) + literal += text.length - end } const pending: Step[] = childSteps(content, { level: 1, lines: 2 }, 0, undefined, () => undefined).reverse() From cdacf9948d6a2cbf9f29290c42d3956ac7c578a5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 16:11:19 +0100 Subject: [PATCH 12/14] Split adfToPlainText's step and line-feed helpers out of its writer --- src/plain-text/adf-to-plain-text.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts index ad2dfcaa..659421dd 100644 --- a/src/plain-text/adf-to-plain-text.ts +++ b/src/plain-text/adf-to-plain-text.ts @@ -42,8 +42,7 @@ function writeText(content: readonly AdfNode[]): Result { let literal = 0 function emit(text: string): void { - let end = text.length - while (end > 0 && text[end - 1] === '\n') end -= 1 + const end = endBeforeLineFeeds(text) if (end > 0) { const owed = Math.max(gap, literal) const head = length === 0 || owed > 0 ? `${length === 0 ? '' : '\n'.repeat(owed)}${lead ?? indent}` : '' @@ -52,9 +51,8 @@ function writeText(content: readonly AdfNode[]): Result { length += head.length + body.length gap = 0 lead = undefined - literal = 0 } - literal += text.length - end + literal = (end > 0 ? 0 : literal) + text.length - end } const pending: Step[] = childSteps(content, { level: 1, lines: 2 }, 0, undefined, () => undefined).reverse() @@ -88,6 +86,17 @@ function writeText(content: readonly AdfNode[]): Result { return success(pieces.join('')) } +function endBeforeLineFeeds(text: string): number { + let end = text.length + while (end > 0 && text[end - 1] === '\n') end -= 1 + return end +} + +function stepsWithin(node: AdfNode, depth: number, place: Place | undefined): Step[] { + const layout = layoutOf(node) + return childSteps(nodeContent(node), layout, depth + layout.level, place, markerFor(node)) +} + // The steps for `nodes` in document order, `layout.lines` line feeds apart. function childSteps(nodes: readonly AdfNode[], layout: Layout, depth: number, parent: Place | undefined, markerFor: (index: number, node: AdfNode) => string | undefined): Step[] { const steps: Step[] = [] From 625ef72044fbac23433ab5d2bc70d89809e230b4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 16:14:21 +0100 Subject: [PATCH 13/14] Kill adfToPlainText's pending-line-feed reset mutant --- src/plain-text/adf-to-plain-text.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plain-text/adf-to-plain-text.test.ts b/src/plain-text/adf-to-plain-text.test.ts index 1a336793..05a47c75 100644 --- a/src/plain-text/adf-to-plain-text.test.ts +++ b/src/plain-text/adf-to-plain-text.test.ts @@ -204,6 +204,10 @@ test('writes the line feeds of consecutive code fragments as the one fragment ho assert.equal(plain(adfToPlainText(document(split))), plain(adfToPlainText(document(whole)))) }) +test('owes a line feed of a code fragment to the next fragment alone', () => { + assert.equal(plain(adfToPlainText(document({ content: [text('a\n'), text('b'), text('c')], type: 'codeBlock' }))), 'a\nbc') +}) + test('writes a task item holding blocks as an item', () => { const tasks: AdfNode = { content: [{ attrs: { localId: 'a', state: 'TODO' }, content: [line('a'), line('b')], type: 'blockTaskItem' }], type: 'taskList' } assert.equal(plain(adfToPlainText(document(tasks))), '☐ a\n b') From 027a0a3e2ea1af7d6725eba495be97c89698d650 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 14 Nov 2026 16:17:28 +0100 Subject: [PATCH 14/14] Use adfToPlainText's step helper in its writer --- src/plain-text/adf-to-plain-text.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plain-text/adf-to-plain-text.ts b/src/plain-text/adf-to-plain-text.ts index 659421dd..9bab89e4 100644 --- a/src/plain-text/adf-to-plain-text.ts +++ b/src/plain-text/adf-to-plain-text.ts @@ -76,10 +76,8 @@ function writeText(content: readonly AdfNode[]): Result { if (node.type === 'hardBreak') gap += 1 else if (shownTextTypes.includes(node.type)) emit(shownText(node) ?? '') else { - if (typeof node.text === 'string') emit(node.text) - if (suffix !== undefined) emit(suffix) - const layout = layoutOf(node) - for (const child of childSteps(nodeContent(node), layout, depth + layout.level, place, markerFor(node)).reverse()) pending.push(child) + emit(`${node.text ?? ''}${suffix ?? ''}`) + for (const child of stepsWithin(node, depth, place).reverse()) pending.push(child) } if (length > longestOutput) return tooLong() }