Compare commits

...

2 Commits

Author SHA1 Message Date
lilleman bd8d240712 10f - review: a node the carry restores stays deep-equal, its ids only skipped
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-29 21:51:22 +02:00
lilleman 3edc12aaa9 10f - review: tests for a carried node keeping its task nodes unminted 2026-09-29 21:51:22 +02:00
5 changed files with 30 additions and 21 deletions
+2 -1
View File
@@ -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 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, 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 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 ## A callout title keeps its link targets
+6 -5
View File
@@ -22,7 +22,7 @@ import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }> type Paragraph = Extract<Block, { kind: 'paragraph' }>
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand. // `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<AdfNode>; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
const documentStart: SourcePosition = { line: 1, offset: 0 } 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' 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<AdfDocument, ParseE
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> { function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown) 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) const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
if (!content.ok) return content if (!content.ok) return content
const document: AdfDocument = 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) if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
return success(document) return success(document)
} }
@@ -83,7 +83,7 @@ function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth
case 'bulletList': case 'bulletList':
return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth) return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
case 'code': case 'code':
return codeBlockNode(block.language, block.text, path, depth) return codeBlockNode(block.language, block.text, reading, path, depth)
case 'directive': case 'directive':
return directiveNode(block, reading, path, depth) return directiveNode(block, reading, path, depth)
case 'fault': case 'fault':
@@ -250,10 +250,11 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
return success({ ...node, content }) return success({ ...node, content })
} }
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> { function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language === carryName) { if (language === carryName) {
const carried = readCarriedBlock(text, depth) const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path) if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value)
return success(carried.value) return success(carried.value)
} }
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
@@ -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 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])
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 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 held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
const spelled = adfToMarkdown(document(held)) const spelled = adfToMarkdown(document(held))
+15 -11
View File
@@ -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', () => { 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 unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
const minted = unminted() const minted = unminted()
mintTaskIds(minted, '- [ ] a\n') mintTaskIds(minted, '- [ ] a\n', new Set())
const ids = taskIds(minted) const ids = taskIds(minted)
assert.equal(ids.length, 4) assert.equal(ids.length, 4)
for (const id of ids) assert.match(String(id), uuidV4) for (const id of ids) assert.match(String(id), uuidV4)
assert.equal(new Set(ids).size, 4) assert.equal(new Set(ids).size, 4)
const again = unminted() const again = unminted()
mintTaskIds(again, '- [ ] a\n') mintTaskIds(again, '- [ ] a\n', new Set())
assert.deepEqual(taskIds(again), ids) assert.deepEqual(taskIds(again), ids)
const other = unminted() const other = unminted()
mintTaskIds(other, '- [ ] b\n') mintTaskIds(other, '- [ ] b\n', new Set())
assert.equal(taskIds(other).some((id) => ids.includes(id)), false) 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', () => { test('keeps a localId the document spells and skips it when minting, a carried one too', () => {
const first = tasks({ type: 'taskList' }) const first = tasks({ type: 'taskList' })
mintTaskIds(first, 'x') mintTaskIds(first, 'x', new Set())
const [taken] = taskIds(first) const [taken] = taskIds(first)
const spelled = tasks({ type: 'taskList' }, { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }) const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }
mintTaskIds(spelled, 'x') const spelled = tasks({ type: 'taskList' }, carried)
mintTaskIds(spelled, 'x', new Set([carried]))
const [minted, kept] = taskIds(spelled) const [minted, kept] = taskIds(spelled)
assert.equal(kept, taken) assert.equal(kept, taken)
assert.notEqual(minted, taken) assert.notEqual(minted, taken)
assert.match(String(minted), uuidV4) assert.match(String(minted), uuidV4)
}) })
test('leaves a task node inside a node type this version does not know as carried', () => { test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => {
const carried: AdfNode = { content: [{ type: 'taskList' }], type: 'futureBlock' } const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' })
const document = tasks(carried) const carriedList = list()
mintTaskIds(document, 'x') const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }
assert.deepEqual(document, tasks({ content: [{ type: 'taskList' }], type: 'futureBlock' })) 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' }))
}) })
+3 -4
View File
@@ -4,8 +4,7 @@ import { nodeAttrs, nodeContent } from '../../adf/document.ts'
const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList']) const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList'])
// docs/decisions.md, Plain task ids come from position. export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet<AdfNode>): void {
export function mintTaskIds(document: AdfDocument, markdown: string): void {
const taken = new Set<string>() const taken = new Set<string>()
for (const node of preorder(document, () => true)) { for (const node of preorder(document, () => true)) {
const localId = nodeAttrs(node)['localId'] const localId = nodeAttrs(node)['localId']
@@ -13,8 +12,8 @@ export function mintTaskIds(document: AdfDocument, markdown: string): void {
} }
const seed = hash128(markdown).join(' ') const seed = hash128(markdown).join(' ')
let count = 0 let count = 0
for (const node of preorder(document, (held) => blockNodeModel(held.type) !== undefined)) { for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) {
if (!taskTypes.has(node.type) || typeof nodeAttrs(node)['localId'] === 'string') continue if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue
let localId = '' let localId = ''
do { do {
count += 1 count += 1