Skip to the content

Capsid menu

app/routes/admin.posts.$slug.edit.tsx

312 lines and 16 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of admin.posts.$slug.edit.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta40 to 421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
loader44 to 1043No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
settled48 to 551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:50>50 to 501No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:51>51 to 541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:59>59 to 591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:60>60 to 601No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:62>62 to 631No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:78>78 to 791No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:94>94 to 941No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
action106 to 19117No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
headAfterProblem114 to 1181No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:115>115 to 1182No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
createdBy126 to 1261No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
EditPost193 to 33819No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:324>324 to 3331No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/admin/confirm-dialog.tsx, app/components/admin/post-editor.tsx, app/components/admin/preview-links.tsx, app/lib/admin-actor.server.ts, app/lib/content/slug.mjs, app/lib/context.ts, app/lib/destructive.mjs, app/lib/editor/action.server.ts, app/lib/editor/delete-left.mjs, app/lib/editor/feedback.ts, app/lib/editor/frontmatter.ts, app/lib/editor/github.server.ts, app/lib/editor/head.server.ts, app/lib/editor/link-targets.server.ts, app/lib/editor/preview-link-action.server.ts, app/lib/editor/publish.server.ts, app/lib/error-message.mjs, app/lib/preview-links.server.ts, app/lib/preview-token.mjs, app/lib/timing.ts. No module of this repo imports it.

Source

import { Form, Link, data, redirect } from "react-router";

import { timed, timingsContext } from "~/lib/timing";

import { Alert } from "capsomer/react/banner";

import { ConfirmDialog } from "~/components/admin/confirm-dialog";
import { PostEditor } from "~/components/admin/post-editor";
import {
  CREATE_FORM_ID,
  PreviewLinks,
  revokeFormId,
  type PreviewLinkView,
} from "~/components/admin/preview-links";
import { adminActorContext, adminSessionContext } from "~/lib/admin-actor.server";
import { getEnv } from "~/lib/context";
import { listPreviewLinks } from "~/lib/preview-links.server";
import { previewUrl } from "~/lib/preview-token.mjs";
import { loadEditorOptions } from "~/lib/editor/link-targets.server";
import { handleEditorAction } from "~/lib/editor/action.server";
import { previewLinkAction } from "~/lib/editor/preview-link-action.server";
import { CONFIRM_FIELD, confirmationSatisfied } from "~/lib/destructive.mjs";
import { feedbackFromSearch, savedRedirectPath } from "~/lib/editor/feedback";
import { parsePost } from "~/lib/editor/frontmatter";
import { stateOf } from "capsomer/publish-transition";
import { currentHead, deletePost, EditorError, GitHubError } from "~/lib/editor/publish.server";
import { readHead } from "~/lib/editor/head.server";
import { postPath } from "~/lib/content/slug.mjs";
import { DELETE_LEFT_PARAM } from "~/lib/editor/delete-left.mjs";
import { listCommitsForPath, readFile } from "~/lib/editor/github.server";
import type { Route } from "./+types/admin.posts.$slug.edit";
import { errorMessage } from "~/lib/error-message.mjs";

/*
 * A handle, not a loader field: the flag other readers use reflects what is saved, and the
 * author is typing something that has not been.
 */
export const handle = { math: true };

export function meta({ params }: Route.MetaArgs) {
  return [{ title: `Edit ${params.slug} ยท Admin` }, { name: "robots", content: "noindex" }];
}

