diff --git a/frontend/apps/cli/src/commands/document.ts b/frontend/apps/cli/src/commands/document.ts index 739227fd6..9ea1671fb 100644 --- a/frontend/apps/cli/src/commands/document.ts +++ b/frontend/apps/cli/src/commands/document.ts @@ -30,9 +30,9 @@ import {resolveKey} from '../utils/keyring' import {resolveIdWithClient} from '../utils/resolve-id' import {createSignerFromKey} from '../utils/signer' import {resolveDocumentState} from '../utils/depth' -import {parseMarkdown, flattenToOperations, type BlockNode} from '../utils/markdown' +import {parseMarkdown} from '../utils/markdown' import {parseBlocksJson, hmBlockNodesToOperations} from '../utils/blocks-json' -import {createBlocksMap, computeReplaceOps, hmBlockNodeToBlockNode, type APIBlockNode} from '../utils/block-diff' +import {createBlocksMap, computeReplaceOps, type APIBlockNode} from '../utils/block-diff' import {resolveFileLinks} from '../utils/file-links' import type {HMBlockNode, HMDocument, HMMetadata} from '@seed-hypermedia/client/hm-types' @@ -96,7 +96,7 @@ export type ParsedInput = { ops: DocumentOperation[] metadata: HMMetadata fileBlobs: CollectedBlob[] - tree?: BlockNode[] // parsed block tree for smart diffing in update + tree?: HMBlockNode[] // parsed block tree for smart diffing in update blocks?: HMBlockNode[] // for dry-run rendering source?: string // extraction method label } @@ -180,22 +180,16 @@ export async function readInput(options: {file?: string; grobidUrl?: string; qui ops: hmBlockNodesToOperations(nodes), metadata: {}, fileBlobs: resolved.blobs, - tree: nodes.map(hmBlockNodeToBlockNode), + tree: nodes, } } // ── Markdown path ── - const {tree, metadata} = parseMarkdown(content!) - const ops = flattenToOperations(tree) + const {tree: hmNodes, metadata} = parseMarkdown(content!) + const resolved = await resolveFileLinks(hmNodes) + const ops = hmBlockNodesToOperations(resolved.nodes) - // Resolve file:// links in the tree (images with local paths) - // We need to convert BlockNode tree back through operations, - // but file:// links are in the operations already via the link field. - // For now, file:// resolution only applies to JSON blocks input. - // Markdown images get file:// prepended at tokenizer level and will - // be resolved when we add block-level file link resolution. - - return {ops, metadata, fileBlobs: [], tree} + return {ops, metadata, fileBlobs: resolved.blobs, tree: resolved.nodes} } export function registerDocumentCommands(program: Command) { diff --git a/frontend/apps/cli/src/test/fixture-seed.ts b/frontend/apps/cli/src/test/fixture-seed.ts index b9d3b6044..c1b78f96f 100644 --- a/frontend/apps/cli/src/test/fixture-seed.ts +++ b/frontend/apps/cli/src/test/fixture-seed.ts @@ -11,7 +11,8 @@ import { FIXTURE_REGISTRATION_SECRET, } from '../../../../../test-fixtures/minimal-fixtures' import {deriveKeyPairFromMnemonic} from '../utils/key-derivation' -import {flattenToOperations, parseMarkdown} from '../utils/markdown' +import {parseMarkdown} from '../utils/markdown' +import {hmBlockNodesToOperations} from '../utils/blocks-json' import {createDocumentUpdate, registerAccount, type TestAccount} from './account-helpers' const fixtureKeyPair = deriveKeyPairFromMnemonic(FIXTURE_ACCOUNT_MNEMONIC, '') @@ -41,7 +42,7 @@ export async function seedTestFixtures(serverUrl: string): Promise { type: 'SetAttributes', attrs: [{key: ['name'], value: FIXTURE_HIERARCHY_TITLE}], }, - ...flattenToOperations(tree), + ...hmBlockNodesToOperations(tree), ]) } diff --git a/frontend/apps/cli/src/utils/block-diff.test.ts b/frontend/apps/cli/src/utils/block-diff.test.ts index 9aab3c7b9..5c6d24a6a 100644 --- a/frontend/apps/cli/src/utils/block-diff.test.ts +++ b/frontend/apps/cli/src/utils/block-diff.test.ts @@ -3,10 +3,8 @@ import { createBlocksMap, matchBlockIds, computeReplaceOps, - hmBlockNodeToBlockNode, type APIBlockNode, } from './block-diff' -import type {BlockNode} from './markdown' import type {HMBlockNode} from '@seed-hypermedia/client/hm-types' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -25,16 +23,15 @@ function apiBlockWithChildren(id: string, type: string, text: string, children: } } -function newBlock(id: string, type: string, text = ''): BlockNode { +function hmBlock(id: string, type: string, text = '', extra?: Record): HMBlockNode { return { - block: {id, type, text, annotations: []}, - children: [], + block: {type, id, text, annotations: [], attributes: {}, ...extra} as any, } } -function newBlockWithChildren(id: string, type: string, text: string, children: BlockNode[]): BlockNode { +function hmBlockWithChildren(id: string, type: string, text: string, children: HMBlockNode[]): HMBlockNode { return { - block: {id, type, text, annotations: []}, + block: {type, id, text, annotations: [], attributes: {}} as any, children, } } @@ -92,44 +89,44 @@ describe('createBlocksMap', () => { describe('matchBlockIds', () => { test('same-type blocks at same position reuse old IDs', () => { const old = [apiBlock('old-1', 'Paragraph'), apiBlock('old-2', 'Heading')] - const fresh = [newBlock('new-1', 'Paragraph'), newBlock('new-2', 'Heading')] + const fresh = [hmBlock('new-1', 'Paragraph'), hmBlock('new-2', 'Heading')] const matched = matchBlockIds(old, fresh) - expect(matched[0].block.id).toBe('old-1') - expect(matched[1].block.id).toBe('old-2') + expect((matched[0].block as any).id).toBe('old-1') + expect((matched[1].block as any).id).toBe('old-2') }) test('different-type blocks get new IDs', () => { const old = [apiBlock('old-1', 'Paragraph')] - const fresh = [newBlock('new-1', 'Heading')] + const fresh = [hmBlock('new-1', 'Heading')] const matched = matchBlockIds(old, fresh) - expect(matched[0].block.id).toBe('new-1') // not reused + expect((matched[0].block as any).id).toBe('new-1') // not reused }) test('new blocks beyond old list length keep generated IDs', () => { const old = [apiBlock('old-1', 'Paragraph')] - const fresh = [newBlock('new-1', 'Paragraph'), newBlock('new-2', 'Paragraph')] + const fresh = [hmBlock('new-1', 'Paragraph'), hmBlock('new-2', 'Paragraph')] const matched = matchBlockIds(old, fresh) - expect(matched[0].block.id).toBe('old-1') - expect(matched[1].block.id).toBe('new-2') + expect((matched[0].block as any).id).toBe('old-1') + expect((matched[1].block as any).id).toBe('new-2') }) test('nested children are matched recursively', () => { const old = [apiBlockWithChildren('h1', 'Heading', 'Title', [apiBlock('p1', 'Paragraph', 'Content')])] - const fresh = [newBlockWithChildren('new-h', 'Heading', 'Title', [newBlock('new-p', 'Paragraph')])] + const fresh = [hmBlockWithChildren('new-h', 'Heading', 'Title', [hmBlock('new-p', 'Paragraph')])] const matched = matchBlockIds(old, fresh) - expect(matched[0].block.id).toBe('h1') - expect(matched[0].children[0].block.id).toBe('p1') + expect((matched[0].block as any).id).toBe('h1') + expect((matched[0].children![0].block as any).id).toBe('p1') }) test('empty old list keeps all new IDs', () => { - const fresh = [newBlock('a', 'Paragraph'), newBlock('b', 'Heading')] + const fresh = [hmBlock('a', 'Paragraph'), hmBlock('b', 'Heading')] const matched = matchBlockIds([], fresh) - expect(matched[0].block.id).toBe('a') - expect(matched[1].block.id).toBe('b') + expect((matched[0].block as any).id).toBe('a') + expect((matched[1].block as any).id).toBe('b') }) test('empty new list returns empty array', () => { @@ -144,7 +141,7 @@ describe('computeReplaceOps', () => { test('unchanged blocks produce no ReplaceBlock ops', () => { const old = [apiBlock('a', 'Paragraph', 'Hello')] const map = createBlocksMap(old) - const matched = [newBlock('a', 'Paragraph', 'Hello')] + const matched = [hmBlock('a', 'Paragraph', 'Hello')] const ops = computeReplaceOps(map, matched) @@ -158,7 +155,7 @@ describe('computeReplaceOps', () => { test('changed text produces ReplaceBlock', () => { const old = [apiBlock('a', 'Paragraph', 'Old text')] const map = createBlocksMap(old) - const matched = [newBlock('a', 'Paragraph', 'New text')] + const matched = [hmBlock('a', 'Paragraph', 'New text')] const ops = computeReplaceOps(map, matched) @@ -169,7 +166,7 @@ describe('computeReplaceOps', () => { test('new blocks produce ReplaceBlock and are included in MoveBlocks', () => { const old = [apiBlock('a', 'Paragraph', 'Existing')] const map = createBlocksMap(old) - const matched = [newBlock('a', 'Paragraph', 'Existing'), newBlock('b', 'Paragraph', 'Brand new')] + const matched = [hmBlock('a', 'Paragraph', 'Existing'), hmBlock('b', 'Paragraph', 'Brand new')] const ops = computeReplaceOps(map, matched) @@ -184,7 +181,7 @@ describe('computeReplaceOps', () => { test('removed blocks produce DeleteBlocks', () => { const old = [apiBlock('a', 'Paragraph', 'Keep'), apiBlock('b', 'Paragraph', 'Remove')] const map = createBlocksMap(old) - const matched = [newBlock('a', 'Paragraph', 'Keep')] + const matched = [hmBlock('a', 'Paragraph', 'Keep')] const ops = computeReplaceOps(map, matched) @@ -207,7 +204,7 @@ describe('computeReplaceOps', () => { test('empty old map with new blocks produces ReplaceBlock + MoveBlocks', () => { const map = createBlocksMap([]) - const matched = [newBlock('a', 'Paragraph', 'New')] + const matched = [hmBlock('a', 'Paragraph', 'New')] const ops = computeReplaceOps(map, matched) @@ -219,7 +216,7 @@ describe('computeReplaceOps', () => { test('nested children produce ops with correct parent', () => { const old = [apiBlockWithChildren('h1', 'Heading', 'Title', [apiBlock('p1', 'Paragraph', 'Old child')])] const map = createBlocksMap(old) - const matched = [newBlockWithChildren('h1', 'Heading', 'Title', [newBlock('p1', 'Paragraph', 'New child')])] + const matched = [hmBlockWithChildren('h1', 'Heading', 'Title', [hmBlock('p1', 'Paragraph', 'New child')])] const ops = computeReplaceOps(map, matched) @@ -248,7 +245,9 @@ describe('computeReplaceOps', () => { }, ] const map = createBlocksMap(old) - const matched: BlockNode[] = [{block: {id: 'a', type: 'Paragraph', text: 'bold', annotations: []}, children: []}] + const matched: HMBlockNode[] = [ + {block: {type: 'Paragraph', id: 'a', text: 'bold', annotations: [], attributes: {}} as any}, + ] const ops = computeReplaceOps(map, matched) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(1) @@ -257,8 +256,41 @@ describe('computeReplaceOps', () => { test('different block type triggers ReplaceBlock', () => { const old = [apiBlock('a', 'Paragraph', 'Text')] const map = createBlocksMap(old) - // Same id but different type (would happen if matchBlockIds reused the id despite type mismatch) - const matched: BlockNode[] = [{block: {id: 'a', type: 'Heading', text: 'Text', annotations: []}, children: []}] + const matched: HMBlockNode[] = [ + {block: {type: 'Heading', id: 'a', text: 'Text', annotations: [], attributes: {}} as any}, + ] + + const ops = computeReplaceOps(map, matched) + expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(1) + }) + + test('changed link triggers ReplaceBlock', () => { + const old: APIBlockNode[] = [ + { + block: {id: 'img1', type: 'Image', text: 'alt', link: 'ipfs://old-cid', annotations: [], attributes: {}}, + children: [], + }, + ] + const map = createBlocksMap(old) + const matched: HMBlockNode[] = [ + {block: {type: 'Image', id: 'img1', text: 'alt', link: 'ipfs://new-cid', annotations: [], attributes: {}} as any}, + ] + + const ops = computeReplaceOps(map, matched) + expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(1) + }) + + test('changed childrenType in attributes triggers ReplaceBlock', () => { + const old: APIBlockNode[] = [ + { + block: {id: 'a', type: 'Paragraph', text: '', link: '', annotations: [], attributes: {childrenType: 'Ordered'}}, + children: [], + }, + ] + const map = createBlocksMap(old) + const matched: HMBlockNode[] = [ + {block: {type: 'Paragraph', id: 'a', text: '', annotations: [], attributes: {childrenType: 'Unordered'}} as any}, + ] const ops = computeReplaceOps(map, matched) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(1) @@ -269,11 +301,9 @@ describe('computeReplaceOps', () => { describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { test('blocks with matching IDs: unchanged content produces no ReplaceBlock', () => { - // Simulates: user exports doc, makes no changes, re-imports const old = [apiBlock('abc12345', 'Paragraph', 'Hello world')] const map = createBlocksMap(old) - // Input has the same ID from comment - const input: BlockNode[] = [newBlock('abc12345', 'Paragraph', 'Hello world')] + const input: HMBlockNode[] = [hmBlock('abc12345', 'Paragraph', 'Hello world')] const ops = computeReplaceOps(map, input) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(0) @@ -281,17 +311,16 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { }) test('blocks with matching IDs: changed text produces ReplaceBlock only for changed block', () => { - // Simulates: user exports doc, edits one paragraph, re-imports const old = [ apiBlock('blk-aaa', 'Paragraph', 'First paragraph'), apiBlock('blk-bbb', 'Paragraph', 'Second paragraph'), apiBlock('blk-ccc', 'Paragraph', 'Third paragraph'), ] const map = createBlocksMap(old) - const input: BlockNode[] = [ - newBlock('blk-aaa', 'Paragraph', 'First paragraph'), - newBlock('blk-bbb', 'Paragraph', 'EDITED second paragraph'), - newBlock('blk-ccc', 'Paragraph', 'Third paragraph'), + const input: HMBlockNode[] = [ + hmBlock('blk-aaa', 'Paragraph', 'First paragraph'), + hmBlock('blk-bbb', 'Paragraph', 'EDITED second paragraph'), + hmBlock('blk-ccc', 'Paragraph', 'Third paragraph'), ] const ops = computeReplaceOps(map, input) @@ -302,41 +331,35 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { }) test('mix of known and unknown IDs: known blocks are diffed, unknown are new', () => { - // Simulates: user exports doc, adds a new paragraph (no ID comment) const old = [ apiBlock('blk-aaa', 'Paragraph', 'Existing paragraph'), apiBlock('blk-bbb', 'Heading', 'Existing heading'), ] const map = createBlocksMap(old) - const input: BlockNode[] = [ - newBlock('blk-aaa', 'Paragraph', 'Existing paragraph'), // unchanged - newBlock('random99', 'Paragraph', 'A brand new paragraph'), // new block, ID not in old - newBlock('blk-bbb', 'Heading', 'Existing heading'), // unchanged + const input: HMBlockNode[] = [ + hmBlock('blk-aaa', 'Paragraph', 'Existing paragraph'), + hmBlock('random99', 'Paragraph', 'A brand new paragraph'), + hmBlock('blk-bbb', 'Heading', 'Existing heading'), ] const ops = computeReplaceOps(map, input) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') - // Only the new block should get ReplaceBlock expect(replaceOps).toHaveLength(1) expect((replaceOps[0] as any).block.id).toBe('random99') - // No deletes — all old blocks are still present expect(ops.filter((o) => o.type === 'DeleteBlocks')).toHaveLength(0) }) test('no matching IDs: full body replacement', () => { - // Simulates: user writes fresh markdown without ID comments const old = [apiBlock('old-aaa', 'Paragraph', 'Old content'), apiBlock('old-bbb', 'Heading', 'Old heading')] const map = createBlocksMap(old) - const input: BlockNode[] = [ - newBlock('gen-111', 'Paragraph', 'Entirely new content'), - newBlock('gen-222', 'Heading', 'New heading'), + const input: HMBlockNode[] = [ + hmBlock('gen-111', 'Paragraph', 'Entirely new content'), + hmBlock('gen-222', 'Heading', 'New heading'), ] const ops = computeReplaceOps(map, input) - // All input blocks are new → ReplaceBlock for each const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') expect(replaceOps).toHaveLength(2) - // All old blocks are deleted const deleteOps = ops.filter((o) => o.type === 'DeleteBlocks') expect(deleteOps).toHaveLength(1) expect((deleteOps[0] as any).blocks).toContain('old-aaa') @@ -344,16 +367,15 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { }) test('removed blocks are deleted, remaining blocks are preserved', () => { - // Simulates: user exports doc, removes a paragraph, re-imports const old = [ apiBlock('blk-aaa', 'Paragraph', 'Keep this'), apiBlock('blk-bbb', 'Paragraph', 'Delete this'), apiBlock('blk-ccc', 'Paragraph', 'Keep this too'), ] const map = createBlocksMap(old) - const input: BlockNode[] = [ - newBlock('blk-aaa', 'Paragraph', 'Keep this'), - newBlock('blk-ccc', 'Paragraph', 'Keep this too'), + const input: HMBlockNode[] = [ + hmBlock('blk-aaa', 'Paragraph', 'Keep this'), + hmBlock('blk-ccc', 'Paragraph', 'Keep this too'), ] const ops = computeReplaceOps(map, input) @@ -368,14 +390,15 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { test('reordered blocks produce MoveBlocks with correct order', () => { const old = [apiBlock('blk-aaa', 'Paragraph', 'First'), apiBlock('blk-bbb', 'Paragraph', 'Second')] const map = createBlocksMap(old) - // Swap order - const input: BlockNode[] = [newBlock('blk-bbb', 'Paragraph', 'Second'), newBlock('blk-aaa', 'Paragraph', 'First')] + const input: HMBlockNode[] = [ + hmBlock('blk-bbb', 'Paragraph', 'Second'), + hmBlock('blk-aaa', 'Paragraph', 'First'), + ] const ops = computeReplaceOps(map, input) const moveOps = ops.filter((o) => o.type === 'MoveBlocks') expect(moveOps).toHaveLength(1) expect((moveOps[0] as any).blocks).toEqual(['blk-bbb', 'blk-aaa']) - // No content changes expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(0) expect(ops.filter((o) => o.type === 'DeleteBlocks')).toHaveLength(0) }) @@ -385,15 +408,13 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { const oldParent = apiBlockWithChildren('parent-1', 'Heading', 'Parent', [oldChild]) const map = createBlocksMap([oldParent]) - const newChild = newBlock('child-1', 'Paragraph', 'Modified child text') - const inputParent = newBlockWithChildren('parent-1', 'Heading', 'Parent', [newChild]) + const newChild = hmBlock('child-1', 'Paragraph', 'Modified child text') + const inputParent = hmBlockWithChildren('parent-1', 'Heading', 'Parent', [newChild]) const ops = computeReplaceOps(map, [inputParent]) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') - // Only the child text changed expect(replaceOps).toHaveLength(1) expect((replaceOps[0] as any).block.id).toBe('child-1') - // Parent is unchanged expect(replaceOps.some((o: any) => o.block.id === 'parent-1')).toBe(false) }) @@ -403,10 +424,10 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { const map = createBlocksMap([oldParent]) const newChildren = [ - newBlock('child-1', 'Paragraph', 'Existing child'), - newBlock('child-new', 'Paragraph', 'New child'), + hmBlock('child-1', 'Paragraph', 'Existing child'), + hmBlock('child-new', 'Paragraph', 'New child'), ] - const inputParent = newBlockWithChildren('parent-1', 'Heading', 'Parent', newChildren) + const inputParent = hmBlockWithChildren('parent-1', 'Heading', 'Parent', newChildren) const ops = computeReplaceOps(map, [inputParent]) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') @@ -414,139 +435,28 @@ describe('computeReplaceOps — ID-based diff (no matchBlockIds)', () => { expect((replaceOps[0] as any).block.id).toBe('child-new') expect(ops.filter((o) => o.type === 'DeleteBlocks')).toHaveLength(0) }) -}) - -// ── hmBlockNodeToBlockNode ────────────────────────────────────────────────── - -describe('hmBlockNodeToBlockNode', () => { - test('converts basic paragraph', () => { - const hm: HMBlockNode = { - block: { - type: 'Paragraph', - id: 'abc123', - text: 'Hello world', - annotations: [{type: 'Bold', starts: [0], ends: [5]}], - attributes: {}, - }, - children: [], - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.type).toBe('Paragraph') - expect(result.block.id).toBe('abc123') - expect(result.block.text).toBe('Hello world') - expect(result.block.annotations).toEqual([{type: 'Bold', starts: [0], ends: [5]}]) - expect(result.children).toEqual([]) - }) - - test('extracts childrenType from attributes', () => { - const hm: HMBlockNode = { - block: { - type: 'Paragraph', - id: 'list1', - text: 'Item', - attributes: {childrenType: 'Ordered'}, - }, - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.childrenType).toBe('Ordered') - }) - - test('extracts language from attributes (code block)', () => { - const hm: HMBlockNode = { - block: { - type: 'Code', - id: 'code1', - text: 'const x = 1', - attributes: {language: 'typescript'}, - }, - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.language).toBe('typescript') - }) - - test('preserves link field', () => { - const hm: HMBlockNode = { - block: { - type: 'Image', - id: 'img1', - text: '', - link: 'ipfs://bafy...', - attributes: {}, - }, - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.link).toBe('ipfs://bafy...') - }) - - test('converts children recursively', () => { - const hm: HMBlockNode = { - block: {type: 'Heading', id: 'h1', text: 'Title', attributes: {}}, - children: [ - { - block: {type: 'Paragraph', id: 'p1', text: 'Child 1', attributes: {}}, - children: [], - }, - { - block: {type: 'Paragraph', id: 'p2', text: 'Child 2', attributes: {}}, - }, - ], - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.children).toHaveLength(2) - expect(result.children[0].block.id).toBe('p1') - expect(result.children[0].block.text).toBe('Child 1') - expect(result.children[1].block.id).toBe('p2') - expect(result.children[1].block.text).toBe('Child 2') - }) - - test('handles missing optional fields gracefully', () => { - const hm: HMBlockNode = { - block: {type: 'Paragraph', id: 'min', text: '', attributes: {}}, - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.type).toBe('Paragraph') - expect(result.block.id).toBe('min') - expect(result.block.text).toBe('') - expect(result.block.annotations).toEqual([]) - expect(result.children).toEqual([]) - expect(result.block.childrenType).toBeUndefined() - expect(result.block.language).toBeUndefined() - expect(result.block.link).toBeUndefined() - }) - test('converted HMBlockNode works with computeReplaceOps for smart diff', () => { - // End-to-end: existing doc → JSON input with IDs → diff + test('HMBlockNode works directly with computeReplaceOps for smart diff', () => { const existingDoc: APIBlockNode[] = [ apiBlock('blk-1', 'Paragraph', 'First paragraph'), apiBlock('blk-2', 'Paragraph', 'Second paragraph'), ] const oldMap = createBlocksMap(existingDoc) - // JSON input: user modified second paragraph and added a third - const jsonInput: HMBlockNode[] = [ - {block: {type: 'Paragraph', id: 'blk-1', text: 'First paragraph', attributes: {}}, children: []}, - {block: {type: 'Paragraph', id: 'blk-2', text: 'EDITED second', attributes: {}}, children: []}, - {block: {type: 'Paragraph', id: 'new-blk', text: 'Brand new', attributes: {}}, children: []}, + const input: HMBlockNode[] = [ + {block: {type: 'Paragraph', id: 'blk-1', text: 'First paragraph', attributes: {}} as any}, + {block: {type: 'Paragraph', id: 'blk-2', text: 'EDITED second', attributes: {}} as any}, + {block: {type: 'Paragraph', id: 'new-blk', text: 'Brand new', attributes: {}} as any}, ] - const tree = jsonInput.map(hmBlockNodeToBlockNode) - const ops = computeReplaceOps(oldMap, tree) + const ops = computeReplaceOps(oldMap, input) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') - // blk-2 changed, new-blk is new expect(replaceOps).toHaveLength(2) const replacedIds = replaceOps.map((o: any) => o.block.id) expect(replacedIds).toContain('blk-2') expect(replacedIds).toContain('new-blk') - // blk-1 unchanged — no ReplaceBlock expect(replacedIds).not.toContain('blk-1') - // No deletes expect(ops.filter((o) => o.type === 'DeleteBlocks')).toHaveLength(0) }) }) diff --git a/frontend/apps/cli/src/utils/block-diff.ts b/frontend/apps/cli/src/utils/block-diff.ts index c8d92e9da..c0b1674da 100644 --- a/frontend/apps/cli/src/utils/block-diff.ts +++ b/frontend/apps/cli/src/utils/block-diff.ts @@ -5,6 +5,5 @@ export { createBlocksMap, matchBlockIds, computeReplaceOps, - hmBlockNodeToBlockNode, } from '@seed-hypermedia/client/block-diff' export type {APIBlockNode, APIBlock} from '@seed-hypermedia/client/block-diff' diff --git a/frontend/apps/cli/src/utils/blocks-json.ts b/frontend/apps/cli/src/utils/blocks-json.ts index cbb2f0f47..219b2c972 100644 --- a/frontend/apps/cli/src/utils/blocks-json.ts +++ b/frontend/apps/cli/src/utils/blocks-json.ts @@ -17,8 +17,8 @@ export function parseBlocksJson(json: string): HMBlockNode[] { /** * Convert HMBlockNode[] into document operations (ReplaceBlock + MoveBlocks). * - * Same traversal pattern as flattenToOperations in markdown.ts but works - * directly with HMBlockNode from @shm/shared. + * Traverses the HMBlockNode tree and emits ReplaceBlock + MoveBlocks + * operations for each block. */ export function hmBlockNodesToOperations(nodes: HMBlockNode[], parentId: string = ''): DocumentOperation[] { const ops: DocumentOperation[] = [] diff --git a/frontend/apps/cli/src/utils/markdown.ts b/frontend/apps/cli/src/utils/markdown.ts index f681b0188..d25b2d98f 100644 --- a/frontend/apps/cli/src/utils/markdown.ts +++ b/frontend/apps/cli/src/utils/markdown.ts @@ -6,8 +6,6 @@ * in the client SDK's `markdown-to-blocks.ts`. */ -export {parseMarkdown, flattenToOperations, parseInlineFormatting, parseFrontmatter} from '@seed-hypermedia/client' - -export type {BlockNode, SeedBlock, Annotation} from '@seed-hypermedia/client' +export {parseMarkdown, parseInlineFormatting, parseFrontmatter} from '@seed-hypermedia/client' export type {HMMetadata} from '@seed-hypermedia/client/hm-types' diff --git a/frontend/apps/desktop/src/app-drafts.ts b/frontend/apps/desktop/src/app-drafts.ts index f3e6fbac6..5635bed20 100644 --- a/frontend/apps/desktop/src/app-drafts.ts +++ b/frontend/apps/desktop/src/app-drafts.ts @@ -10,7 +10,7 @@ import { HMResourceVisibilitySchema, } from '@seed-hypermedia/client/hm-types' import {parseDraftFilename} from '@seed-hypermedia/client/blocks-to-markdown' -import {parseMarkdown, markdownBlockNodesToHMBlockNodes} from '@seed-hypermedia/client/markdown-to-blocks' +import {parseMarkdown} from '@seed-hypermedia/client/markdown-to-blocks' import {hmBlocksToEditorContent} from '@seed-hypermedia/client/hmblock-to-editorblock' import {hmIdPathToEntityQueryPath, pathMatches} from '@shm/shared' import {queryKeys} from '@shm/shared/models/query-keys' @@ -415,8 +415,7 @@ async function readDraftContent(draftId: string, indexEntry: HMListedDraft): Pro try { const raw = await fs.readFile(file.path, 'utf-8') const {tree} = parseMarkdown(raw) - const hmNodes = markdownBlockNodesToHMBlockNodes(tree) - const editorBlocks = hmBlocksToEditorContent(hmNodes) + const editorBlocks = hmBlocksToEditorContent(tree) return { content: editorBlocks, diff --git a/frontend/packages/client/__tests__/block-diff.test.ts b/frontend/packages/client/__tests__/block-diff.test.ts index 485c1edd4..60fa471df 100644 --- a/frontend/packages/client/__tests__/block-diff.test.ts +++ b/frontend/packages/client/__tests__/block-diff.test.ts @@ -1,7 +1,6 @@ import {describe, it, expect} from 'vitest' -import {createBlocksMap, matchBlockIds, computeReplaceOps, hmBlockNodeToBlockNode} from '../src/block-diff' +import {createBlocksMap, matchBlockIds, computeReplaceOps} from '../src/block-diff' import type {APIBlockNode} from '../src/block-diff' -import type {BlockNode} from '../src/markdown-to-blocks' import type {HMBlockNode} from '../src/hm-types' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -20,16 +19,15 @@ function apiBlockWithChildren(id: string, type: string, text: string, children: } } -function newBlock(id: string, type: string, text = ''): BlockNode { +function hmBlock(id: string, type: string, text = '', extra?: Record): HMBlockNode { return { - block: {id, type, text, annotations: []}, - children: [], + block: {type, id, text, annotations: [], attributes: {}, ...extra} as any, } } -function newBlockWithChildren(id: string, type: string, text: string, children: BlockNode[]): BlockNode { +function hmBlockWithChildren(id: string, type: string, text: string, children: HMBlockNode[]): HMBlockNode { return { - block: {id, type, text, annotations: []}, + block: {type, id, text, annotations: [], attributes: {}} as any, children, } } @@ -66,19 +64,19 @@ describe('createBlocksMap', () => { describe('matchBlockIds', () => { it('same-type blocks at same position reuse old IDs', () => { const old = [apiBlock('old-1', 'Paragraph'), apiBlock('old-2', 'Heading')] - const fresh = [newBlock('new-1', 'Paragraph'), newBlock('new-2', 'Heading')] + const fresh = [hmBlock('new-1', 'Paragraph'), hmBlock('new-2', 'Heading')] const matched = matchBlockIds(old, fresh) - expect(matched[0]!.block.id).toBe('old-1') - expect(matched[1]!.block.id).toBe('old-2') + expect((matched[0]!.block as any).id).toBe('old-1') + expect((matched[1]!.block as any).id).toBe('old-2') }) it('different-type blocks keep new IDs', () => { const old = [apiBlock('old-1', 'Paragraph')] - const fresh = [newBlock('new-1', 'Heading')] + const fresh = [hmBlock('new-1', 'Heading')] const matched = matchBlockIds(old, fresh) - expect(matched[0]!.block.id).toBe('new-1') + expect((matched[0]!.block as any).id).toBe('new-1') }) }) @@ -88,7 +86,7 @@ describe('computeReplaceOps', () => { it('unchanged blocks produce no ReplaceBlock ops', () => { const old = [apiBlock('a', 'Paragraph', 'Hello')] const map = createBlocksMap(old) - const input = [newBlock('a', 'Paragraph', 'Hello')] + const input = [hmBlock('a', 'Paragraph', 'Hello')] const ops = computeReplaceOps(map, input) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(0) @@ -97,7 +95,7 @@ describe('computeReplaceOps', () => { it('changed text produces ReplaceBlock', () => { const old = [apiBlock('a', 'Paragraph', 'Old text')] const map = createBlocksMap(old) - const input = [newBlock('a', 'Paragraph', 'New text')] + const input = [hmBlock('a', 'Paragraph', 'New text')] const ops = computeReplaceOps(map, input) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(1) @@ -106,7 +104,7 @@ describe('computeReplaceOps', () => { it('new blocks produce ReplaceBlock', () => { const old = [apiBlock('a', 'Paragraph', 'Existing')] const map = createBlocksMap(old) - const input = [newBlock('a', 'Paragraph', 'Existing'), newBlock('b', 'Paragraph', 'Brand new')] + const input = [hmBlock('a', 'Paragraph', 'Existing'), hmBlock('b', 'Paragraph', 'Brand new')] const ops = computeReplaceOps(map, input) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') @@ -117,7 +115,7 @@ describe('computeReplaceOps', () => { it('removed blocks produce DeleteBlocks', () => { const old = [apiBlock('a', 'Paragraph', 'Keep'), apiBlock('b', 'Paragraph', 'Remove')] const map = createBlocksMap(old) - const input = [newBlock('a', 'Paragraph', 'Keep')] + const input = [hmBlock('a', 'Paragraph', 'Keep')] const ops = computeReplaceOps(map, input) const deleteOps = ops.filter((o) => o.type === 'DeleteBlocks') @@ -132,10 +130,10 @@ describe('computeReplaceOps — ID-based diff', () => { it('mix of known and unknown IDs: known blocks are diffed, unknown are new', () => { const old = [apiBlock('blk-aaa', 'Paragraph', 'Existing'), apiBlock('blk-bbb', 'Heading', 'Heading')] const map = createBlocksMap(old) - const input: BlockNode[] = [ - newBlock('blk-aaa', 'Paragraph', 'Existing'), - newBlock('random99', 'Paragraph', 'New paragraph'), - newBlock('blk-bbb', 'Heading', 'Heading'), + const input: HMBlockNode[] = [ + hmBlock('blk-aaa', 'Paragraph', 'Existing'), + hmBlock('random99', 'Paragraph', 'New paragraph'), + hmBlock('blk-bbb', 'Heading', 'Heading'), ] const ops = computeReplaceOps(map, input) @@ -148,7 +146,7 @@ describe('computeReplaceOps — ID-based diff', () => { it('no matching IDs: full body replacement', () => { const old = [apiBlock('old-aaa', 'Paragraph', 'Old'), apiBlock('old-bbb', 'Heading', 'Old')] const map = createBlocksMap(old) - const input = [newBlock('gen-111', 'Paragraph', 'New'), newBlock('gen-222', 'Heading', 'New')] + const input = [hmBlock('gen-111', 'Paragraph', 'New'), hmBlock('gen-222', 'Heading', 'New')] const ops = computeReplaceOps(map, input) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(2) @@ -161,7 +159,7 @@ describe('computeReplaceOps — ID-based diff', () => { it('reordered blocks produce MoveBlocks with correct order', () => { const old = [apiBlock('blk-aaa', 'Paragraph', 'First'), apiBlock('blk-bbb', 'Paragraph', 'Second')] const map = createBlocksMap(old) - const input = [newBlock('blk-bbb', 'Paragraph', 'Second'), newBlock('blk-aaa', 'Paragraph', 'First')] + const input = [hmBlock('blk-bbb', 'Paragraph', 'Second'), hmBlock('blk-aaa', 'Paragraph', 'First')] const ops = computeReplaceOps(map, input) const moveOps = ops.filter((o) => o.type === 'MoveBlocks') @@ -169,65 +167,21 @@ describe('computeReplaceOps — ID-based diff', () => { expect((moveOps[0] as any).blocks).toEqual(['blk-bbb', 'blk-aaa']) expect(ops.filter((o) => o.type === 'ReplaceBlock')).toHaveLength(0) }) -}) - -// ── hmBlockNodeToBlockNode ────────────────────────────────────────────────── - -describe('hmBlockNodeToBlockNode', () => { - it('converts basic paragraph', () => { - const hm: HMBlockNode = { - block: { - type: 'Paragraph', - id: 'abc123', - text: 'Hello world', - annotations: [{type: 'Bold', starts: [0], ends: [5]}], - attributes: {}, - }, - children: [], - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.type).toBe('Paragraph') - expect(result.block.id).toBe('abc123') - expect(result.block.text).toBe('Hello world') - }) - - it('extracts childrenType and language from attributes', () => { - const hm: HMBlockNode = { - block: {type: 'Code', id: 'c1', text: 'x = 1', attributes: {language: 'python', childrenType: 'Ordered'}}, - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.block.language).toBe('python') - expect(result.block.childrenType).toBe('Ordered') - }) - - it('converts children recursively', () => { - const hm: HMBlockNode = { - block: {type: 'Heading', id: 'h1', text: 'Title', attributes: {}}, - children: [{block: {type: 'Paragraph', id: 'p1', text: 'Child', attributes: {}}, children: []}], - } - - const result = hmBlockNodeToBlockNode(hm) - expect(result.children).toHaveLength(1) - expect(result.children[0]!.block.id).toBe('p1') - }) - it('end-to-end: HMBlockNode → BlockNode → computeReplaceOps', () => { + it('HMBlockNode works directly with computeReplaceOps', () => { const existingDoc = [apiBlock('blk-1', 'Paragraph', 'First'), apiBlock('blk-2', 'Paragraph', 'Second')] const oldMap = createBlocksMap(existingDoc) - const jsonInput: HMBlockNode[] = [ - {block: {type: 'Paragraph', id: 'blk-1', text: 'First', attributes: {}}, children: []}, - {block: {type: 'Paragraph', id: 'blk-2', text: 'EDITED', attributes: {}}, children: []}, - {block: {type: 'Paragraph', id: 'new-blk', text: 'Brand new', attributes: {}}, children: []}, + const input: HMBlockNode[] = [ + {block: {type: 'Paragraph', id: 'blk-1', text: 'First', attributes: {}} as any}, + {block: {type: 'Paragraph', id: 'blk-2', text: 'EDITED', attributes: {}} as any}, + {block: {type: 'Paragraph', id: 'new-blk', text: 'Brand new', attributes: {}} as any}, ] - const tree = jsonInput.map(hmBlockNodeToBlockNode) - const ops = computeReplaceOps(oldMap, tree) + const ops = computeReplaceOps(oldMap, input) const replaceOps = ops.filter((o) => o.type === 'ReplaceBlock') - expect(replaceOps).toHaveLength(2) // blk-2 changed + new-blk + expect(replaceOps).toHaveLength(2) const replacedIds = replaceOps.map((o: any) => o.block.id) expect(replacedIds).toContain('blk-2') expect(replacedIds).toContain('new-blk') diff --git a/frontend/packages/client/src/block-diff.ts b/frontend/packages/client/src/block-diff.ts index fe57ffca3..57a1e3c91 100644 --- a/frontend/packages/client/src/block-diff.ts +++ b/frontend/packages/client/src/block-diff.ts @@ -13,8 +13,7 @@ */ import type {DocumentOperation} from './change' -import type {HMBlockNode} from './hm-types' -import type {BlockNode, SeedBlock} from './markdown-to-blocks' +import type {HMBlock, HMBlockNode} from './hm-types' // ── Types matching the API response shape ──────────────────────────────────── @@ -75,20 +74,21 @@ export function createBlocksMap(nodes: APIBlockNode[], parentId: string = ''): B * * Returns a new tree with IDs reassigned (does not mutate inputs). */ -export function matchBlockIds(oldNodes: APIBlockNode[], newNodes: BlockNode[]): BlockNode[] { +export function matchBlockIds(oldNodes: APIBlockNode[], newNodes: HMBlockNode[]): HMBlockNode[] { return newNodes.map((newNode, idx) => { const oldNode = idx < oldNodes.length ? oldNodes[idx] : undefined + const b = newNode.block as Record - let matchedId = newNode.block.id - if (oldNode && oldNode.block.type === newNode.block.type) { + let matchedId = b.id as string + if (oldNode && oldNode.block.type === (b.type as string)) { matchedId = oldNode.block.id } - const matchedChildren = matchBlockIds(oldNode?.children ?? [], newNode.children) + const matchedChildren = matchBlockIds(oldNode?.children ?? [], newNode.children || []) return { - block: {...newNode.block, id: matchedId}, - children: matchedChildren, + block: {...newNode.block, id: matchedId} as HMBlock, + children: matchedChildren.length > 0 ? matchedChildren : undefined, } }) } @@ -105,7 +105,7 @@ export function matchBlockIds(oldNodes: APIBlockNode[], newNodes: BlockNode[]): */ export function computeReplaceOps( oldMap: BlocksMap, - matchedTree: BlockNode[], + matchedTree: HMBlockNode[], parentId: string = '', ): DocumentOperation[] { const ops: DocumentOperation[] = [] @@ -113,51 +113,39 @@ export function computeReplaceOps( const blockIdsAtLevel: string[] = [] matchedTree.forEach((node, idx) => { - const blockId = node.block.id + const b = node.block as Record + const blockId = b.id as string touchedIds.add(blockId) blockIdsAtLevel.push(blockId) const oldEntry = oldMap[blockId] const isNew = !oldEntry - // Build the block object for ReplaceBlock - const block: Record = { - type: node.block.type, - id: blockId, - text: node.block.text, - annotations: node.block.annotations, - } - if (node.block.language !== undefined) { - block.language = node.block.language - } - if (node.block.childrenType !== undefined) { - block.childrenType = node.block.childrenType - } - if (isNew) { - // New block: need both ReplaceBlock and MoveBlocks - ops.push({type: 'ReplaceBlock', block}) + // New block: emit ReplaceBlock with the HMBlock directly + ops.push({type: 'ReplaceBlock', block: node.block}) } else { // Existing block: only ReplaceBlock if content changed if (!isBlockContentEqual(oldEntry.block, node.block)) { - ops.push({type: 'ReplaceBlock', block}) + ops.push({type: 'ReplaceBlock', block: node.block}) } // Check if position changed const prevNode = idx > 0 ? matchedTree[idx - 1] : undefined - const expectedLeft = prevNode?.block.id ?? '' + const expectedLeft = prevNode ? ((prevNode.block as Record).id as string) : '' if (oldEntry.parent !== parentId || oldEntry.left !== expectedLeft) { // Position changed — will be handled by the MoveBlocks below } } // Recurse into children - if (node.children.length > 0) { - const childOps = computeReplaceOps(oldMap, node.children, blockId) + const children = node.children || [] + if (children.length > 0) { + const childOps = computeReplaceOps(oldMap, children, blockId) ops.push(...childOps) // Collect touched IDs from children - collectIds(node.children, touchedIds) + collectIds(children, touchedIds) } }) @@ -188,24 +176,28 @@ export function computeReplaceOps( // ── Helpers ────────────────────────────────────────────────────────────────── -function collectIds(nodes: BlockNode[], set: Set) { +function collectIds(nodes: HMBlockNode[], set: Set) { for (const node of nodes) { - set.add(node.block.id) - collectIds(node.children, set) + set.add((node.block as Record).id as string) + collectIds(node.children || [], set) } } /** - * Compare old API block content with new parsed block content. + * Compare old API block content with new HMBlock content. * Returns true if they're semantically equal. */ -function isBlockContentEqual(old: APIBlock, newBlock: SeedBlock): boolean { - if (old.type !== newBlock.type) return false - if ((old.text || '') !== (newBlock.text || '')) return false +function isBlockContentEqual(old: APIBlock, newBlock: HMBlock): boolean { + const nb = newBlock as Record + if (old.type !== nb.type) return false + if ((old.text || '') !== ((nb.text as string) || '')) return false + + // Compare link field (images, embeds, etc.) + if ((old.link || '') !== ((nb.link as string) || '')) return false // Compare annotations const oldAnn = old.annotations || [] - const newAnn = newBlock.annotations || [] + const newAnn = (nb.annotations as unknown[]) || [] if (oldAnn.length !== newAnn.length) return false if (oldAnn.length > 0 && JSON.stringify(oldAnn) !== JSON.stringify(newAnn)) { return false @@ -213,39 +205,11 @@ function isBlockContentEqual(old: APIBlock, newBlock: SeedBlock): boolean { // Compare relevant attributes const oldAttrs = old.attributes || {} - if ((oldAttrs.childrenType || '') !== (newBlock.childrenType || '')) { + const newAttrs = (nb.attributes as Record) || {} + if ((oldAttrs.childrenType || '') !== (newAttrs.childrenType || '')) { return false } - if ((oldAttrs.language || '') !== (newBlock.language || '')) return false + if ((oldAttrs.language || '') !== (newAttrs.language || '')) return false return true } - -// ── HMBlockNode → BlockNode conversion ────────────────────────────────────── - -/** - * Convert an HMBlockNode tree (from JSON input or API response) into - * a BlockNode tree compatible with the diff pipeline. - * - * HMBlockNode nests childrenType/language inside `attributes`, while - * SeedBlock/BlockNode has them at the top level. - */ -export function hmBlockNodeToBlockNode(node: HMBlockNode): BlockNode { - const b = node.block as Record - const attrs = (b.attributes || {}) as Record - - const block: SeedBlock = { - type: (b.type as string) || '', - id: (b.id as string) || '', - text: (b.text as string) || '', - annotations: (b.annotations as SeedBlock['annotations']) || [], - ...(attrs.childrenType ? {childrenType: attrs.childrenType as string} : {}), - ...(attrs.language ? {language: attrs.language as string} : {}), - ...(b.link ? {link: b.link as string} : {}), - } - - return { - block, - children: (node.children || []).map(hmBlockNodeToBlockNode), - } -} diff --git a/frontend/packages/client/src/index.ts b/frontend/packages/client/src/index.ts index 86e358889..77d4539a3 100644 --- a/frontend/packages/client/src/index.ts +++ b/frontend/packages/client/src/index.ts @@ -57,18 +57,11 @@ export type {DomainResolverFn, DomainIdChangedCallback, ResolveOptions, Resolved export {fileToIpfsBlobs, filesToIpfsBlobs, resolveFileLinksInBlocks, hasFileLinks} from './file-to-ipfs' export type {CollectedBlob} from './file-to-ipfs' -export { - parseMarkdown, - flattenToOperations, - parseInlineFormatting, - parseFrontmatter, - markdownBlockNodesToHMBlockNodes, -} from './markdown-to-blocks' -export type {BlockNode, SeedBlock, Annotation} from './markdown-to-blocks' +export {parseMarkdown, parseInlineFormatting, parseFrontmatter} from './markdown-to-blocks' export {blocksToMarkdown, emitFrontmatter, slugify, draftFilename, parseDraftFilename} from './blocks-to-markdown' export type {BlocksToMarkdownOptions} from './blocks-to-markdown' -export {createBlocksMap, matchBlockIds, computeReplaceOps, hmBlockNodeToBlockNode} from './block-diff' +export {createBlocksMap, matchBlockIds, computeReplaceOps} from './block-diff' export type {APIBlockNode, APIBlock} from './block-diff' export { diff --git a/frontend/packages/client/src/markdown-to-blocks.ts b/frontend/packages/client/src/markdown-to-blocks.ts index 261e08777..a46c4f082 100644 --- a/frontend/packages/client/src/markdown-to-blocks.ts +++ b/frontend/packages/client/src/markdown-to-blocks.ts @@ -16,18 +16,17 @@ import {parse as parseYaml} from 'yaml' import type {HMBlockNode, HMMetadata} from './hm-types' -import type {DocumentOperation} from './change' // ─── Types ─────────────────────────────────────────────────────────────────── -export type Annotation = { +type Annotation = { type: string starts: number[] ends: number[] link?: string } -export type SeedBlock = { +type SeedBlock = { type: string id: string text: string @@ -37,7 +36,7 @@ export type SeedBlock = { link?: string } -export type BlockNode = { +type BlockNode = { block: SeedBlock children: BlockNode[] } @@ -635,7 +634,7 @@ export function parseFrontmatter(markdown: string): { * When no ID is present, random 8-char IDs are generated. */ export function parseMarkdown(markdown: string): { - tree: BlockNode[] + tree: HMBlockNode[] metadata: HMMetadata } { const {content, metadata} = parseFrontmatter(markdown) @@ -704,7 +703,7 @@ export function parseMarkdown(markdown: string): { } } - return {tree: rootNodes, metadata} + return {tree: markdownBlockNodesToHMBlockNodes(rootNodes), metadata} } /** @@ -715,7 +714,7 @@ export function parseMarkdown(markdown: string): { * The resulting tree can be fed into `hmBlocksToEditorContent()` to get * BlockNote editor blocks. */ -export function markdownBlockNodesToHMBlockNodes(nodes: BlockNode[]): HMBlockNode[] { +function markdownBlockNodesToHMBlockNodes(nodes: BlockNode[]): HMBlockNode[] { return nodes.map((node) => { const {block} = node const attributes: Record = {} @@ -750,58 +749,3 @@ export function markdownBlockNodesToHMBlockNodes(nodes: BlockNode[]): HMBlockNod } as HMBlockNode }) } - -// ─── Operations builder ────────────────────────────────────────────────────── - -/** - * Flattens a block tree into Seed document operations. - * - * For each block: - * 1. ReplaceBlock — defines the block content - * 2. MoveBlocks — positions the block under its parent - * - * Operations are ordered so that ReplaceBlock comes before MoveBlocks - * for each level, and children are processed recursively. - */ -export function flattenToOperations(tree: BlockNode[], parentId: string = ''): DocumentOperation[] { - const ops: DocumentOperation[] = [] - const blockIds: string[] = [] - - for (const node of tree) { - // Build the block object for ReplaceBlock. - // Attributes are inlined at the top level of the block (not nested). - const block: Record = { - type: node.block.type, - id: node.block.id, - text: node.block.text, - annotations: node.block.annotations, - } - - if (node.block.language !== undefined) { - block['language'] = node.block.language - } - - if (node.block.childrenType !== undefined) { - block['childrenType'] = node.block.childrenType - } - - if (node.block.link !== undefined) { - block['link'] = node.block.link - } - - ops.push({type: 'ReplaceBlock', block}) - blockIds.push(node.block.id) - - // Recurse into children - if (node.children.length > 0) { - ops.push(...flattenToOperations(node.children, node.block.id)) - } - } - - // Position all blocks at this level under the parent - if (blockIds.length > 0) { - ops.push({type: 'MoveBlocks', blocks: blockIds, parent: parentId}) - } - - return ops -}