Hosted onhoraciohdev.dev.hyper.mediavia theHypermedia Protocol

Implementation: Auto-Link Child to Parent on First Publish

Overview

When a user publishes a new document (first publish only), automatically add an Embed block (Card view) linking to the child document at the end of its immediate parent document.

Requirements

    Link Type: Embed block with Card view

    Parent always exists: If we have <account_id>/informes/foo, we must have <account_id>/informes

    Root level: Home document always exists for <account_id>/foo

    Nested levels: Only add link to immediate parent

    Link position: Absolute end of document (after all content)

    First publish only: Don't add link when editing existing documents

    Skip conditions:

      Link to child already exists in parent

      Parent has Query block that includes itself (self-referential)

    Duplicates: Skip adding if link already exists

    User consent: Add "X" button in publish popover to opt-out per parent

    Draft handling: If parent has draft, add to draft instead of publishing

Phase 1: Core Logic - Determine if Auto-Link is Needed

File: frontend/apps/desktop/src/models/documents.ts

Create a new function shouldAutoLinkToParent():

async function shouldAutoLinkToParent( childId: UnpackedHypermediaId, parentDocument: HMDocument | null ): Promise<boolean>

Logic:

    Check if this is a first publish (editId is null)

    Check if child has a parent (path length > 0, or if path is empty, parent is home document)

    Check parent document content for:

      Existing embed/link to the child → skip

      Query block that includes the child (self-referential query where space/path is empty or matches parent) → skip

    Return true if none of the skip conditions apply

Phase 2: Check for Existing Parent Draft

File: frontend/apps/desktop/src/app-drafts.ts

Add a new tRPC procedure drafts.findByEdit:

findByEdit: t.procedure .input(z.object({ editUid: z.string(), editPath: z.array(z.string()), })) .query(({input}) => { return draftIndex?.find(d => d.editUid === input.editUid && pathMatches(d.editPath || [], input.editPath) ) || null })

Phase 3: Add Link to Parent Draft (if draft exists)

File: frontend/apps/desktop/src/models/documents.ts

Create function addLinkToParentDraft():

async function addLinkToParentDraft( parentDraftId: string, childId: UnpackedHypermediaId ): Promise<void>

Logic:

    Fetch draft via client.drafts.get.query(parentDraftId)

    Create new embed block in editor format:

    { id: nanoid(10), type: 'embed', props: { url: packHmId(childId), view: 'Card', defaultOpen: 'false' }, content: [], children: [] }

    Append block to end of draft.content

    Write back via client.drafts.write.mutate({...draft, content: updatedContent})

    Cache invalidation happens automatically

Add TODO comment:

// TODO: If user discards this draft later, the auto-link will be lost. // Discuss with team whether we should warn user or handle this differently.

Phase 4: Add Link to Parent Document (if no draft exists)

File: frontend/apps/desktop/src/models/documents.ts

Create function publishLinkToParentDocument():

async function publishLinkToParentDocument( parentId: UnpackedHypermediaId, parentDocument: HMDocument, childId: UnpackedHypermediaId, signingKeyName: string ): Promise<HMDocument>

Logic:

    Find the last root-level block in parent document

    Generate new block ID

    Create DocumentChange operations in correct order:

      MoveBlock to position the new block at the end (after last block)

      ReplaceBlock to create the embed block

    Call grpcClient.documents.createDocumentChange() with:

      signingKeyName

      account: parentId.uid

      path: parentId.path

      baseVersion: parentDocument.version

      changes: [moveBlock, replaceBlock]

Document changes order:

