Skip to the content

Capsid menu

components/publish-gate/publish-transition.ts

72 lines and 6 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of publish-transition.ts
FunctionLinesComplexityCoveredCRAPMutation
transitionsFor30 to 464100%OkCRAP 4No data: mutation testing has not run on this repo yet
<anonymous:57>57 to 571100%OkCRAP 1No data: mutation testing has not run on this repo yet
draftForIntent63 to 663100%OkCRAP 3No data: mutation testing has not run on this repo yet
saveInPlaceIntent69 to 742100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:72>72 to 721100%OkCRAP 1No data: mutation testing has not run on this repo yet
stateOf77 to 845100%OkCRAP 5No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. Imported by components/publish-gate/publish-gate.ts.

Source

// The publish transitions a post editor offers, and the draft flag each one asks for. Pure, with no
// DOM, so a server action imports it as `capsomer/publish-transition` beside the gate's page.
//
// The pressed button's intent carries the transition, not a hidden field: a submitter's value is the
// one part of a form sent because of what was pressed, so the buttons mean the same without script.

export type PostState = "draft" | "scheduled" | "published";

export interface Transition {
  id: string;
  label: string;
  wantsDraft: boolean;
  ceremony: boolean;
  danger: boolean;
}

const SAVE_DRAFT: Transition = { id: "save-draft", label: "Save draft", wantsDraft: true, ceremony: false, danger: false };

const SAVE_CHANGES: Transition = { id: "save", label: "Update", wantsDraft: false, ceremony: false, danger: false };

const UNPUBLISH: Transition = {
  id: "unpublish",
  label: "Revert to draft",
  wantsDraft: true,
  ceremony: false,
  // Not destructive, but it takes something off the public site.
  danger: true,
};

export function transitionsFor(state: PostState, everPublished: boolean): [Transition, ...Transition[]] {
  if (state === "draft") {
    return [
      {
        id: everPublished ? "republish" : "publish",
        label: everPublished ? "Republish" : "Publish",
        wantsDraft: false,
        // Only a never-public post gets the ceremony; asking again on republish trains click-through.
        ceremony: !everPublished,
        danger: false,
      },
      SAVE_DRAFT,
    ];
  }
  // Published and scheduled are both draft: false; publish_at is the schedule control's, not this table's.
  return [SAVE_CHANGES, UNPUBLISH];
}

/** Publish is the ask and this is the answer; both mean draft: false, only this passes the ceremony. */
export const PUBLISH_CONFIRMED_INTENT = "publish-confirmed";

/** What the first-publication confirmation says, with or without script. */
export const FIRST_PUBLICATION_NOTE = "It has never been public. Publishing puts it on the blog, in the feed, the sitemap, the search index and the AI answer layer.";

// No prototype, so an intent such as "constructor" is unknown here too, not Object.prototype's.
export const DRAFT_BY_INTENT: Readonly<Record<string, boolean>> = Object.freeze(
  Object.assign(Object.create(null) as Record<string, boolean>, Object.fromEntries([
    ...[...transitionsFor("draft", false), ...transitionsFor("draft", true), ...transitionsFor("published", true)].map((t) => [t.id, t.wantsDraft]),
    [PUBLISH_CONFIRMED_INTENT, false],
  ])),
);

/** Fails closed: an unknown intent is a draft, so the failure mode is a post that stays private. */
export function draftForIntent(intent: string | null): boolean {
  if (intent === null) return true;
  return DRAFT_BY_INTENT[intent] ?? true;
}

/** Cmd+S submits with no submitter, so it has to name the transition that keeps the current state. */
export function saveInPlaceIntent(state: PostState): string {
  const wantsDraft = state === "draft";
  // everPublished does not change which transition saves in place, so false is passed.
  const match = transitionsFor(state, false).find((t) => t.wantsDraft === wantsDraft);
  return match ? match.id : SAVE_DRAFT.id;
}

/** Takes now rather than reading the clock, so a component recomputing it cannot hydrate a different word. */
export function stateOf(fields: { draft: boolean; publishAt: string }, now: number): PostState {
  if (fields.draft) return "draft";
  if (!fields.publishAt) return "published";
  const at = Date.parse(fields.publishAt);
  // An unparseable publish_at is not a schedule; treating it as one would hide a live post.
  if (Number.isNaN(at)) return "published";
  return at > now ? "scheduled" : "published";
}