Hosted onseed.hyper.mediavia theHypermedia Protocol

Publish guard: block publishing when document content references local drafts

Context

A new editor command will let users create a draft document from inside the editor and immediately embed/link it into the current document. Because drafts are local-only (not yet shared on the network), publishing a parent document while it still contains links/embeds to local drafts produces broken references for every reader.

We need a guard that runs at publish time, detects any reference to a local draft in the current document, and blocks the publish with a clear inline message in the publish popover. The user is expected to fix the offending links manually (publish the referenced drafts first, or remove the references) and try again.

Decisions locked

Critical files

  • frontend/packages/shared/src/models/document-machine.ts — add guard, new event/context shape, branching transition.

  • frontend/packages/shared/src/content.ts — reuse extractAllContentRefs (already extracts every hm:// ref with parsed refId from blocks + annotations + children). Add a tiny helper findDraftReferences(blocks) next to it.

  • frontend/apps/desktop/src/components/editing-toolbar.tsx — at publishNow (line 397), read blocks via useEditorHandlersRef().current?.getCurrentBlocks() and include them in the publish.start event payload. Render the new error in PublishPopoverBody.

  • Tests: frontend/packages/shared/src/__tests__/content-refs.test.ts (extend), new frontend/packages/shared/src/models/__tests__/document-machine.publish-guard.test.ts for the transition, and a focused unit test for PublishPopoverBody if practical.

Implementation steps

1. Helper: findDraftReferences

In frontend/packages/shared/src/content.ts, next to extractAllContentRefs:

export function findDraftReferences(blocks: HMBlockNode[]): RefDefinition[] {
  return extractAllContentRefs(blocks).filter((ref) =>
    ref.refId.path?.some((seg) => seg.startsWith('-'))
  )
}

Rationale: extractAllContentRefs already handles every URL-bearing surface (embed, link block, button, image/video/file, query includes[].space, inline link/embed annotations) and recurses children. We only need the draft-segment filter.

2. Document machine changes (document-machine.ts)

Add to context shape (near line 85):

publishBlocked: {reason: 'draft-links'} | null

Initial value null (extend the error: null init around line 585).

Extend the publish.start event type so it carries blocks:

| {type: 'publish.start'; pathOverride?: string[]; blocks?: HMBlockNode[]}

Add a guard in setup({guards: {...}}):

hasDraftReferences: ({event}) => {
  if (event.type !== 'publish.start') return false
  return findDraftReferences(event.blocks ?? []).length > 0
}

Add a clearPublishBlocked action:

clearPublishBlocked: assign({publishBlocked: null})

Replace the publish.start transition (current line 764–768) with an ordered conditional array (XState v5 on accepts array of transitions; first matching guard wins):

'publish.start': [
  {
    guard: 'hasDraftReferences',
    actions: [assign({publishBlocked: () => ({reason: 'draft-links'})})],
    // No target — stay in editing.draft.idle, just assign context.
  },
  {
    target: '#DocumentLifecycle.publishing',
    guard: 'hasDraftId',
    actions: ['clearPublishBlocked', 'setPathOverrideFromEvent'],
  },
],

Also clear publishBlocked on change and reset.content actions in editing.draft.idle (and in editing.draft.changed) so the error disappears as soon as the user edits.

Add a dismiss event for explicit close:

'publish.blocked.dismiss': {
  actions: ['clearPublishBlocked'],
}

3. Selector

In frontend/packages/shared/src/models/document-machine.ts (or wherever selectors live — selectDraftId already exists nearby), add:

export const selectPublishBlocked = (state: DocumentMachineSnapshot) => state.context.publishBlocked

4. UI wiring (editing-toolbar.tsx)

In publishNow (line 397):

const handlersRef = useEditorHandlersRef()

const publishNow = (pathOverride?: string[]) => {
  popoverState.onOpenChange(false)
  setHasTriggeredPublish()
  send({type: 'edit.start'})
  const blocks = handlersRef.current?.getCurrentBlocks() ?? []
  send({type: 'publish.start', pathOverride, blocks})
}

Note: do NOT close the popover before sending the event. Reorder so the popover stays open if the publish is blocked:

const publishNow = (pathOverride?: string[]) => {
  setHasTriggeredPublish()
  send({type: 'edit.start'})
  const blocks = handlersRef.current?.getCurrentBlocks() ?? []
  send({type: 'publish.start', pathOverride, blocks})
  // Caller side-effect: popover stays open if context.publishBlocked got set.
}

In PublishPopoverBody (line 124), read the blocked state and render an inline error block above the publish button when publishBlocked is set. On onClose, dispatch publish.blocked.dismiss so the next popover open starts clean.

const publishBlocked = useDocumentSelector(selectPublishBlocked)
// ...
{publishBlocked?.reason === 'draft-links' && (
  <div role="alert" className="...error styling...">
    Publish the referenced drafts first before publishing this document.
  </div>
)}

Disable the publish button while publishBlocked is set, so the user must either edit content (auto-clears) or close.

5. Tests

  • content-refs.test.ts: extend with findDraftReferences cases:

    • empty content → []

    • embed link hm://uid/-abc1234567 → 1 hit

    • nested child block with embed to draft → 1 hit

    • inline embed annotation to draft → 1 hit

    • query block includes[].space = 'hm://uid/-draftSeg' → 1 hit

    • published-only refs (hm://uid/parent/child) → []

  • document-machine.publish-guard.test.ts:

    • send publish.start with draft-link blocks → stays in editing.draft.idle, publishBlocked.reason === 'draft-links'

    • send publish.start with clean blocks → transitions to publishing.inProgress, publishBlocked === null

    • send change after a block → publishBlocked === null

    • send publish.blocked.dismisspublishBlocked === null

  • PublishPopoverBody test (or a small RTL test in frontend/apps/desktop): render with mocked snapshot where publishBlocked.reason === 'draft-links' → asserts the error string is visible, publish button disabled.

Verification

  1. pnpm --filter @shm/shared test — covers helper + machine transition tests.

  2. pnpm --filter @shm/desktop test:unit — covers popover render test.

  3. pnpm typecheck — confirm new event payload + context shape propagate cleanly.

  4. Manual desktop run:

    • Start desktop: pnpm --filter @shm/desktop dev.

    • In an existing doc, create an embed pointing to a draft (path with - segment).

    • Click publish → popover shows inline error, publish button disabled.

    • Remove the offending embed → popover error clears, publish button enables.

    • Publish succeeds.

    • Edge: create a fresh doc with no draft refs → publish flow unchanged.

Unresolved / future work

  • Cascading publish (one-click publish of referenced drafts first) — explicitly out of scope.

  • Auto-remove draft links — out of scope.

  • Block-level highlighting / scroll-to — out of scope.

  • The new "create document" editor command itself (creates draft + inserts embed) — separate feature; this plan only covers the guard required to make that feature safe to publish around.

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

Unsubscribe anytime