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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| transitionsFor | 30 to 46 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| <anonymous:57> | 57 to 57 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| draftForIntent | 63 to 66 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| saveInPlaceIntent | 69 to 74 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:72> | 72 to 72 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| stateOf | 77 to 84 | 5 | 100% | OkCRAP 5 | No 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";
}