export async function loader({ params, request, context }: Route.LoaderArgs) {
  const timings = context.get(timingsContext).timings;
  const env = getEnv(context);
  /* Each read below is non-fatal, so an outage cannot blank the editor, but its failure is shown, never an empty list. */
  const settled = <T,>(what: string, read: Promise<T[]>) =>
    read.then(
      (value) => ({ value, error: null as string | null }),
      (error: unknown) => {
        console.error(JSON.stringify({ alert: "editor-read-failed", what, detail: errorMessage(error) }));
        return { value: [] as T[], error: errorMessage(error) };
      },
    );

  /* The reads below do not depend on the file, only the preview links do (they wait on the post's state), so they start
     with it and run together: the page takes as long as the slowest GitHub call, not the sum of all of them. */
  const filePromise = timed(timings, "gh_read_file", () => readFile(env, postPath(params.slug)));
  const headPromise = timed(timings, "gh_head", () => readHead(env));
  const optionsPromise = loadEditorOptions(env, timings);
  const revisionsPromise = timed(timings, "gh_commits", () =>
    settled("revisions", listCommitsForPath(env, postPath(params.slug))),
  );
  const file = await filePromise;
  if (!file) {
    /* The others have nothing to show for a missing post; let them finish rather than leave them floating. */
    await Promise.allSettled([headPromise, optionsPromise, revisionsPromise]);
    throw data("Not found", { status: 404 });
  }

  const fields = parsePost(file.content);
  const state = stateOf(fields, Date.now());
  const origin = new URL(request.url).origin;

  const links =
    state === "draft"
      ? await timed(timings, "kv_preview_links", () =>
          settled("preview links", listPreviewLinks(env, params.slug)),
        )
      : { value: [], error: null };

  const [{ headSha, headError }, options, revisions] = await Promise.all([headPromise, optionsPromise, revisionsPromise]);

  return {
    fields,
    headSha,
    headError,
    slug: params.slug,
    saved: feedbackFromSearch(new URL(request.url).searchParams, params.slug),
    // Derived here because `stateOf` reads the clock: recomputed in the component, a post scheduled
    // seconds away would render one word on the server and hydrate another.
    state,
    previewLinks: links.value.map((link) => ({ ...link, url: previewUrl(origin, link.token) })),
    previewLinksError: links.error,
    // Only `first_published` in the committed file tells a brand-new draft from a withdrawn one.
    everPublished: fields.firstPublished.trim() !== "",
    tagOptions: options.tagOptions,
    loadProblems: options.problems,
    linkTargets: options.linkTargets,
    revisions: revisions.value,
    revisionsError: revisions.error,
  };
}

export async function action({ request, params, context }: Route.ActionArgs) {
  const env = getEnv(context);
  const clone = request.clone();
  const form = await clone.formData();

  const intent = form.get("intent");

  /* After a failed action the page keeps the head it was loaded with; a failed re-read is logged, not shown as a missing token. */
  const headAfterProblem = () =>
    currentHead(env).catch((error: unknown) => {
      console.error(JSON.stringify({ alert: "editor-head-reread-failed", detail: errorMessage(error) }));
      return String(form.get("headSha") ?? "");
    });

  if (intent === "preview-link" || intent === "revoke-preview-link") {
    return previewLinkAction(env, {
      intent,
      slug: params.slug,
      origin: new URL(request.url).origin,
      form,
      createdBy: () => context.get(adminSessionContext).user.email,
      headAfterProblem,
    });
  }

  if (intent === "delete") {
    /*
     * The confirmation is checked here, not in onSubmit: with scripting off the handler never runs.
     * `expectedHeadSha` is concurrency, not consent.
     */
    const typed = String(form.get(CONFIRM_FIELD) ?? "").trim();
    if (!confirmationSatisfied(typed, 1)) {
      return { kind: "confirm-delete" as const, slug: params.slug };
    }
    /* Without the head the page was loaded at, a change made since could not be detected, so no delete. */
    const expectedHeadSha = String(form.get("headSha") ?? "");
    if (!expectedHeadSha) {
      return {
        kind: "problem" as const,
        fields: undefined,
        problem: {
          message:
            "Delete refused: this page has no repository head, so a change made since it loaded could not be detected. Reload and try again.",
          conflict: false,
          field: undefined,
          line: undefined,
        },
        headSha: await headAfterProblem(),
      };
    }
    try {
      const { purged, askRemoval } = await deletePost(env, {
        slug: params.slug,
        expectedHeadSha,
        actor: context.get(adminActorContext),
      });
      /* The delete landed, so it redirects; what it left behind travels to the list page to be said there. */
      const left = new URLSearchParams();
      if (askRemoval && !askRemoval.ok) left.append(DELETE_LEFT_PARAM, "ask");
      if (purged === false) left.append(DELETE_LEFT_PARAM, "purge");
      if (!left.has(DELETE_LEFT_PARAM)) return redirect("/admin/posts");
      left.set("deleted", params.slug);
      return redirect(`/admin/posts?${left}`);
    } catch (error) {
      const message =
        error instanceof EditorError || error instanceof GitHubError
          ? error.message
          : String(error);
      return {
        kind: "problem" as const,
        fields: undefined,
        problem: {
          message,
          conflict: error instanceof GitHubError && error.conflict,
          field: undefined,
          line: undefined,
        },
        headSha: await headAfterProblem(),
      };
    }
  }

  const result = await handleEditorAction(env, request, context.get(adminActorContext));
  if (result.kind === "saved") return redirect(savedRedirectPath(result));
  return result;
}

