Hosted onifebitcoin.orgvia theHypermedia Protocol

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

  1. Link Type: Embed block with Card view

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

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

  4. Nested levels: Only add link to immediate parent

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

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

  7. Skip conditions:

    • Link to child already exists in parent

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

  8. Duplicates: Skip adding if link already exists

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

  10. 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:

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

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

  3. 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

  4. 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:

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

  2. Create new embed block in editor format:

    {
      id: nanoid(10),
      type: 'embed',
      props: {
        url: packHmId(childId),
        view: 'Card',
        defaultOpen: 'false'
      },
      content: [],
      children: []
    }
    
  3. Append block to end of draft.content

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

  5. 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:

  1. Find the last root-level block in parent document

  2. Generate new block ID

  3. Create DocumentChange operations in correct order:

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

    • ReplaceBlock to create the embed block

  4. 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:

  1. 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)

  2. 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

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

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

  3. ✅ Link already exists → skip adding

  4. ✅ Parent has Query block to itself → skip adding

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

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

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

  8. ✅ Draft discard edge case → TODO note added

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

Unsubscribe anytime