10f - plainMarkdownToAdf mints task localIds as UUID v4s hashed from the markdown and position
CI / gate (push) Successful in 44s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-29 21:47:09 +02:00
parent 160c2a7052
commit c81bda9bfb
7 changed files with 168 additions and 13 deletions
+3 -2
View File
@@ -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 - 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 highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads
that marker per item. that marker per item.
- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`, - A node read back carries no `localId`, save a `taskList`, `taskItem` or `blockTaskItem`, which
`taskItem` and `blockTaskItem`: mint one where the receiving site requires it. 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 ## The errors
+8 -6
View File
@@ -127,13 +127,15 @@ accepted.
## Plain task ids come from position ## 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 2026-09-26, spelling 2026-09-29, the maintainer. Goals 5 and 7. Valid while a site rejects a task
`localId`. Lands with `todo.md` 10f. node with no `localId`.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` from its `plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
position in document order, unique within the document and minted with no host API, so a site in the editor's UUID v4 shape, hashed (cyrb128) from the whole markdown and the node's position in
rejecting a missing `localId` takes the document and the same markdown reads to the same ids every document order, skipping any id the document holds. A site rejecting a missing `localId` takes the
run. 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 ## A callout title keeps its link targets
+4 -1
View File
@@ -12,6 +12,7 @@ import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { listBreakName, listBreakSpelling } from '../block-directive.ts' import { listBreakName, listBreakSpelling } from '../block-directive.ts'
import { mintTaskIds } from './task-ids.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts' import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
@@ -40,7 +41,9 @@ function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, P
const reading: Reading = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() } const reading: Reading = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart) const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
if (!content.ok) return content if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }) const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
if (flavour === 'plain') mintTaskIds(document, markdown)
return success(document)
} }
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> { function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
@@ -13,9 +13,22 @@ const em: AdfMark = { type: 'em' }
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const strong: AdfMark = { type: 'strong' } const strong: AdfMark = { type: 'strong' }
const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
function read(markdown: string): readonly AdfNode[] | string { function read(markdown: string): readonly AdfNode[] | string {
const parsed = plainMarkdownToAdf(markdown) 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[] { 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'))), 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'))))) 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', () => { 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', () => { 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: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 future = bare('futureBlock', text('==x=='))
const carried = adfToMarkdown(document(future)) const carried = adfToMarkdown(document(future))
assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future]) assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future])
+56
View File
@@ -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' }))
})
+71
View File
@@ -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<string>()
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<AdfNode> {
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)}`
}
-3
View File
@@ -2,9 +2,6 @@
## 0.2.0 ## 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 - **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)` `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. reads to an expand titled `See x`, the target gone.