export default function EditPost({ loaderData, actionData }: Route.ComponentProps) {
  const problemData = actionData?.kind === "problem" ? actionData : null;
  const previewData = actionData?.kind === "preview" ? actionData : null;
  /*
   * An unconfirmed first publication re-renders around the submitted body: the confirming submit
   * posts this same form again, so it must carry what the author typed.
   */
  const confirmPublishData = actionData?.kind === "confirm-publish" ? actionData : null;
  const fields =
    problemData?.fields ?? previewData?.fields ?? confirmPublishData?.fields ?? loaderData.fields;
  const headSha =
    problemData?.headSha ??
    previewData?.headSha ??
    confirmPublishData?.headSha ??
    loaderData.headSha;

  const feedback =
    actionData?.kind === "problem"
      ? {
          state: "failed" as const,
          message: actionData.problem.message,
          conflict: Boolean(actionData.problem.conflict),
          field: actionData.problem.field,
          line: actionData.problem.line,
        }
      : actionData
        ? null
        : loaderData.saved;

  // A failed save's fields can disagree with the loader's; the transition must describe what is
  // committed, so it stays the loader's.
  const state = loaderData.state;

  const previewLinkSlot =
    state === "draft" ? (
      <PreviewLinks
        links={loaderData.previewLinks as PreviewLinkView[]}
        error={loaderData.previewLinksError}
        created={
          actionData?.kind === "preview-link"
            ? { url: actionData.url, expiresAt: actionData.expiresAt }
            : null
        }
      />
    ) : undefined;

  return (
    <>
      <PostEditor
        fields={fields}
        isNew={false}
        headSha={headSha}
        headError={headSha ? null : loaderData.headError}
        loadProblems={loaderData.loadProblems}
        previewHtml={actionData?.kind === "preview" ? actionData.previewHtml : null}
        feedback={feedback}
        state={state}
        everPublished={loaderData.everPublished}
        tagOptions={loaderData.tagOptions}
        linkTargets={loaderData.linkTargets}
        revisions={loaderData.revisions}
        awaitingPublishConfirmation={confirmPublishData !== null}
        previewLinkSlot={previewLinkSlot}
        historySlot={
          <>
            <p className="cap-muted">
              Every save is a commit. Nothing is ever rewritten, and a restore
              lands as a new commit on top.
            </p>
            {loaderData.revisionsError ? (
              <Alert tone="warn">
                The version list could not be read, so no revisions are shown: {loaderData.revisionsError}
              </Alert>
            ) : null}
            <div>
              <Link to={`/admin/posts/${loaderData.slug}/history`} className="cap-btn" data-size="sm">
                Open version history
              </Link>
            </div>
          </>
        }
        dangerSlot={
          <>
            <p className="cap-muted">
              Removes content/posts/{loaderData.slug}.md, its entry in the
              generated artifact, and its rows, in one commit.
            </p>
            {/* Associated by the `form` attribute: nested forms are invalid and the browser drops the inner one. */}
            <div>
              <button
                type="submit"
                form="delete-post"
                name="intent"
                value="delete"
                className="cap-btn"
                data-variant="danger"
              >
                Delete post
              </button>
            </div>
          </>
        }
      />

      {actionData?.kind === "confirm-delete" ? (
        <ConfirmDialog
          title={`Delete "${actionData.slug}"?`}
          body={
            <p>
              This removes the file and its rows. It is recoverable only through
              git.
            </p>
          }
          requireTyped="1"
          confirmLabel="Delete permanently"
          cancelHref={`/admin/posts/${actionData.slug}/edit`}
        >
          <input type="hidden" name="intent" value="delete" />
          <input type="hidden" name="headSha" value={headSha} />
        </ConfirmDialog>
      ) : null}

      {/* Submits no confirmation, so the action answers with the typed one above, script or not. */}
      <Form id="delete-post" method="post">
        <input type="hidden" name="headSha" value={headSha} />
      </Form>

      {/* Outside the editing form: the drawer is a `<dialog>` inside it, and a form inside a form is dropped. */}
      {state === "draft" ? (
        <>
          <Form id={CREATE_FORM_ID} method="post" />
          {loaderData.previewLinks.map((link) => (
            <Form
              key={link.token}
              id={revokeFormId(link.token)}
              method="post"
             
            >
              <input type="hidden" name="token" value={link.token} />
            </Form>
          ))}
        </>
      ) : null}
    </>
  );
}