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
+4 -1
View File
@@ -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<AdfDocument, P
const reading: Reading = { definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
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[]> {
@@ -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])
+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)}`
}