syntaxai/tdd.md · main · src / b51_render_edit.ts
// 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,
});
};