From b0d096bc44b451476e104d5f93f55bed7fe3472d Mon Sep 17 00:00:00 2001 From: juligasa <11684004+juligasa@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:59:49 +0100 Subject: [PATCH 1/5] refactor(cli): unify block tree on HMBlockNode, fix markdown image resolution Replace BlockNode with HMBlockNode throughout the diff engine and CLI pipeline. This eliminates the parallel type system and the bidirectional converters (hmBlockNodeToBlockNode, markdownBlockNodesToHMBlockNodes at call sites). - block-diff.ts: computeReplaceOps, matchBlockIds, isBlockContentEqual now accept HMBlockNode directly; removed hmBlockNodeToBlockNode - markdown-to-blocks.ts: parseMarkdown now returns HMBlockNode[] by calling markdownBlockNodesToHMBlockNodes internally - document.ts: markdown path now resolves file:// image links to IPFS via resolveFileLinks (previously only worked for JSON input) - Removed flattenToOperations usage in favor of hmBlockNodesToOperations - Updated all tests to use HMBlockNode format Fixes: markdown images with local file paths (![alt](/path/to/img.png)) are now uploaded to IPFS and rendered correctly when published. --- frontend/apps/cli/src/commands/document.ts | 22 +- frontend/apps/cli/src/test/fixture-seed.ts | 5 +- .../apps/cli/src/utils/block-diff.test.ts | 274 ++++++------------ frontend/apps/cli/src/utils/block-diff.ts | 1 - frontend/apps/cli/src/utils/blocks-json.ts | 4 +- frontend/apps/cli/src/utils/markdown.ts | 4 +- .../client/__tests__/block-diff.test.ts | 100 ++----- frontend/packages/client/src/block-diff.ts | 104 +++---- frontend/packages/client/src/index.ts | 2 +- .../packages/client/src/markdown-to-blocks.ts | 4 +- 10 files changed, 170 insertions(+), 350 deletions(-) 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/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..5f041a000 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..1bb7c9a88 100644 --- a/frontend/packages/client/src/index.ts +++ b/frontend/packages/client/src/index.ts @@ -68,7 +68,7 @@ export type {BlockNode, SeedBlock, Annotation} 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..e5f348139 100644 --- a/frontend/packages/client/src/markdown-to-blocks.ts +++ b/frontend/packages/client/src/markdown-to-blocks.ts @@ -635,7 +635,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 +704,7 @@ export function parseMarkdown(markdown: string): { } } - return {tree: rootNodes, metadata} + return {tree: markdownBlockNodesToHMBlockNodes(rootNodes), metadata} } /** From c0bf09698cded45dde69dc14b314da0e4fbea47b Mon Sep 17 00:00:00 2001 From: juligasa <11684004+juligasa@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:47:27 +0100 Subject: [PATCH 2/5] style: format block-diff.ts with prettier --- frontend/packages/client/src/block-diff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/packages/client/src/block-diff.ts b/frontend/packages/client/src/block-diff.ts index 5f041a000..57a1e3c91 100644 --- a/frontend/packages/client/src/block-diff.ts +++ b/frontend/packages/client/src/block-diff.ts @@ -132,7 +132,7 @@ export function computeReplaceOps( // Check if position changed const prevNode = idx > 0 ? matchedTree[idx - 1] : undefined - const expectedLeft = prevNode ? (prevNode.block as Record).id as string : '' + 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 } From e2f9fad470f45c5ab40549df3bf81faf8c091216 Mon Sep 17 00:00:00 2001 From: juligasa <11684004+juligasa@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:02:29 +0100 Subject: [PATCH 3/5] fix: remove redundant markdownBlockNodesToHMBlockNodes call in desktop app --- frontend/apps/desktop/src/app-drafts.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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, From 6480cbcb7b417ddce3c5a0f8806983e6d54ccebd Mon Sep 17 00:00:00 2001 From: juligasa <11684004+juligasa@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:00:57 +0200 Subject: [PATCH 4/5] refactor(client): remove dead BlockNode exports and flattenToOperations Now that the entire pipeline uses HMBlockNode, remove the legacy BlockNode/SeedBlock/Annotation type exports, the flattenToOperations function, and the markdownBlockNodesToHMBlockNodes re-export from the public API. The internal types and converter remain for parseMarkdown's internal use. --- frontend/packages/client/src/index.ts | 9 +-- .../packages/client/src/markdown-to-blocks.ts | 63 ++----------------- 2 files changed, 5 insertions(+), 67 deletions(-) diff --git a/frontend/packages/client/src/index.ts b/frontend/packages/client/src/index.ts index 1bb7c9a88..77d4539a3 100644 --- a/frontend/packages/client/src/index.ts +++ b/frontend/packages/client/src/index.ts @@ -57,14 +57,7 @@ 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' diff --git a/frontend/packages/client/src/markdown-to-blocks.ts b/frontend/packages/client/src/markdown-to-blocks.ts index e5f348139..78675f558 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[] } @@ -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 = {} @@ -751,57 +750,3 @@ export function markdownBlockNodesToHMBlockNodes(nodes: BlockNode[]): HMBlockNod }) } -// ─── 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 -} From 262f9a3cb6520352e3f46e711254988b7272310c Mon Sep 17 00:00:00 2001 From: juligasa <11684004+juligasa@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:07:55 +0200 Subject: [PATCH 5/5] style: format markdown-to-blocks.ts with prettier --- frontend/packages/client/src/markdown-to-blocks.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/packages/client/src/markdown-to-blocks.ts b/frontend/packages/client/src/markdown-to-blocks.ts index 78675f558..a46c4f082 100644 --- a/frontend/packages/client/src/markdown-to-blocks.ts +++ b/frontend/packages/client/src/markdown-to-blocks.ts @@ -749,4 +749,3 @@ function markdownBlockNodesToHMBlockNodes(nodes: BlockNode[]): HMBlockNode[] { } as HMBlockNode }) } -