From c81bda9bfbd66ae07034d672b8ac88711f10627b Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 21:47:09 +0200 Subject: [PATCH 1/5] 10f - plainMarkdownToAdf mints task localIds as UUID v4s hashed from the markdown and position --- README.md | 5 +- docs/decisions.md | 14 ++-- src/markdown/parse/markdown-to-adf.ts | 5 +- .../parse/plain-markdown-to-adf.test.ts | 27 ++++++- src/markdown/parse/task-ids.test.ts | 56 +++++++++++++++ src/markdown/parse/task-ids.ts | 71 +++++++++++++++++++ todo.md | 3 - 7 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 src/markdown/parse/task-ids.test.ts create mode 100644 src/markdown/parse/task-ids.ts diff --git a/README.md b/README.md index 5ec7dfc..7bcb853 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,9 @@ read replaces mentions, attachments and macros with text. - Text that would read as a marker takes a backslash: `==` wherever it could open or close a highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads that marker per item. -- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`, - `taskItem` and `blockTaskItem`: mint one where the receiving site requires it. +- A node read back carries no `localId`, save a `taskList`, `taskItem` or `blockTaskItem`, which + Atlassian's schema requires one on: it gets a UUID v4 hashed from the markdown and its position, + so the same markdown reads to the same ids. ## The errors diff --git a/docs/decisions.md b/docs/decisions.md index 123453f..41843e4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -127,13 +127,15 @@ accepted. ## Plain task ids come from position -2026-09-26, the maintainer. Goals 5 and 7. Valid while a site rejects a task node with no -`localId`. Lands with `todo.md` 10f. +2026-09-26, spelling 2026-09-29, the maintainer. Goals 5 and 7. Valid while a site rejects a task +node with no `localId`. -`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` from its -position in document order, unique within the document and minted with no host API, so a site -rejecting a missing `localId` takes the document and the same markdown reads to the same ids every -run. +`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` +in the editor's UUID v4 shape, hashed (cyrb128) from the whole markdown and the node's position in +document order, skipping any id the document holds. A site rejecting a missing `localId` takes the +document, the same markdown reads to the same ids every run, and different markdown to ids no other +document shares. The same markdown pasted twice into one document repeats its ids: determinism +wins over that case. ## A callout title keeps its link targets diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 10c8f2c..5ca6f6a 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -12,6 +12,7 @@ import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' import { listBreakName, listBreakSpelling } from '../block-directive.ts' +import { mintTaskIds } from './task-ids.ts' import { nodeAttrs, nodeContent } from '../../adf/document.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' @@ -40,7 +41,9 @@ function readDocument(markdown: string, flavour: Flavour): Result { diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts index 322fbdb..fa6192f 100644 --- a/src/markdown/parse/plain-markdown-to-adf.test.ts +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -13,9 +13,22 @@ const em: AdfMark = { type: 'em' } const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const strong: AdfMark = { type: 'strong' } +const taskTypes = ['blockTaskItem', 'taskItem', 'taskList'] + function read(markdown: string): readonly AdfNode[] | string { const parsed = plainMarkdownToAdf(markdown) - return parsed.ok ? (toEditorNormal(parsed.value).content ?? []) : parsed.error.code + if (!parsed.ok) return parsed.error.code + const blocks = toEditorNormal(parsed.value).content ?? [] + const pending = [...blocks] + for (let block = pending.pop(); block !== undefined; block = pending.pop()) { + for (const child of block.content ?? []) pending.push(child) + if (!taskTypes.includes(block.type)) continue + const { localId, ...attrs } = block.attrs ?? {} + assert.equal(typeof localId, 'string', `${block.type} in ${JSON.stringify(markdown)}`) + if (Object.keys(attrs).length === 0) delete block.attrs + else block.attrs = attrs + } + return blocks } function normal(...blocks: AdfNode[]): readonly AdfNode[] { @@ -123,6 +136,15 @@ test('reads a bullet list whose every item leads with a task marker to a task li bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))), ]) assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after'))))) + const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n') + assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [ + node( + 'taskList', + { localId: '51470556-7c91-46cb-b140-16e225a9b1f2' }, + node('taskItem', { localId: 'e04cbd87-eb04-4737-ae72-6d56d7799874', state: 'DONE' }, text('Parent')), + node('taskList', { localId: '9ab33f3f-8c58-428f-b4eb-343a32a177d6' }, node('taskItem', { localId: 'b16a2f09-9543-499b-8a97-b88fc342b918', state: 'TODO' }, text('Child'))), + ), + ]) }) test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => { @@ -208,6 +230,9 @@ test('reads text the writer kept from reading as a marker back as text', () => { test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => { assert.deepEqual(read('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))]) + assert.deepEqual(read('!adf:taskList\n!adf:taskItem TODO\nb\n!adf:/taskItem\n!adf:/taskList\n'), [bare('taskList', task('TODO', text('b')))]) + const listed = plainMarkdownToAdf('!adf:taskList {localId=01a0eeb2-be48-7ea7-8587-db5e013c374a}\n- [ ] b\n!adf:/taskList\n') + assert.deepEqual(listed.ok ? listed.value.content?.[0]?.attrs : listed.error.code, { localId: '01a0eeb2-be48-7ea7-8587-db5e013c374a' }) const future = bare('futureBlock', text('==x==')) const carried = adfToMarkdown(document(future)) assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future]) diff --git a/src/markdown/parse/task-ids.test.ts b/src/markdown/parse/task-ids.test.ts new file mode 100644 index 0000000..f7ea7ab --- /dev/null +++ b/src/markdown/parse/task-ids.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { AdfDocument, AdfNode } from '../../adf/document.ts' +import { mintTaskIds } from './task-ids.ts' + +const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +function taskIds(document: AdfDocument): unknown[] { + const ids: unknown[] = [] + const pending: AdfNode[] = [...(document.content ?? [])].reverse() + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId']) + for (const child of [...(node.content ?? [])].reverse()) pending.push(child) + } + return ids +} + +function tasks(...content: AdfNode[]): AdfDocument { + return { content, type: 'doc', version: 1 } +} + +test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => { + const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' }) + const minted = unminted() + mintTaskIds(minted, '- [ ] a\n') + const ids = taskIds(minted) + assert.equal(ids.length, 4) + for (const id of ids) assert.match(String(id), uuidV4) + assert.equal(new Set(ids).size, 4) + const again = unminted() + mintTaskIds(again, '- [ ] a\n') + assert.deepEqual(taskIds(again), ids) + const other = unminted() + mintTaskIds(other, '- [ ] b\n') + assert.equal(taskIds(other).some((id) => ids.includes(id)), false) +}) + +test('keeps a localId the document spells and skips it when minting, a carried one too', () => { + const first = tasks({ type: 'taskList' }) + mintTaskIds(first, 'x') + const [taken] = taskIds(first) + const spelled = tasks({ type: 'taskList' }, { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }) + mintTaskIds(spelled, 'x') + const [minted, kept] = taskIds(spelled) + assert.equal(kept, taken) + assert.notEqual(minted, taken) + assert.match(String(minted), uuidV4) +}) + +test('leaves a task node inside a node type this version does not know as carried', () => { + const carried: AdfNode = { content: [{ type: 'taskList' }], type: 'futureBlock' } + const document = tasks(carried) + mintTaskIds(document, 'x') + assert.deepEqual(document, tasks({ content: [{ type: 'taskList' }], type: 'futureBlock' })) +}) diff --git a/src/markdown/parse/task-ids.ts b/src/markdown/parse/task-ids.ts new file mode 100644 index 0000000..4043558 --- /dev/null +++ b/src/markdown/parse/task-ids.ts @@ -0,0 +1,71 @@ +import type { AdfDocument, AdfNode } from '../../adf/document.ts' +import { blockNodeModel } from '../../adf/block-nodes.ts' +import { nodeAttrs, nodeContent } from '../../adf/document.ts' + +const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList']) + +// docs/decisions.md, Plain task ids come from position. +export function mintTaskIds(document: AdfDocument, markdown: string): void { + const taken = new Set() + for (const node of preorder(document, () => true)) { + const localId = nodeAttrs(node)['localId'] + if (typeof localId === 'string') taken.add(localId) + } + const seed = hash128(markdown).join(' ') + let count = 0 + for (const node of preorder(document, (held) => blockNodeModel(held.type) !== undefined)) { + if (!taskTypes.has(node.type) || typeof nodeAttrs(node)['localId'] === 'string') continue + let localId = '' + do { + count += 1 + localId = uuidV4(hash128(`${seed} ${count}`)) + } while (taken.has(localId)) + taken.add(localId) + node.attrs = { ...node.attrs, localId } + } +} + +function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean): Generator { + const pending: AdfNode[] = [] + const pushReversed = (nodes: readonly AdfNode[]): void => { + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const node = nodes[index] + if (node !== undefined) pending.push(node) + } + } + pushReversed(document.content ?? []) + for (let node = pending.pop(); node !== undefined; node = pending.pop()) { + yield node + if (entered(node)) pushReversed(nodeContent(node)) + } +} + +// cyrb128, public domain. +function hash128(text: string): number[] { + let h1 = 1779033703 + let h2 = 3144134277 + let h3 = 1013904242 + let h4 = 2773480762 + for (let index = 0; index < text.length; index += 1) { + const unit = text.charCodeAt(index) + h1 = h2 ^ Math.imul(h1 ^ unit, 597399067) + h2 = h3 ^ Math.imul(h2 ^ unit, 2869860233) + h3 = h4 ^ Math.imul(h3 ^ unit, 951274213) + h4 = h1 ^ Math.imul(h4 ^ unit, 2716044179) + } + h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067) + h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233) + h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213) + h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179) + h1 ^= h2 ^ h3 ^ h4 + h2 ^= h1 + h3 ^= h1 + h4 ^= h1 + return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0] +} + +function uuidV4(lanes: readonly number[]): string { + const hex = lanes.map((lane) => lane.toString(16).padStart(8, '0')).join('') + const variant = ((Number.parseInt(hex.charAt(16), 16) & 3) | 8).toString(16) + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}` +} diff --git a/todo.md b/todo.md index 80498cb..9f43c60 100644 --- a/todo.md +++ b/todo.md @@ -2,9 +2,6 @@ ## 0.2.0 -- **10f — Give task nodes read from plain markdown position ids.** Per `docs/decisions.md` §Plain - task ids come from position, README §Plain markdown's `localId` bullet saying so. The id spelling - is part of the chunk. - **41 — Keep a link's target when `plainMarkdownToAdf` reads a callout title.** Per `docs/decisions.md` §A callout title keeps its link targets; today `> [!faq]- See [x](http://y)` reads to an expand titled `See x`, the target gone. -- 2.52.0 From 3edc12aaa9c180545acb5e65e28ad30f56ab3200 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 21:51:22 +0200 Subject: [PATCH 2/5] 10f - review: tests for a carried node keeping its task nodes unminted --- .../parse/plain-markdown-to-adf.test.ts | 4 +++ src/markdown/parse/task-ids.test.ts | 26 +++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/markdown/parse/plain-markdown-to-adf.test.ts b/src/markdown/parse/plain-markdown-to-adf.test.ts index fa6192f..4a7d8c7 100644 --- a/src/markdown/parse/plain-markdown-to-adf.test.ts +++ b/src/markdown/parse/plain-markdown-to-adf.test.ts @@ -236,6 +236,10 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it const future = bare('futureBlock', text('==x==')) const carried = adfToMarkdown(document(future)) assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future]) + const uncarriable = bare('taskList', node('taskItem', { extra: { a: 1 }, state: 'TODO' }, text('a'))) + const carriedPanel = adfToMarkdown(document(node('panel', { extra: true, panelType: 'info' }, uncarriable))) + const restored = carriedPanel.ok ? plainMarkdownToAdf(carriedPanel.value) : carriedPanel + assert.deepEqual(restored.ok ? restored.value.content : restored.error.code, [node('panel', { extra: true, panelType: 'info' }, uncarriable)]) const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' } const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g==')) const spelled = adfToMarkdown(document(held)) diff --git a/src/markdown/parse/task-ids.test.ts b/src/markdown/parse/task-ids.test.ts index f7ea7ab..4a76340 100644 --- a/src/markdown/parse/task-ids.test.ts +++ b/src/markdown/parse/task-ids.test.ts @@ -23,34 +23,38 @@ function tasks(...content: AdfNode[]): AdfDocument { test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => { const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' }) const minted = unminted() - mintTaskIds(minted, '- [ ] a\n') + mintTaskIds(minted, '- [ ] a\n', new Set()) const ids = taskIds(minted) assert.equal(ids.length, 4) for (const id of ids) assert.match(String(id), uuidV4) assert.equal(new Set(ids).size, 4) const again = unminted() - mintTaskIds(again, '- [ ] a\n') + mintTaskIds(again, '- [ ] a\n', new Set()) assert.deepEqual(taskIds(again), ids) const other = unminted() - mintTaskIds(other, '- [ ] b\n') + mintTaskIds(other, '- [ ] b\n', new Set()) assert.equal(taskIds(other).some((id) => ids.includes(id)), false) }) test('keeps a localId the document spells and skips it when minting, a carried one too', () => { const first = tasks({ type: 'taskList' }) - mintTaskIds(first, 'x') + mintTaskIds(first, 'x', new Set()) const [taken] = taskIds(first) - const spelled = tasks({ type: 'taskList' }, { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }) - mintTaskIds(spelled, 'x') + const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' } + const spelled = tasks({ type: 'taskList' }, carried) + mintTaskIds(spelled, 'x', new Set([carried])) const [minted, kept] = taskIds(spelled) assert.equal(kept, taken) assert.notEqual(minted, taken) assert.match(String(minted), uuidV4) }) -test('leaves a task node inside a node type this version does not know as carried', () => { - const carried: AdfNode = { content: [{ type: 'taskList' }], type: 'futureBlock' } - const document = tasks(carried) - mintTaskIds(document, 'x') - assert.deepEqual(document, tasks({ content: [{ type: 'taskList' }], type: 'futureBlock' })) +test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => { + const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' }) + const carriedList = list() + const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' } + const future: AdfNode = { content: [list()], type: 'futureBlock' } + const document = tasks(carriedList, carriedPanel, future) + mintTaskIds(document, 'x', new Set([carriedList, carriedPanel])) + assert.deepEqual(document, tasks(list(), { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }, { content: [list()], type: 'futureBlock' })) }) -- 2.52.0 From bd8d240712ff0317ad1d47029a50da3fc73917a3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 21:51:22 +0200 Subject: [PATCH 3/5] 10f - review: a node the carry restores stays deep-equal, its ids only skipped --- docs/decisions.md | 3 ++- src/markdown/parse/markdown-to-adf.ts | 11 ++++++----- src/markdown/parse/task-ids.ts | 7 +++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 41843e4..8a834d5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -135,7 +135,8 @@ in the editor's UUID v4 shape, hashed (cyrb128) from the whole markdown and the document order, skipping any id the document holds. A site rejecting a missing `localId` takes the document, the same markdown reads to the same ids every run, and different markdown to ids no other document shares. The same markdown pasted twice into one document repeats its ids: determinism -wins over that case. +wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its +ids are only skipped. ## A callout title keeps its link targets diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 5ca6f6a..30fee02 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -22,7 +22,7 @@ import { unsupportedNodeShape } from '../directive-syntax.ts' type Paragraph = Extract // `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand. -type Reading = { definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo } +type Reading = { carried: Set; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo } const documentStart: SourcePosition = { line: 1, offset: 0 } const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends' @@ -38,11 +38,11 @@ export function plainMarkdownToAdf(markdown: string): Result { const parsed = parseBlocks(markdown) - const reading: Reading = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() } + const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() } const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart) if (!content.ok) return content const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 } - if (flavour === 'plain') mintTaskIds(document, markdown) + if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried) return success(document) } @@ -83,7 +83,7 @@ function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth case 'bulletList': return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth) case 'code': - return codeBlockNode(block.language, block.text, path, depth) + return codeBlockNode(block.language, block.text, reading, path, depth) case 'directive': return directiveNode(block, reading, path, depth) case 'fault': @@ -250,10 +250,11 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa return success({ ...node, content }) } -function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result { +function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result { if (language === carryName) { const carried = readCarriedBlock(text, depth) if (carried.fault !== undefined) return faulted(carried.fault, path) + reading.carried.add(carried.value) return success(carried.value) } const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } diff --git a/src/markdown/parse/task-ids.ts b/src/markdown/parse/task-ids.ts index 4043558..8033605 100644 --- a/src/markdown/parse/task-ids.ts +++ b/src/markdown/parse/task-ids.ts @@ -4,8 +4,7 @@ import { nodeAttrs, nodeContent } from '../../adf/document.ts' const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList']) -// docs/decisions.md, Plain task ids come from position. -export function mintTaskIds(document: AdfDocument, markdown: string): void { +export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet): void { const taken = new Set() for (const node of preorder(document, () => true)) { const localId = nodeAttrs(node)['localId'] @@ -13,8 +12,8 @@ export function mintTaskIds(document: AdfDocument, markdown: string): void { } const seed = hash128(markdown).join(' ') let count = 0 - for (const node of preorder(document, (held) => blockNodeModel(held.type) !== undefined)) { - if (!taskTypes.has(node.type) || typeof nodeAttrs(node)['localId'] === 'string') continue + for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) { + if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue let localId = '' do { count += 1 -- 2.52.0 From 050e52296a8e791dfc110b9c727f89cb2b78a5d6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 23:35:24 +0200 Subject: [PATCH 4/5] 10f - review: README says to read markdown bound for one document once --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7bcb853..7c39d6e 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,10 @@ read replaces mentions, attachments and macros with text. - Text that would read as a marker takes a backslash: `==` wherever it could open or close a highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads that marker per item. -- A node read back carries no `localId`, save a `taskList`, `taskItem` or `blockTaskItem`, which - Atlassian's schema requires one on: it gets a UUID v4 hashed from the markdown and its position, - so the same markdown reads to the same ids. +- A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which + Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its + position, the same on every read. Join markdown bound for one document and read it once: the same + markdown read twice into one document repeats its ids. ## The errors -- 2.52.0 From 1f7d11ea3e886c4c8489e213a187dda1e493fe46 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 29 Sep 2026 23:37:39 +0200 Subject: [PATCH 5/5] 10f - review: the position id and carry entries and the errors intro stated truly --- README.md | 4 ++-- docs/decisions.md | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7c39d6e..be56770 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ read replaces mentions, attachments and macros with text. ## The errors -An ADF node type this version does not know is not an error: it is carried opaquely and restores -unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). +An ADF node type this version does not know is not an error: the lossless pair carries it opaquely +and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). `ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`, stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text diff --git a/docs/decisions.md b/docs/decisions.md index 8a834d5..5fcc49f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -50,8 +50,8 @@ An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in restores to a deep-equal node. The round-trip holds for documents newer than the library. So does a known node no section spells where it stands: a markdown serializer spells a node by type without checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. -Where a container's own spelling cannot hold the child it has — a `bulletList` outside `listItem`, -a `codeBlock` outside text — the error result names that instead. +Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than +`listItem`, a `codeBlock` other than text — the error result names that instead. ## Foreign HTML sorts three ways @@ -131,11 +131,10 @@ accepted. node with no `localId`. `plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` -in the editor's UUID v4 shape, hashed (cyrb128) from the whole markdown and the node's position in -document order, skipping any id the document holds. A site rejecting a missing `localId` takes the -document, the same markdown reads to the same ids every run, and different markdown to ids no other -document shares. The same markdown pasted twice into one document repeats its ids: determinism -wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its +in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it +mints, skipping any id the document holds: the same markdown reads to the same ids every run, +different markdown to different ids. The same markdown pasted twice into one document repeats its +ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its ids are only skipped. ## A callout title keeps its link targets -- 2.52.0