diff --git a/frontend/apps/cli/src/commands/document.ts b/frontend/apps/cli/src/commands/document.ts index f085e48c9..f551e3f2c 100644 --- a/frontend/apps/cli/src/commands/document.ts +++ b/frontend/apps/cli/src/commands/document.ts @@ -25,7 +25,7 @@ import {unpackHmId} from '@shm/shared/utils/entity-id-url' import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api' import {getClient, getServerUrl, getOutputFormat, isPretty} from '../index' import {formatOutput, renderMarkdown, printError, printSuccess, printInfo, printWarning} from '../output' -import {documentToMarkdown} from '../markdown' +import {documentToMarkdown} from '@shm/shared/hm-markdown' import {resolveKey} from '../utils/keyring' import {resolveIdWithClient} from '../utils/resolve-id' import {createSignerFromKey} from '../utils/signer' diff --git a/frontend/apps/cli/src/commands/draft.ts b/frontend/apps/cli/src/commands/draft.ts index 5cc0e152b..0e26d0982 100644 --- a/frontend/apps/cli/src/commands/draft.ts +++ b/frontend/apps/cli/src/commands/draft.ts @@ -27,7 +27,7 @@ import * as readline from 'readline' import {readInput, mergeMetadata} from './document' import {getOutputFormat, isPretty} from '../index' import {formatOutput, renderMarkdown, printError, printSuccess, printInfo, printWarning} from '../output' -import {documentToMarkdown} from '../markdown' +import {documentToMarkdown} from '@shm/shared/hm-markdown' import {parseMarkdown} from '../utils/markdown' import {blocksToMarkdown, slugify, draftFilename, parseDraftFilename} from '@seed-hypermedia/client' import {editorBlocksToHMBlockNodes} from '@seed-hypermedia/client/editorblock-to-hmblock' diff --git a/frontend/apps/cli/src/markdown.ts b/frontend/apps/cli/src/markdown.ts index 71a11cde0..70aa693ae 100644 --- a/frontend/apps/cli/src/markdown.ts +++ b/frontend/apps/cli/src/markdown.ts @@ -1,483 +1,9 @@ /** - * CLI markdown formatter with network resolution support. + * CLI markdown formatter. * - * This is a thin wrapper around @seed-hypermedia/client's pure - * `blocksToMarkdown()` that adds optional resolution of embeds, - * mentions, and queries via a SeedClient. - * - * For non-resolved output, use `blocksToMarkdown()` from the client - * SDK directly. + * The implementation is shared with desktop/assistant code so HM URLs, + * documents, and comments render consistently across surfaces. */ -import type {HMBlockNode, HMBlock, HMAnnotation, HMDocument, HMMetadata} from '@seed-hypermedia/client/hm-types' -import type {SeedClient} from '@seed-hypermedia/client' -import {emitFrontmatter} from '@seed-hypermedia/client' -import {unpackHmId} from '@shm/shared/utils/entity-id-url' - -export type MarkdownOptions = { - resolve?: boolean // Enable automatic resolution of embeds/mentions/queries - client?: SeedClient // Required if resolve is true - maxDepth?: number // Max embed recursion depth (default 2) -} - -// Re-export the pure conversion for non-resolved use -export {blocksToMarkdown} from '@seed-hypermedia/client' - -// ── Main entry point ───────────────────────────────────────────────────────── - -/** - * Convert a document to markdown with frontmatter, block IDs, and - * optional resolution of embeds/mentions/queries. - * - * When `resolve` is false (default), this delegates to the pure - * `blocksToMarkdown()` from the client SDK. - * - * When `resolve` is true, embeds and queries are fetched via the - * provided SeedClient and their content is inlined. - */ -export async function documentToMarkdown(doc: HMDocument, options?: MarkdownOptions): Promise { - const resolve = options?.resolve ?? false - - // Fast path: no resolution needed → use pure sync conversion - if (!resolve || !options?.client) { - // Inline the pure conversion to avoid async overhead - const {blocksToMarkdown} = await import('@seed-hypermedia/client') - return blocksToMarkdown(doc) - } - - // Slow path: resolve embeds/mentions/queries - const lines: string[] = [] - const ctx: ResolveContext = { - client: options.client, - resolve: true, - maxDepth: options.maxDepth ?? 2, - currentDepth: 0, - cache: new Map(), - } - - // Always emit frontmatter - lines.push(emitFrontmatter(doc.metadata || {})) - - // Content blocks - for (const node of doc.content) { - const blockMd = await blockNodeToMarkdown(node, 0, ctx) - if (blockMd) { - lines.push(blockMd) - } - } - - return lines.join('\n') -} - -type ResolveContext = { - client: SeedClient - resolve: boolean - maxDepth: number - currentDepth: number - cache: Map -} - -// ── Block ID helpers ───────────────────────────────────────────────────────── - -function idComment(id: string): string { - return `` -} - -function appendIdToFirstLine(md: string, id: string): string { - const newline = md.indexOf('\n') - if (newline === -1) { - return md ? `${md} ${idComment(id)}` : idComment(id) - } - return `${md.slice(0, newline)} ${idComment(id)}${md.slice(newline)}` -} - -// ── Block rendering with resolution ────────────────────────────────────────── - -async function blockNodeToMarkdown(node: HMBlockNode, depth: number, ctx: ResolveContext): Promise { - const block = node.block - const children = node.children || [] - const childrenType = (block as {attributes?: {childrenType?: string}}).attributes?.childrenType - - const isListContainer = - block.type === 'Paragraph' && - !block.text && - (childrenType === 'Ordered' || childrenType === 'Unordered' || childrenType === 'Blockquote') - - let result: string - - if (isListContainer) { - result = idComment(block.id) - } else { - result = await blockToMarkdown(block, depth, ctx) - } - - for (const child of children) { - const childMd = await blockNodeToMarkdown(child, depth + 1, ctx) - if (childMd) { - if (childrenType === 'Ordered') { - result += '\n' + indent(depth + 1) + '1. ' + childMd.trim() - } else if (childrenType === 'Unordered') { - result += '\n' + indent(depth + 1) + '- ' + childMd.trim() - } else if (childrenType === 'Blockquote') { - result += '\n' + indent(depth + 1) + '> ' + childMd.trim() - } else { - result += '\n' + childMd - } - } - } - - return result -} - -async function blockToMarkdown(block: HMBlock, depth: number, ctx: ResolveContext): Promise { - const ind = indent(depth) - const b = block as { - type: string - id: string - text?: string - link?: string - annotations?: HMAnnotation[] - attributes?: Record - } - const text = b.text || '' - const link = b.link || '' - const annotations = b.annotations - const id = b.id - - switch (block.type) { - case 'Paragraph': { - const rendered = ind + (await applyAnnotations(text, annotations, ctx)) - return appendIdToFirstLine(rendered, id) - } - - case 'Heading': { - const level = Math.min(depth + 1, 6) - const hashes = '#'.repeat(level) - const rendered = `${hashes} ${await applyAnnotations(text, annotations, ctx)}` - return appendIdToFirstLine(rendered, id) - } - - case 'Code': { - const lang = (b.attributes?.language as string) || '' - return ind + '```' + lang + ' ' + idComment(id) + '\n' + ind + text + '\n' + ind + '```' - } - - case 'Math': { - return ind + '$$ ' + idComment(id) + '\n' + ind + text + '\n' + ind + '$$' - } - - case 'Image': { - const altText = text || 'image' - const imgUrl = formatMediaUrl(link) - return ind + `![${altText}](${imgUrl}) ${idComment(id)}` - } - - case 'Video': { - const videoUrl = formatMediaUrl(link) - return ind + `[Video](${videoUrl}) ${idComment(id)}` - } - - case 'File': { - const fileName = (b.attributes?.name as string) || 'file' - const fileUrl = formatMediaUrl(link) - return ind + `[${fileName}](${fileUrl}) ${idComment(id)}` - } - - case 'Embed': { - const rendered = await resolveBlockEmbed(block, depth, ctx) - return appendIdToFirstLine(rendered, id) - } - - case 'WebEmbed': { - return ind + `[Web Embed](${link}) ${idComment(id)}` - } - - case 'Button': { - const buttonText = text || 'Button' - return ind + `[${buttonText}](${link}) ${idComment(id)}` - } - - case 'Query': { - const rendered = await resolveQuery(block, depth, ctx) - return appendIdToFirstLine(rendered, id) - } - - case 'Nostr': { - return ind + `[Nostr: ${link}](${link}) ${idComment(id)}` - } - - default: { - if (text) { - return appendIdToFirstLine(ind + text, id) - } - return '' - } - } -} - -// ── Embed/query resolution ─────────────────────────────────────────────────── - -async function resolveBlockEmbed(block: HMBlock, depth: number, ctx: ResolveContext): Promise { - const ind = indent(depth) - const link = (block as {link?: string}).link || '' - - if (ctx.currentDepth >= ctx.maxDepth) { - return ind + `> [Embed: ${link}](${link})` - } - - try { - const unpacked = unpackHmId(link) - if (!unpacked) { - return ind + `> [Embed: ${link}](${link})` - } - - const cacheKey = link - let cached = ctx.cache.get(cacheKey) - - if (!cached) { - const result = await ctx.client.request('Resource', unpacked) - if (result.type === 'document') { - cached = { - name: result.document.metadata?.name, - content: result.document.content, - } - ctx.cache.set(cacheKey, cached) - } else { - return ind + `> [Embed: ${link}](${link})` - } - } - - let contentToRender = cached.content - if (unpacked.blockRef && cached.content) { - const targetBlock = findBlockById(cached.content, unpacked.blockRef as string) - if (targetBlock) { - contentToRender = [targetBlock] - } - } - - if (contentToRender && contentToRender.length > 0) { - const nestedCtx = {...ctx, currentDepth: ctx.currentDepth + 1} - const lines: string[] = [] - - if (cached.name && !unpacked.blockRef) { - lines.push(ind + `> **[${cached.name}](${link})**`) - } - - for (const node of contentToRender) { - const blockMd = await blockNodeToMarkdown(node, depth, nestedCtx) - if (blockMd) { - lines.push( - blockMd - .split('\n') - .map((l) => ind + '> ' + l.trim()) - .join('\n'), - ) - } - } - - return lines.join('\n') - } - - return ind + `> [Embed: ${link}](${link})` - } catch { - return ind + `> [Embed: ${link}](${link})` - } -} - -async function resolveQuery(block: HMBlock, depth: number, ctx: ResolveContext): Promise { - const ind = indent(depth) - - try { - type SortTerm = 'Path' | 'Title' | 'CreateTime' | 'UpdateTime' | 'DisplayTime' - const attrs = (block as {attributes?: Record}).attributes - const queryConfig = attrs?.query as - | { - includes?: Array<{space: string; path?: string; mode?: string}> - sort?: Array<{term: SortTerm; reverse?: boolean}> - limit?: number - } - | undefined - - let includes: Array<{space: string; path?: string; mode: 'Children' | 'AllDescendants'}> - let sort: Array<{term: SortTerm; reverse: boolean}> | undefined - let limit: number | undefined - - if (queryConfig?.includes) { - includes = queryConfig.includes.map((inc) => ({ - space: inc.space, - path: inc.path, - mode: (inc.mode as 'Children' | 'AllDescendants') || 'Children', - })) - sort = queryConfig.sort?.map((s) => ({term: s.term, reverse: s.reverse ?? false})) - limit = queryConfig.limit - } else { - const space = (attrs?.space as string) || '' - if (!space) { - return ind + `` - } - includes = [ - { - space, - path: attrs?.path as string | undefined, - mode: (attrs?.mode as 'Children' | 'AllDescendants') || 'Children', - }, - ] - } - - const results = await ctx.client.request('Query', { - includes, - sort: sort || [{term: 'UpdateTime', reverse: true}], - limit: limit || 10, - }) - - if (!results || !results.results || results.results.length === 0) { - return ind + `` - } - - const lines: string[] = [] - - for (const doc of results.results) { - const name = doc.metadata?.name || doc.id.path?.join('/') || doc.id.uid - const docId = doc.id.id - lines.push(ind + `- [${name}](${docId})`) - } - - return lines.join('\n') - } catch (e) { - return ind + `` - } -} - -// ── Annotation rendering with embed resolution ────────────────────────────── - -async function applyAnnotations( - text: string, - annotations: HMAnnotation[] | undefined, - ctx: ResolveContext, -): Promise { - if (!annotations || annotations.length === 0) { - return text - } - - type Marker = {pos: number; type: 'open' | 'close'; annotation: HMAnnotation} - const markers: Marker[] = [] - - for (const ann of annotations) { - const starts = ann.starts || [] - const ends = ann.ends || [] - - for (let i = 0; i < starts.length; i++) { - markers.push({pos: starts[i], type: 'open', annotation: ann}) - if (ends[i] !== undefined) { - markers.push({pos: ends[i], type: 'close', annotation: ann}) - } - } - } - - markers.sort((a, b) => { - if (a.pos !== b.pos) return a.pos - b.pos - return a.type === 'open' ? -1 : 1 - }) - - let result = '' - let lastPos = 0 - - for (const marker of markers) { - result += text.slice(lastPos, marker.pos) - lastPos = marker.pos - result += await getAnnotationMarker(marker.annotation, marker.type, ctx) - } - - result += text.slice(lastPos) - result = result.replace(/\uFFFC/g, '') - - return result -} - -async function getAnnotationMarker(ann: HMAnnotation, type: 'open' | 'close', ctx: ResolveContext): Promise { - switch (ann.type) { - case 'Bold': - return '**' - case 'Italic': - return '_' - case 'Strike': - return '~~' - case 'Code': - return '`' - case 'Underline': - return type === 'open' ? '' : '' - case 'Link': - if (type === 'open') { - return '[' - } else { - return `](${ann.link || ''})` - } - case 'Embed': - return await resolveInlineEmbed(ann, type, ctx) - default: - return '' - } -} - -async function resolveInlineEmbed(ann: HMAnnotation, type: 'open' | 'close', ctx: ResolveContext): Promise { - const link = 'link' in ann ? (ann.link as string) || '' : '' - - if (type === 'open') { - if (link) { - try { - const cacheKey = link - let cached = ctx.cache.get(cacheKey) - - if (!cached) { - const unpacked = unpackHmId(link) - if (unpacked) { - const result = await ctx.client.request('ResourceMetadata', unpacked) - cached = {name: result.metadata?.name} - ctx.cache.set(cacheKey, cached) - } - } - - if (cached?.name) { - return `[@${cached.name}` - } - } catch { - // Fall through to default - } - } - - const pathPart = link.includes('/') ? link.split('/').pop() || 'embed' : link.replace('hm://', '').slice(0, 12) - return `[↗ ${pathPart}` - } else { - return `](${link})` - } -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function findBlockById(content: HMBlockNode[], blockId: string): HMBlockNode | null { - for (const node of content) { - if (node.block.id === blockId) { - return node - } - if (node.children) { - const found = findBlockById(node.children, blockId) - if (found) return found - } - } - return null -} - -function formatMediaUrl(url: string): string { - if (url.startsWith('ipfs://')) { - const cid = url.slice(7) - return `https://ipfs.io/ipfs/${cid}` - } - return url -} - -function indent(depth: number): string { - return ' '.repeat(depth) -} - -// Re-export types for backward compatibility -export type {HMDocument, HMMetadata} +export {blocksToMarkdown, documentToMarkdown} from '@shm/shared/hm-markdown' +export type {MarkdownOptions} from '@shm/shared/hm-markdown' diff --git a/frontend/apps/desktop/src/app-chat.ts b/frontend/apps/desktop/src/app-chat.ts index c042b9f28..e5597f613 100644 --- a/frontend/apps/desktop/src/app-chat.ts +++ b/frontend/apps/desktop/src/app-chat.ts @@ -2,6 +2,7 @@ import {createAnthropic} from '@ai-sdk/anthropic' import {createGoogleGenerativeAI} from '@ai-sdk/google' import {createOpenAI} from '@ai-sdk/openai' import {HMBlockNode, HMComment, HMCommentSchema} from '@seed-hypermedia/client/hm-types' +import {documentToMarkdown} from '@shm/shared/hm-markdown' import {extractViewTermFromUrl, unpackHmId} from '@shm/shared/utils/entity-id-url' import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api' import {jsonSchema, stepCountIs, streamText, type ModelMessage} from 'ai' @@ -206,10 +207,15 @@ function parseDocumentUrl(url: string) { const id = unpackHmId(cleanUrl) if (!id) return null + const panel = new URLSearchParams(cleanUrl.split('?')[1]?.split('#')[0] || '').get('panel') + const panelCommentId = + panel?.startsWith('comments/') || panel?.startsWith('comment/') ? panel.replace(/^comments?\//, '') : undefined + const panelActivityFilter = panel?.startsWith('activity/') ? panel.slice('activity/'.length) : undefined + return { id, - viewTerm: extracted.viewTerm ? extracted.viewTerm.slice(1) : null, - viewArg: extracted.commentId || extracted.activityFilter || extracted.accountUid, + viewTerm: extracted.viewTerm ? extracted.viewTerm.slice(1) : panelCommentId ? 'comments' : null, + viewArg: extracted.commentId || panelCommentId || extracted.activityFilter || panelActivityFilter || extracted.accountUid, } } @@ -425,21 +431,58 @@ function summarizeToolOutput(output: unknown): string { // Read handlers for each view type -async function readDocument(id: ReturnType) { - if (!id) return 'Error: invalid document ID' - const resource = await desktopRequest('Resource', id) - if (resource.type !== 'document' || !resource.document) { - return 'Error: resource is not a document' +async function formatCommentResource(comment: HMComment) { + const names = await resolveAccountNames([comment.author].filter(Boolean) as string[]) + const fakeDoc = { + content: comment.content, + metadata: {}, + version: comment.version, + authors: [comment.author], } - const doc = resource.document - const title = doc.metadata?.name || 'Untitled' - const content = doc.content?.length ? blockNodesToMarkdown(doc.content) : '(empty document)' + const content = await documentToMarkdown(fakeDoc as any) + const replyInfo = comment.replyParent ? ` (reply to ${comment.replyParent})` : '' + const targetPath = comment.targetPath || '' + const targetUrl = `hm://${comment.targetAccount}${targetPath}` + const targetTitle = await readResourceTitle(unpackHmId(targetUrl)) + return { - title, - markdown: `# ${title}\n\n${content}`, + title: targetTitle, + markdown: [ + `## Comment`, + '', + `**ID:** ${comment.id}`, + `**Version:** ${comment.version}`, + `**Author:** ${displayName(names, comment.author)}${replyInfo}`, + `**Target:** ${targetTitle ? `${targetTitle} (${targetUrl})` : targetUrl}`, + '', + content || '(empty comment)', + ].join('\n'), + commentAuthorName: displayName(names, comment.author), + targetUrl, } } +async function readResource(id: ReturnType) { + if (!id) return 'Error: invalid resource ID' + const resource = await desktopRequest('Resource', id) + if (resource.type === 'document' && resource.document) { + const doc = resource.document + const title = doc.metadata?.name || 'Untitled' + return { + type: 'document' as const, + title, + markdown: await documentToMarkdown(doc), + } + } + if (resource.type === 'comment' && resource.comment) { + return { + type: 'comment' as const, + ...(await formatCommentResource(resource.comment)), + } + } + return `Error: resource is ${resource.type || 'not readable'}` +} + async function readComments(id: ReturnType, commentId?: string) { if (!id) return 'Error: invalid document ID' const res = await grpcClient.comments.listComments({ @@ -626,13 +669,14 @@ const chatTools: Record = { }, read: { description: - 'Read a Hypermedia document, its comments, directory listing, version history, citations, or collaborators.', + 'Read a Hypermedia URL: a document, comment URL, comments view, directory listing, version history, citations, or collaborators.', inputSchema: jsonSchema({ type: 'object', properties: { url: { type: 'string', - description: 'The hm:// URL to read', + description: + 'The hm:// URL to read. Supports view suffixes like /:comments and comment URLs like /:comments/ or ?panel=comments/.', }, }, required: ['url'], @@ -645,10 +689,29 @@ const chatTools: Record = { return createToolErrorOutput(`Error: Could not parse URL "${url}". Use an hm:// URL.`, {resourceUrl: url}) } const {id, viewTerm, viewArg} = parsed + const selectedCommentId = viewArg + const effectiveViewTerm = selectedCommentId && (!viewTerm || viewTerm === 'comments') ? 'comments' : viewTerm const [resourceTitle, siteName] = await Promise.all([readResourceTitle(id), readSiteName(id.uid)]) - switch (viewTerm) { + switch (effectiveViewTerm) { case 'comments': { - const commentsResult = await readComments(id, viewArg) + if (selectedCommentId) { + const commentResult = await readResource(unpackHmId(`hm://${selectedCommentId}`)) + if (typeof commentResult === 'string') { + return createToolErrorOutput(commentResult, {resourceUrl: url}) + } + if (commentResult.type !== 'comment') { + return createToolErrorOutput('Error: comment URL did not resolve to a comment.', {resourceUrl: url}) + } + + return createReadToolOutput({ + url, + view: 'comments', + markdown: commentResult.markdown, + title: commentResult.title || resourceTitle, + displayLabel: `Comment by ${commentResult.commentAuthorName}`, + }) + } + const commentsResult = await readComments(id, selectedCommentId) if (typeof commentsResult === 'string') { return createToolErrorOutput(commentsResult, {resourceUrl: url}) } @@ -707,16 +770,20 @@ const chatTools: Record = { displayLabel: formatDocumentDisplayLabel(resourceTitle, siteName), }) default: { - const documentResult = await readDocument(id) - if (typeof documentResult === 'string') { - return createToolErrorOutput(documentResult, {resourceUrl: url}) + const resourceResult = await readResource(id) + if (typeof resourceResult === 'string') { + return createToolErrorOutput(resourceResult, {resourceUrl: url}) } + const displayLabel = + resourceResult.type === 'comment' + ? `Comment by ${resourceResult.commentAuthorName}` + : formatDocumentDisplayLabel(resourceResult.title, siteName) return createReadToolOutput({ url, - view: 'document', - markdown: documentResult.markdown, - title: documentResult.title, - displayLabel: formatDocumentDisplayLabel(documentResult.title, siteName), + view: resourceResult.type === 'comment' ? 'comments' : 'document', + markdown: resourceResult.markdown, + title: resourceResult.title, + displayLabel, }) } } diff --git a/frontend/packages/shared/src/hm-markdown.ts b/frontend/packages/shared/src/hm-markdown.ts new file mode 100644 index 000000000..be460e612 --- /dev/null +++ b/frontend/packages/shared/src/hm-markdown.ts @@ -0,0 +1,487 @@ +/** + * CLI markdown formatter with network resolution support. + * + * This is a thin wrapper around @seed-hypermedia/client's pure + * `blocksToMarkdown()` that adds optional resolution of embeds, + * mentions, and queries via a SeedClient. + * + * For non-resolved output, use `blocksToMarkdown()` from the client + * SDK directly. + */ + +import type {HMBlockNode, HMBlock, HMAnnotation, HMDocument, HMMetadata} from '@seed-hypermedia/client/hm-types' +import type {SeedClient} from '@seed-hypermedia/client' +import {emitFrontmatter} from '@seed-hypermedia/client' +import {unpackHmId} from './utils/entity-id-url' + +export type MarkdownOptions = { + resolve?: boolean // Enable automatic resolution of embeds/mentions/queries + client?: SeedClient // Required if resolve is true + maxDepth?: number // Max embed recursion depth (default 2) +} + +// Re-export the pure conversion for non-resolved use +export {blocksToMarkdown} from '@seed-hypermedia/client' + +// ── Main entry point ───────────────────────────────────────────────────────── + +/** + * Convert a document to markdown with frontmatter, block IDs, and + * optional resolution of embeds/mentions/queries. + * + * When `resolve` is false (default), this delegates to the pure + * `blocksToMarkdown()` from the client SDK. + * + * When `resolve` is true, embeds and queries are fetched via the + * provided SeedClient and their content is inlined. + */ +export async function documentToMarkdown(doc: HMDocument, options?: MarkdownOptions): Promise { + const resolve = options?.resolve ?? false + + // Fast path: no resolution needed → use pure sync conversion + if (!resolve || !options?.client) { + // Inline the pure conversion to avoid async overhead + const {blocksToMarkdown} = await import('@seed-hypermedia/client') + return blocksToMarkdown(doc) + } + + // Slow path: resolve embeds/mentions/queries + const lines: string[] = [] + const ctx: ResolveContext = { + client: options.client, + resolve: true, + maxDepth: options.maxDepth ?? 2, + currentDepth: 0, + cache: new Map(), + } + + // Always emit frontmatter + lines.push(emitFrontmatter(doc.metadata || {})) + + // Content blocks + for (const node of doc.content) { + const blockMd = await blockNodeToMarkdown(node, 0, ctx) + if (blockMd) { + lines.push(blockMd) + } + } + + return lines.join('\n') +} + +type ResolveContext = { + client: SeedClient + resolve: boolean + maxDepth: number + currentDepth: number + cache: Map +} + +// ── Block ID helpers ───────────────────────────────────────────────────────── + +function idComment(id: string): string { + return `` +} + +function appendIdToFirstLine(md: string, id: string): string { + const newline = md.indexOf('\n') + if (newline === -1) { + return md ? `${md} ${idComment(id)}` : idComment(id) + } + return `${md.slice(0, newline)} ${idComment(id)}${md.slice(newline)}` +} + +// ── Block rendering with resolution ────────────────────────────────────────── + +async function blockNodeToMarkdown(node: HMBlockNode, depth: number, ctx: ResolveContext): Promise { + const block = node.block + const children = node.children || [] + const childrenType = (block as {attributes?: {childrenType?: string}}).attributes?.childrenType + + const isListContainer = + block.type === 'Paragraph' && + !block.text && + (childrenType === 'Ordered' || childrenType === 'Unordered' || childrenType === 'Blockquote') + + let result: string + + if (isListContainer) { + result = idComment(block.id) + } else { + result = await blockToMarkdown(block, depth, ctx) + } + + for (const child of children) { + const childMd = await blockNodeToMarkdown(child, depth + 1, ctx) + if (childMd) { + if (childrenType === 'Ordered') { + result += '\n' + indent(depth + 1) + '1. ' + childMd.trim() + } else if (childrenType === 'Unordered') { + result += '\n' + indent(depth + 1) + '- ' + childMd.trim() + } else if (childrenType === 'Blockquote') { + result += '\n' + indent(depth + 1) + '> ' + childMd.trim() + } else { + result += '\n' + childMd + } + } + } + + return result +} + +async function blockToMarkdown(block: HMBlock, depth: number, ctx: ResolveContext): Promise { + const ind = indent(depth) + const b = block as { + type: string + id: string + text?: string + link?: string + annotations?: HMAnnotation[] + attributes?: Record + } + const text = b.text || '' + const link = b.link || '' + const annotations = b.annotations + const id = b.id + + switch (block.type) { + case 'Paragraph': { + const rendered = ind + (await applyAnnotations(text, annotations, ctx)) + return appendIdToFirstLine(rendered, id) + } + + case 'Heading': { + const level = Math.min(depth + 1, 6) + const hashes = '#'.repeat(level) + const rendered = `${hashes} ${await applyAnnotations(text, annotations, ctx)}` + return appendIdToFirstLine(rendered, id) + } + + case 'Code': { + const lang = (b.attributes?.language as string) || '' + return ind + '```' + lang + ' ' + idComment(id) + '\n' + ind + text + '\n' + ind + '```' + } + + case 'Math': { + return ind + '$$ ' + idComment(id) + '\n' + ind + text + '\n' + ind + '$$' + } + + case 'Image': { + const altText = text || 'image' + const imgUrl = formatMediaUrl(link) + return ind + `![${altText}](${imgUrl}) ${idComment(id)}` + } + + case 'Video': { + const videoUrl = formatMediaUrl(link) + return ind + `[Video](${videoUrl}) ${idComment(id)}` + } + + case 'File': { + const fileName = (b.attributes?.name as string) || 'file' + const fileUrl = formatMediaUrl(link) + return ind + `[${fileName}](${fileUrl}) ${idComment(id)}` + } + + case 'Embed': { + const rendered = await resolveBlockEmbed(block, depth, ctx) + return appendIdToFirstLine(rendered, id) + } + + case 'WebEmbed': { + return ind + `[Web Embed](${link}) ${idComment(id)}` + } + + case 'Button': { + const buttonText = text || 'Button' + return ind + `[${buttonText}](${link}) ${idComment(id)}` + } + + case 'Query': { + const rendered = await resolveQuery(block, depth, ctx) + return appendIdToFirstLine(rendered, id) + } + + case 'Nostr': { + return ind + `[Nostr: ${link}](${link}) ${idComment(id)}` + } + + default: { + if (text) { + return appendIdToFirstLine(ind + text, id) + } + return '' + } + } +} + +// ── Embed/query resolution ─────────────────────────────────────────────────── + +async function resolveBlockEmbed(block: HMBlock, depth: number, ctx: ResolveContext): Promise { + const ind = indent(depth) + const link = (block as {link?: string}).link || '' + + if (ctx.currentDepth >= ctx.maxDepth) { + return ind + `> [Embed: ${link}](${link})` + } + + try { + const unpacked = unpackHmId(link) + if (!unpacked) { + return ind + `> [Embed: ${link}](${link})` + } + + const cacheKey = link + let cached = ctx.cache.get(cacheKey) + + if (!cached) { + const result = await ctx.client.request('Resource', unpacked) + if (result.type === 'document') { + cached = { + name: result.document.metadata?.name, + content: result.document.content, + } + ctx.cache.set(cacheKey, cached) + } else { + return ind + `> [Embed: ${link}](${link})` + } + } + + let contentToRender = cached.content + if (unpacked.blockRef && cached.content) { + const targetBlock = findBlockById(cached.content, unpacked.blockRef as string) + if (targetBlock) { + contentToRender = [targetBlock] + } + } + + if (contentToRender && contentToRender.length > 0) { + const nestedCtx = {...ctx, currentDepth: ctx.currentDepth + 1} + const lines: string[] = [] + + if (cached.name && !unpacked.blockRef) { + lines.push(ind + `> **[${cached.name}](${link})**`) + } + + for (const node of contentToRender) { + const blockMd = await blockNodeToMarkdown(node, depth, nestedCtx) + if (blockMd) { + lines.push( + blockMd + .split('\n') + .map((l) => ind + '> ' + l.trim()) + .join('\n'), + ) + } + } + + return lines.join('\n') + } + + return ind + `> [Embed: ${link}](${link})` + } catch { + return ind + `> [Embed: ${link}](${link})` + } +} + +async function resolveQuery(block: HMBlock, depth: number, ctx: ResolveContext): Promise { + const ind = indent(depth) + + try { + type SortTerm = 'Path' | 'Title' | 'CreateTime' | 'UpdateTime' | 'DisplayTime' + const attrs = (block as {attributes?: Record}).attributes + const queryConfig = attrs?.query as + | { + includes?: Array<{space: string; path?: string; mode?: string}> + sort?: Array<{term: SortTerm; reverse?: boolean}> + limit?: number + } + | undefined + + let includes: Array<{space: string; path?: string; mode: 'Children' | 'AllDescendants'}> + let sort: Array<{term: SortTerm; reverse: boolean}> | undefined + let limit: number | undefined + + if (queryConfig?.includes) { + includes = queryConfig.includes.map((inc) => ({ + space: inc.space, + path: inc.path, + mode: (inc.mode as 'Children' | 'AllDescendants') || 'Children', + })) + sort = queryConfig.sort?.map((s) => ({term: s.term, reverse: s.reverse ?? false})) + limit = queryConfig.limit + } else { + const space = (attrs?.space as string) || '' + if (!space) { + return ind + `` + } + includes = [ + { + space, + path: attrs?.path as string | undefined, + mode: (attrs?.mode as 'Children' | 'AllDescendants') || 'Children', + }, + ] + } + + const results = await ctx.client.request('Query', { + includes, + sort: sort || [{term: 'UpdateTime', reverse: true}], + limit: limit || 10, + }) + + if (!results || !results.results || results.results.length === 0) { + return ind + `` + } + + const lines: string[] = [] + + for (const doc of results.results) { + const name = doc.metadata?.name || doc.id.path?.join('/') || doc.id.uid + const docId = doc.id.id + lines.push(ind + `- [${name}](${docId})`) + } + + return lines.join('\n') + } catch (e) { + return ind + `` + } +} + +// ── Annotation rendering with embed resolution ────────────────────────────── + +async function applyAnnotations( + text: string, + annotations: HMAnnotation[] | undefined, + ctx: ResolveContext, +): Promise { + if (!annotations || annotations.length === 0) { + return text + } + + type Marker = {pos: number; type: 'open' | 'close'; annotation: HMAnnotation} + const markers: Marker[] = [] + + for (const ann of annotations) { + const starts = ann.starts || [] + const ends = ann.ends || [] + + for (let i = 0; i < starts.length; i++) { + const start = starts[i] + const end = ends[i] + if (start !== undefined) { + markers.push({pos: start, type: 'open', annotation: ann}) + } + if (end !== undefined) { + markers.push({pos: end, type: 'close', annotation: ann}) + } + } + } + + markers.sort((a, b) => { + if (a.pos !== b.pos) return a.pos - b.pos + return a.type === 'open' ? -1 : 1 + }) + + let result = '' + let lastPos = 0 + + for (const marker of markers) { + result += text.slice(lastPos, marker.pos) + lastPos = marker.pos + result += await getAnnotationMarker(marker.annotation, marker.type, ctx) + } + + result += text.slice(lastPos) + result = result.replace(/\uFFFC/g, '') + + return result +} + +async function getAnnotationMarker(ann: HMAnnotation, type: 'open' | 'close', ctx: ResolveContext): Promise { + switch (ann.type) { + case 'Bold': + return '**' + case 'Italic': + return '_' + case 'Strike': + return '~~' + case 'Code': + return '`' + case 'Underline': + return type === 'open' ? '' : '' + case 'Link': + if (type === 'open') { + return '[' + } else { + return `](${ann.link || ''})` + } + case 'Embed': + return await resolveInlineEmbed(ann, type, ctx) + default: + return '' + } +} + +async function resolveInlineEmbed(ann: HMAnnotation, type: 'open' | 'close', ctx: ResolveContext): Promise { + const link = 'link' in ann ? (ann.link as string) || '' : '' + + if (type === 'open') { + if (link) { + try { + const cacheKey = link + let cached = ctx.cache.get(cacheKey) + + if (!cached) { + const unpacked = unpackHmId(link) + if (unpacked) { + const result = await ctx.client.request('ResourceMetadata', unpacked) + cached = {name: result.metadata?.name} + ctx.cache.set(cacheKey, cached) + } + } + + if (cached?.name) { + return `[@${cached.name}` + } + } catch { + // Fall through to default + } + } + + const pathPart = link.includes('/') ? link.split('/').pop() || 'embed' : link.replace('hm://', '').slice(0, 12) + return `[↗ ${pathPart}` + } else { + return `](${link})` + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function findBlockById(content: HMBlockNode[], blockId: string): HMBlockNode | null { + for (const node of content) { + if (node.block.id === blockId) { + return node + } + if (node.children) { + const found = findBlockById(node.children, blockId) + if (found) return found + } + } + return null +} + +function formatMediaUrl(url: string): string { + if (url.startsWith('ipfs://')) { + const cid = url.slice(7) + return `https://ipfs.io/ipfs/${cid}` + } + return url +} + +function indent(depth: number): string { + return ' '.repeat(depth) +} + +// Re-export types for backward compatibility +export type {HMDocument, HMMetadata}