const changes = [ { moveBlock: { blockId: generatedBlockId, parent: '', // root level leftSibling: lastBlockId, // after the last existing block } }, { replaceBlock: { block: { id: generatedBlockId, type: 'Embed', link: packHmId(childId), attributes: { view: 'Card' } } } } ]

Phase 5: Integrate into Publish Workflow

File: frontend/apps/desktop/src/components/publish-draft-button.tsx

New state:

const [parentPublishInfo, setParentPublishInfo] = useState<{ parentId: UnpackedHypermediaId hasDraft: boolean draftId?: string willAddLink: boolean optedOut: boolean } | null>(null)

On publish flow modification:

    Before publishing child, compute parentPublishInfo:

      Get parent ID from child's destination path

      Check if parent has existing draft

      Check if should auto-link (using shouldAutoLinkToParent)

    After child publishes successfully:

    if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) { if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) { await addLinkToParentDraft(parentPublishInfo.draftId, childResultId) } else { const parentDoc = await publishLinkToParentDocument( parentPublishInfo.parentId, parentDocument, childResultId, signingAccountId ) // Push parent along with child pushResource(hmId(parentDoc.account, { path: entityQueryPathToHmIdPath(parentDoc.path), version: parentDoc.version })) } }

Phase 6: UI Changes - Publish Popover

File: frontend/apps/desktop/src/components/publish-draft-button.tsx

Modify "You are publishing" section (parent first, then child):

{/* You are publishing section */} <div className="flex flex-col gap-1"> <p className="text-sm font-medium">You are publishing</p> {/* Parent document first (if auto-linking) */} {parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut && ( <PublishItem url={parentUrl} icon={<Document size={12} />} label={parentPublishInfo.hasDraft ? "(adding link to draft)" : "(adding link)"} onRemove={() => setParentPublishInfo(prev => prev ? {...prev, optedOut: true} : null )} /> )} {/* Child document (current) - always shown */} {documentUrl && ( <PublishItem url={documentUrl} icon={<Document size={12} />} /> )} </div>

New component PublishItem:

function PublishItem({ url, icon, label, onRemove }: { url: string icon: React.ReactNode label?: string onRemove?: () => void }) { return ( <div className="flex items-center gap-1 group"> <span className="shrink-0">{icon}</span> <span className="text-xs truncate" style={{direction: 'rtl', textAlign: 'left'}}> {url} </span> {label && <span className="text-xs text-muted-foreground">{label}</span>} {onRemove && ( <Button size="iconSm" variant="ghost" className="opacity-0 group-hover:opacity-100 ml-auto" onClick={onRemove} > <X size={12} /> </Button> )} </div> ) }

Phase 7: Push Workflow Updates

File: frontend/apps/desktop/src/components/publish-draft-button.tsx

In the onSuccess callback of usePublishResource:

onSuccess: async (resultDoc, input) => { if (pushOnPublish.data === 'never') return const [setPushStatus, pushStatus] = writeableStateStream<PushResourceStatus | null>(null) const childResultId = hmId(resultDoc.account, { path: entityQueryPathToHmIdPath(resultDoc.path), version: resultDoc.version, }) // Handle parent auto-link let parentResultDoc: HMDocument | null = null if (parentPublishInfo?.willAddLink && !parentPublishInfo.optedOut) { if (parentPublishInfo.hasDraft && parentPublishInfo.draftId) { // Add to draft - no push needed for parent await addLinkToParentDraft(parentPublishInfo.draftId, childResultId) } else { // Publish to parent - will need to push both parentResultDoc = await publishLinkToParentDocument(...) } } // Push child const childPushPromise = pushResource(childResultId, undefined, setPushStatus) // Push parent if we published changes to it if (parentResultDoc) { const parentResultId = hmId(parentResultDoc.account, { path: entityQueryPathToHmIdPath(parentResultDoc.path), version: parentResultDoc.version, }) pushResource(parentResultId) // Fire and forget, or chain with Promise.all } toast.promise(childPushPromise, {...}) }

Phase 8: Helper Functions

File: frontend/apps/desktop/src/models/documents.ts

Check for existing link to child:

function documentContainsLinkToChild( document: HMDocument, childId: UnpackedHypermediaId ): boolean { const childUrl = packHmId(childId) // Recursively search all blocks for embed/link to childUrl function searchBlocks(nodes: HMBlockNode[]): boolean { for (const node of nodes) { if (node.block.type === 'Embed' && node.block.link === childUrl) return true // Also check inline annotations for links if (node.block.annotations) { for (const ann of node.block.annotations) { if ((ann.type === 'Link' || ann.type === 'Embed') && ann.link === childUrl) return true } } if (node.children && searchBlocks(node.children)) return true } return false } return searchBlocks(document.content || []) }

Check for self-referential Query block:

function documentHasSelfQuery( document: HMDocument, documentId: UnpackedHypermediaId ): boolean { function searchBlocks(nodes: HMBlockNode[]): boolean { for (const node of nodes) { if (node.block.type === 'Query') { const query = node.block.attributes?.query if (query?.includes) { for (const inc of query.includes) { // Self-referential if space is empty or matches document const isSpaceMatch = !inc.space || inc.space === documentId.uid const isPathMatch = !inc.path || inc.path === hmIdPathToEntityQueryPath(documentId.path) if (isSpaceMatch && isPathMatch) return true } } } if (node.children && searchBlocks(node.children)) return true } return false } return searchBlocks(document.content || []) }

File Changes Summary

| File | Changes | |------|---------| | frontend/apps/desktop/src/app-drafts.ts | Add drafts.findByEdit procedure | | frontend/apps/desktop/src/models/documents.ts | Add helper functions: shouldAutoLinkToParent, addLinkToParentDraft, publishLinkToParentDocument, documentContainsLinkToChild, documentHasSelfQuery | | frontend/apps/desktop/src/components/publish-draft-button.tsx | Add parent tracking state, modify UI to show parent in "You are publishing" section with opt-out X button, update publish flow to handle parent auto-link |

Edge Cases Handled

    ✅ Parent has existing draft → add link to draft, don't publish parent

    ✅ Parent has no draft → publish new version of parent with link

    ✅ Link already exists → skip adding

    ✅ Parent has Query block to itself → skip adding

    ✅ User opts out via X button → skip auto-link

    ✅ First publish only → edits don't trigger auto-link

    ✅ Push workflow → push both child and parent (if parent was published)

    ✅ Draft discard edge case → TODO note added

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime