syntaxai/tdd.md · main · src / b51_render_edit.ts

b51_render_edit.ts 166 lines · 7618 bytes raw
// c51 (edit) — UI: edit-form, login-required prompt, applied-live
// success page, commit-failure page, non-admin "read-only" wall.
// Composes the docs layout's chrome via renderPage with bodyHtml so
// the form can use real <form> elements (markdown would escape them).

import {
  renderPage,
  escape,
} from "./b51_render_layout.ts";
import type { ResolvedEdit } from "./b32_edit_resolve.ts";
import type { GitCommitOk, GitCommitFailure } from "./a31_git_parse.ts";

const layoutWrap = (innerHtml: string): string =>
  `<main class="md edit-page"><div class="edit-container">${innerHtml}</div></main>`;

// Override the standard <main class="md">: the edit experience needs
// full-width form controls, not the doc-layout's three columns.
const editBodyClass = "edit-body";

const shortSha = (sha: string): string => sha.slice(0, 7);

// SAMA-native commit URL on tdd.md itself. The /GIT/ prefix routes to
// c21_handlers_commit_view which reads the data from Forgejo's API and
// renders it through tdd.md's chrome — visitor never leaves the main
// domain.
const tddCommitUrl = (sha: string): string =>
  `/GIT/tdd.md/commit/${sha}`;

// -------- /edit/:section/:slug — form for the admin --------

export const renderEditFormPage = async (
  resolved: ResolvedEdit,
  currentBody: string,
  viewer: string,
): Promise<string> => {
  const inner = `<h1>edit · ${escape(resolved.title)}</h1>
<p class="edit-meta">
  Editing <code>${escape(resolved.filePath)}</code> as <strong>${escape(viewer)}</strong>.
  Saving will commit directly to <code>syntaxai/tdd.md@main</code> on git.tdd.md
  and refresh the live page.
  <a href="${escape(resolved.pageUrl)}">view the live page</a> ·
  <a href="/auth/logout">log out</a>
</p>
<form method="post" action="/edit/${escape(resolved.section)}/${escape(resolved.slug)}" class="edit-form">
  <textarea name="body" class="edit-textarea" rows="32" spellcheck="false">${escape(currentBody)}</textarea>
  <div class="edit-actions">
    <button type="submit">save (commit + live)</button>
    <a class="edit-cancel" href="${escape(resolved.pageUrl)}">cancel</a>
  </div>
</form>
<p class="edit-note">
  This editor commits to git via Forgejo's contents API — the container has
  no <code>.git</code> directory, no SSH keys, only an HTTP token. Every save
  becomes a real commit you can review at git.tdd.md.
</p>`;
  return renderPage({
    title: `edit · ${resolved.title} — tdd.md`,
    bodyHtml: layoutWrap(inner),
    description: `Edit ${resolved.title} on tdd.md. Admin-only; saves commit directly to git.tdd.md.`,
    ogPath: `https://tdd.md/edit/${resolved.section}/${resolved.slug}`,
    noindex: true,
    bodyClass: editBodyClass,
  });
};

// -------- login wall before the form --------

export const renderEditLoginWall = async (
  resolved: ResolvedEdit,
): Promise<string> => {
  const returnTo = `/edit/${resolved.section}/${resolved.slug}`;
  const inner = `<h1>edit · ${escape(resolved.title)}</h1>
<p>To edit a page you need to sign in via GitHub. Editing is admin-only — only the site owner's GitHub account can save changes. We use GitHub for identity only; saves commit to git.tdd.md, never to GitHub.</p>
<p><a class="edit-login-button" href="/auth/github/start?to=${encodeURIComponent(returnTo)}">sign in with GitHub →</a></p>
<p class="edit-meta">If you have an edit suggestion and you're not the admin, open an issue at <a href="https://git.tdd.md/syntaxai/tdd.md/issues" rel="noopener" target="_blank">git.tdd.md/syntaxai/tdd.md/issues</a>.</p>
<p><a href="${escape(resolved.pageUrl)}">← back to the page</a></p>`;
  return renderPage({
    title: `sign in to edit · ${resolved.title} — tdd.md`,
    bodyHtml: layoutWrap(inner),
    description: `Sign in via GitHub to edit ${resolved.title} on tdd.md.`,
    noindex: true,
    bodyClass: editBodyClass,
  });
};

// -------- non-admin signed-in wall --------

export const renderEditNonAdminWall = async (
  resolved: ResolvedEdit,
  viewer: string,
): Promise<string> => {
  const inner = `<h1>edit · ${escape(resolved.title)}</h1>
<p>Signed in as <strong>${escape(viewer)}</strong>, but editing is admin-only. Only the site owner can save changes from here.</p>
<p>If you'd like to suggest an edit, open an issue at <a href="https://git.tdd.md/syntaxai/tdd.md/issues" rel="noopener" target="_blank">git.tdd.md/syntaxai/tdd.md/issues</a> describing the change.</p>
<p><a href="${escape(resolved.pageUrl)}">← back to the page</a> · <a href="/auth/logout">log out</a></p>`;
  return renderPage({
    title: `edit · ${resolved.title} — tdd.md`,
    bodyHtml: layoutWrap(inner),
    noindex: true,
    bodyClass: editBodyClass,
  });
};

// -------- admin direct-edit applied live --------

export const renderEditAppliedLive = async (
  resolved: ResolvedEdit,
  commit: GitCommitOk,
): Promise<string> => {
  const sha = commit.commitSha;
  const inner = `<h1>applied live · ${escape(resolved.title)}</h1>
<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> is now live <strong>and committed</strong>.</p>
<p class="edit-meta">
  Commit <a href="${escape(tddCommitUrl(sha))}"><code>${escape(shortSha(sha))}</code></a>
  landed in the local bare repo (<code>/app/repo</code> in the container,
  <code>~/repos/tdd.md.git</code> on p620) via <code>git</code> plumbing.
  No HTTP, no Forgejo, no SSH involved — just a real git commit on disk.
</p>
<p class="edit-note">
  The container's <code>content/</code> dir is copied from the working
  tree at image build, and the next deploy fetches new commits from the
  local bare repo before rebuilding — so this commit will outlive any
  container restart.
</p>
<p><a href="${escape(resolved.pageUrl)}">→ view the live page</a> · <a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">edit again</a></p>`;
  return renderPage({
    title: `applied · ${resolved.title} — tdd.md`,
    bodyHtml: layoutWrap(inner),
    noindex: true,
    bodyClass: editBodyClass,
  });
};

// -------- admin commit failed (Forgejo conflict / network / other) --------

export const renderEditCommitFailed = async (
  resolved: ResolvedEdit,
  failure: GitCommitFailure,
): Promise<string> => {
  const explanation =
    failure.kind === "conflict"
      ? "The branch tip moved while you were editing — someone else committed in between. Refresh the editor to load the latest version, then re-apply your change."
      : failure.kind === "permission"
      ? "The container can't write to the bare repo. Check that /home/scri/repos/tdd.md.git on p620 is mounted read-write into /app/repo."
      : failure.kind === "not_found"
      ? "The 'main' branch doesn't exist in the bare repo. Verify that ~/repos/tdd.md.git on p620 has a refs/heads/main."
      : "git rejected the commit for an unexpected reason. See the message below.";
  const inner = `<h1>commit failed · ${escape(resolved.title)}</h1>
<p>Your edit to <a href="${escape(resolved.pageUrl)}"><code>${escape(resolved.pageUrl)}</code></a> was <strong>not applied</strong>. The live page is unchanged.</p>
<p class="edit-meta">
  git returned <strong>${escape(failure.kind)}</strong>.
</p>
<p>${escape(explanation)}</p>
<details class="edit-note">
  <summary>git stderr</summary>
  <pre><code>${escape(failure.message.slice(0, 2000))}</code></pre>
</details>
<p><a href="/edit/${escape(resolved.section)}/${escape(resolved.slug)}">← back to the editor (refreshes the form)</a></p>`;
  return renderPage({
    title: `commit failed · ${resolved.title} — tdd.md`,
    bodyHtml: layoutWrap(inner),
    noindex: true,
    bodyClass: editBodyClass,
  });
};