Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

wikis.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

wikis.tsxBlame1501 lines · 2 contributors
1e162a8Claude1/**
2 * Block E3 — Wikis: per-repo markdown page collection with revision history.
3 *
4 * v1 is DB-backed (no git bare repo). Each wiki_pages row holds the current
5 * title+body+revision counter; every edit appends a wiki_revisions row for
6 * history/diff/revert.
7 *
a1be1e1Claude8 * 2026 polish:
9 * - Scoped `.wiki-*` CSS (no bleed into RepoHeader).
10 * - Eyebrow + display headline + subtitle below the repo header.
11 * - Sidebar polished as page cards w/ last-edited and edit affordance.
12 * - New / Edit forms get the focus-ring + gradient submit treatment.
13 * - Dashed empty state with orb + CTA when there are no pages.
14 *
15 * Never throws. Every route + form action + POST handler is preserved.
1e162a8Claude16 */
17
18import { Hono } from "hono";
19import { and, eq, desc } from "drizzle-orm";
20import { db } from "../db";
21import {
22 wikiPages,
23 wikiRevisions,
24 repositories,
25 users,
26} from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader } from "../views/components";
29import { renderMarkdown } from "../lib/markdown";
a1be1e1Claude30import { formatRelative } from "../views/ui";
1e162a8Claude31import { softAuth, requireAuth } from "../middleware/auth";
32import type { AuthEnv } from "../middleware/auth";
33
34/** lowercase-alphanumerics joined by single dashes, trimmed. */
35export function slugifyTitle(title: string): string {
36 return title
37 .toLowerCase()
38 .normalize("NFKD")
39 .replace(/[^a-z0-9\s-]/g, "")
40 .replace(/\s+/g, "-")
41 .replace(/-+/g, "-")
42 .replace(/^-+|-+$/g, "");
43}
44
45const wikiRoutes = new Hono<AuthEnv>();
46
a1be1e1Claude47// ─── Scoped CSS (.wiki-*) ────────────────────────────────────────────────
48const wikiStyles = `
eed4684Claude49 .wiki-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
a1be1e1Claude50
51 .wiki-head {
52 margin-bottom: var(--space-5);
53 display: flex;
54 align-items: flex-end;
55 justify-content: space-between;
56 gap: var(--space-4);
57 flex-wrap: wrap;
58 }
59 .wiki-head-text { flex: 1; min-width: 280px; }
60 .wiki-eyebrow {
61 display: inline-flex;
62 align-items: center;
63 gap: 8px;
64 text-transform: uppercase;
65 font-family: var(--font-mono);
66 font-size: 11px;
67 letter-spacing: 0.16em;
68 color: var(--text-muted);
69 font-weight: 600;
70 margin-bottom: 10px;
71 }
72 .wiki-eyebrow-dot {
73 width: 8px; height: 8px;
74 border-radius: 9999px;
6fd5915Claude75 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
76 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
a1be1e1Claude77 }
78 .wiki-title {
79 font-family: var(--font-display);
80 font-size: clamp(22px, 3.2vw, 34px);
81 font-weight: 800;
82 letter-spacing: -0.028em;
83 line-height: 1.1;
84 margin: 0 0 6px;
85 color: var(--text-strong);
86 }
87 .wiki-title-grad {
6fd5915Claude88 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
a1be1e1Claude89 -webkit-background-clip: text;
90 background-clip: text;
91 -webkit-text-fill-color: transparent;
92 color: transparent;
93 }
94 .wiki-sub {
95 margin: 0;
96 font-size: 14px;
97 color: var(--text-muted);
98 line-height: 1.5;
99 max-width: 720px;
100 }
101
102 .wiki-btn {
103 display: inline-flex;
104 align-items: center;
105 justify-content: center;
106 gap: 6px;
107 padding: 9px 16px;
108 border-radius: 10px;
109 font-size: 13px;
110 font-weight: 600;
111 text-decoration: none;
112 border: 1px solid transparent;
113 cursor: pointer;
114 font: inherit;
115 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
116 line-height: 1;
117 white-space: nowrap;
118 }
119 .wiki-btn-primary {
6fd5915Claude120 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
a1be1e1Claude121 color: #ffffff;
6fd5915Claude122 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
a1be1e1Claude123 }
124 .wiki-btn-primary:hover {
125 transform: translateY(-1px);
6fd5915Claude126 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
a1be1e1Claude127 text-decoration: none;
128 color: #ffffff;
129 }
130 .wiki-btn-ghost {
131 background: transparent;
132 color: var(--text);
133 border-color: var(--border-strong);
134 }
135 .wiki-btn-ghost:hover {
6fd5915Claude136 background: rgba(91,110,232,0.06);
137 border-color: rgba(91,110,232,0.45);
a1be1e1Claude138 color: var(--text-strong);
139 text-decoration: none;
140 }
141 .wiki-btn-danger {
142 background: transparent;
e589f77ccantynz-alt143 color: var(--red);
a1be1e1Claude144 border-color: rgba(248,113,113,0.35);
145 }
146 .wiki-btn-danger:hover {
147 border-style: dashed;
148 border-color: rgba(248,113,113,0.70);
149 background: rgba(248,113,113,0.06);
150 color: #fecaca;
151 text-decoration: none;
152 }
153
154 /* ─── Layout ─── */
155 .wiki-grid {
156 display: grid;
157 grid-template-columns: 260px 1fr;
158 gap: var(--space-5);
159 align-items: start;
160 }
161 @media (max-width: 820px) {
162 .wiki-grid { grid-template-columns: 1fr; }
163 }
164
165 /* ─── Sidebar ─── */
166 .wiki-side {
167 position: relative;
168 background: var(--bg-elevated);
169 border: 1px solid var(--border);
170 border-radius: 14px;
171 padding: var(--space-3);
172 overflow: hidden;
173 }
174 .wiki-side::before {
175 content: '';
176 position: absolute;
177 top: 0; left: 0; right: 0;
178 height: 1.5px;
6fd5915Claude179 background: linear-gradient(90deg, transparent 0%, #5b6ee8 50%, #5f8fa0 100%);
a1be1e1Claude180 opacity: 0.40;
181 pointer-events: none;
182 }
183 .wiki-side-title {
184 font-family: var(--font-display);
185 font-size: 12.5px;
186 font-weight: 700;
187 color: var(--text-muted);
188 text-transform: uppercase;
189 letter-spacing: 0.08em;
190 margin: 6px 8px 10px;
191 }
192 .wiki-side-list {
193 list-style: none;
194 padding: 0;
195 margin: 0;
196 display: flex;
197 flex-direction: column;
198 gap: 4px;
199 }
200 .wiki-side-link {
201 display: flex;
202 align-items: center;
203 gap: 8px;
204 padding: 8px 10px;
205 border-radius: 8px;
206 color: var(--text);
207 text-decoration: none;
208 font-size: 13px;
209 transition: background 120ms ease, color 120ms ease;
210 }
211 .wiki-side-link::before {
212 content: '';
213 width: 5px; height: 5px;
214 border-radius: 9999px;
215 background: var(--text-muted);
216 opacity: 0.5;
217 flex-shrink: 0;
218 }
219 .wiki-side-link:hover {
6fd5915Claude220 background: rgba(91,110,232,0.08);
a1be1e1Claude221 color: var(--text-strong);
222 text-decoration: none;
223 }
224 .wiki-side-empty {
225 padding: 12px;
226 font-size: 12px;
227 color: var(--text-muted);
228 text-align: center;
229 }
230 .wiki-side-foot {
231 margin-top: 10px;
232 padding-top: 10px;
233 border-top: 1px dashed var(--border-strong);
234 }
235 .wiki-side-foot .wiki-btn { width: 100%; }
236
237 /* ─── Main content ─── */
238 .wiki-main { min-width: 0; }
239 .wiki-page-head {
240 display: flex;
241 align-items: center;
242 justify-content: space-between;
243 gap: var(--space-3);
244 flex-wrap: wrap;
245 margin-bottom: var(--space-4);
246 }
247 .wiki-page-title {
248 font-family: var(--font-display);
249 font-size: clamp(22px, 3vw, 32px);
250 font-weight: 800;
251 letter-spacing: -0.024em;
252 color: var(--text-strong);
253 margin: 0;
254 }
255 .wiki-page-actions { display: flex; gap: 6px; flex-wrap: wrap; }
256 .wiki-page-body {
257 font-size: 15px;
258 line-height: 1.65;
259 color: var(--text);
260 }
261
262 /* ─── Pages index list (card layout) ─── */
263 .wiki-list { display: flex; flex-direction: column; gap: 8px; }
264 .wiki-list-row {
265 display: flex;
266 align-items: center;
267 justify-content: space-between;
268 gap: var(--space-3);
269 padding: 12px 14px;
270 background: rgba(255,255,255,0.018);
271 border: 1px solid var(--border);
272 border-radius: 10px;
273 transition: border-color 120ms ease, background 120ms ease;
274 }
275 .wiki-list-row:hover {
276 border-color: var(--border-strong);
277 background: rgba(255,255,255,0.03);
278 }
279 .wiki-list-name {
280 font-family: var(--font-display);
281 font-weight: 700;
282 font-size: 14.5px;
283 color: var(--text-strong);
284 text-decoration: none;
285 letter-spacing: -0.008em;
286 }
287 .wiki-list-name:hover { text-decoration: underline; }
288 .wiki-list-meta {
289 display: flex;
290 align-items: center;
291 gap: 10px;
292 font-size: 12px;
293 color: var(--text-muted);
294 font-variant-numeric: tabular-nums;
295 }
296 .wiki-rev-chip {
297 display: inline-flex;
298 align-items: center;
299 gap: 4px;
300 padding: 2px 8px;
301 border-radius: 9999px;
302 background: rgba(148,163,184,0.12);
303 color: #cbd5e1;
304 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28);
305 font-family: var(--font-mono);
306 font-size: 10.5px;
307 font-weight: 600;
308 }
309
310 /* ─── Editor (new / edit form) ─── */
311 .wiki-editor {
312 display: flex;
313 flex-direction: column;
314 gap: 14px;
315 background: var(--bg-elevated);
316 border: 1px solid var(--border);
317 border-radius: 14px;
318 padding: var(--space-4);
319 position: relative;
320 overflow: hidden;
321 }
322 .wiki-editor::before {
323 content: '';
324 position: absolute;
325 top: 0; left: 0; right: 0;
326 height: 1.5px;
6fd5915Claude327 background: linear-gradient(90deg, transparent 0%, #5b6ee8 50%, #5f8fa0 100%);
a1be1e1Claude328 opacity: 0.45;
329 pointer-events: none;
330 }
331 .wiki-field-label {
332 display: block;
333 font-size: 11.5px;
334 color: var(--text-muted);
335 font-weight: 600;
336 text-transform: uppercase;
337 letter-spacing: 0.06em;
338 margin-bottom: 6px;
339 }
340 .wiki-input,
341 .wiki-textarea {
342 width: 100%;
343 box-sizing: border-box;
344 padding: 10px 12px;
345 font: inherit;
346 font-size: 13.5px;
347 color: var(--text);
348 background: rgba(255,255,255,0.03);
349 border: 1px solid var(--border-strong);
350 border-radius: 10px;
351 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
352 outline: none;
353 }
354 .wiki-input:focus,
355 .wiki-textarea:focus {
6fd5915Claude356 border-color: rgba(91,110,232,0.55);
a1be1e1Claude357 background: rgba(255,255,255,0.05);
6fd5915Claude358 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
a1be1e1Claude359 }
360 .wiki-textarea {
361 font-family: var(--font-mono);
362 font-size: 13px;
363 line-height: 1.55;
364 resize: vertical;
365 min-height: 280px;
366 }
367 .wiki-editor-foot {
368 display: flex;
369 align-items: center;
370 justify-content: flex-end;
371 gap: 8px;
372 flex-wrap: wrap;
373 }
374 .wiki-editor-hint {
375 margin-right: auto;
376 font-size: 12px;
377 color: var(--text-muted);
378 }
379
380 /* ─── Empty state ─── */
381 .wiki-empty {
382 position: relative;
383 overflow: hidden;
384 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
385 text-align: center;
386 background: var(--bg-elevated);
387 border: 1px dashed var(--border-strong);
388 border-radius: 16px;
389 }
390 .wiki-empty-orb {
391 position: absolute;
392 inset: -40% 25% auto 25%;
393 height: 300px;
6fd5915Claude394 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
a1be1e1Claude395 filter: blur(72px);
396 opacity: 0.7;
397 pointer-events: none;
398 z-index: 0;
399 }
400 .wiki-empty-inner { position: relative; z-index: 1; }
401 .wiki-empty-icon {
402 width: 56px; height: 56px;
403 margin: 0 auto 14px;
404 border-radius: 9999px;
6fd5915Claude405 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
406 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
a1be1e1Claude407 display: inline-flex;
408 align-items: center;
409 justify-content: center;
410 color: #c4b5fd;
411 }
412 .wiki-empty-title {
413 font-family: var(--font-display);
414 font-size: 18px;
415 font-weight: 700;
416 margin: 0 0 6px;
417 color: var(--text-strong);
418 }
419 .wiki-empty-sub {
420 margin: 0 auto 16px;
421 font-size: 13.5px;
422 color: var(--text-muted);
423 max-width: 440px;
424 line-height: 1.5;
425 }
426
427 /* ─── History list ─── */
428 .wiki-hist { display: flex; flex-direction: column; gap: 8px; }
429 .wiki-hist-row {
430 display: flex;
431 align-items: center;
432 justify-content: space-between;
433 gap: var(--space-3);
434 padding: 12px 14px;
435 background: rgba(255,255,255,0.018);
436 border: 1px solid var(--border);
437 border-radius: 10px;
438 }
439 .wiki-hist-row:hover { border-color: var(--border-strong); background: rgba(255,255,255,0.03); }
440 .wiki-hist-name {
441 font-family: var(--font-mono);
442 font-size: 13px;
443 color: var(--text-strong);
444 text-decoration: none;
445 }
446 .wiki-hist-name:hover { text-decoration: underline; }
447 .wiki-hist-msg {
448 margin-left: 8px;
449 color: var(--text-muted);
450 font-size: 12.5px;
451 }
452 .wiki-hist-meta {
453 display: flex;
454 align-items: center;
455 gap: 8px;
456 font-size: 12px;
457 color: var(--text-muted);
458 }
459`;
460
1e162a8Claude461async function resolveRepo(ownerName: string, repoName: string) {
462 try {
463 const [owner] = await db
464 .select()
465 .from(users)
466 .where(eq(users.username, ownerName))
467 .limit(1);
468 if (!owner) return null;
469 const [repo] = await db
470 .select()
471 .from(repositories)
472 .where(
473 and(
474 eq(repositories.ownerId, owner.id),
475 eq(repositories.name, repoName)
476 )
477 )
478 .limit(1);
479 if (!repo) return null;
480 return { owner, repo };
481 } catch {
482 return null;
483 }
484}
485
f0cd0beccantynz-alt486/**
487 * Write gate for every mutating wiki route.
488 *
489 * POST /wiki (create) and POST /wiki/:slug/edit were guarded by `requireAuth`
490 * alone — "is anyone signed in" — with no ownership or collaborator check, so
491 * any registered account could create pages on, and overwrite pages of, any
492 * repository's wiki. The edit path is destructive: it UPDATEs title/body in
493 * place and bumps the revision, so it replaced the owner's content rather
494 * than appending to it.
495 *
496 * delete and revert did check, but as `user.id !== repo.ownerId`, which
497 * wrongly refuses collaborators and org members who legitimately have write
498 * access. Routing all four through resolveRepoAccess fixes both the hole and
499 * the over-restriction.
500 *
501 * Returns a redirect Response to be returned by the caller, or null when the
502 * caller may proceed.
503 */
504async function denyWikiWrite(
505 c: { redirect: (u: string) => Response },
506 repo: { id: string; isPrivate: boolean },
507 userId: string | null,
508 redirectTo: string
509): Promise<Response | null> {
510 const { resolveRepoAccess, satisfiesAccess } = await import(
511 "../middleware/repo-access"
512 );
513 const access = await resolveRepoAccess({
514 repoId: repo.id,
515 userId,
516 isPublic: !repo.isPrivate,
517 });
518 if (!satisfiesAccess(access, "write")) return c.redirect(redirectTo);
519 return null;
520}
521
1e162a8Claude522function notFound(user: any, label = "Page not found") {
523 return (
524 <Layout title={label} user={user}>
525 <div class="empty-state">
526 <h2>{label}</h2>
527 </div>
528 </Layout>
529 );
530}
531
a1be1e1Claude532function IconBook() {
533 return (
534 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
535 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
536 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
537 </svg>
538 );
539}
540function IconPlus() {
541 return (
542 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
543 <line x1="12" y1="5" x2="12" y2="19" />
544 <line x1="5" y1="12" x2="19" y2="12" />
545 </svg>
546 );
547}
548function IconEdit() {
549 return (
550 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
551 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
552 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
553 </svg>
554 );
555}
556
1e162a8Claude557function WikiSidebar(props: {
558 ownerName: string;
559 repoName: string;
560 pages: { slug: string; title: string }[];
561 user: any;
562}) {
563 const { ownerName, repoName, pages, user } = props;
564 return (
a1be1e1Claude565 <aside class="wiki-side">
566 <div class="wiki-side-title">Pages</div>
567 {pages.length === 0 ? (
568 <div class="wiki-side-empty">No pages yet.</div>
569 ) : (
570 <ul class="wiki-side-list">
571 {pages.map((p) => (
572 <li>
573 <a
574 href={`/${ownerName}/${repoName}/wiki/${p.slug}`}
575 class="wiki-side-link"
576 >
577 {p.title}
578 </a>
579 </li>
580 ))}
581 </ul>
582 )}
1e162a8Claude583 {user && (
a1be1e1Claude584 <div class="wiki-side-foot">
1e162a8Claude585 <a
586 href={`/${ownerName}/${repoName}/wiki/new`}
a1be1e1Claude587 class="wiki-btn wiki-btn-primary"
1e162a8Claude588 >
a1be1e1Claude589 <IconPlus />
590 New page
1e162a8Claude591 </a>
592 </div>
593 )}
594 </aside>
595 );
596}
597
598async function listPages(repoId: string) {
599 try {
600 return await db
601 .select({ slug: wikiPages.slug, title: wikiPages.title })
602 .from(wikiPages)
603 .where(eq(wikiPages.repositoryId, repoId))
604 .orderBy(wikiPages.title);
605 } catch {
606 return [];
607 }
608}
609
610// Root — render "home" page if exists, else CTA
611wikiRoutes.get("/:owner/:repo/wiki", softAuth, async (c) => {
612 const { owner: ownerName, repo: repoName } = c.req.param();
613 const user = c.get("user");
614 const resolved = await resolveRepo(ownerName, repoName);
615 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
616 const pages = await listPages(resolved.repo.id);
617
618 let home: any = null;
619 try {
620 const [row] = await db
621 .select()
622 .from(wikiPages)
623 .where(
624 and(
625 eq(wikiPages.repositoryId, resolved.repo.id),
626 eq(wikiPages.slug, "home")
627 )
628 )
629 .limit(1);
630 if (row) home = row;
631 } catch {
632 // leave null
633 }
634
635 return c.html(
636 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
637 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude638 <div class="wiki-wrap">
639 <header class="wiki-head">
640 <div class="wiki-head-text">
641 <div class="wiki-eyebrow">
642 <span class="wiki-eyebrow-dot" aria-hidden="true" />
643 Repository · Wiki
644 </div>
645 <h1 class="wiki-title">
646 <span class="wiki-title-grad">
647 {home ? home.title : "Wiki home."}
648 </span>
649 </h1>
650 <p class="wiki-sub">
651 Markdown pages with full revision history. Anyone can read;
652 signed-in users can edit when allowed by the owner.
653 </p>
654 </div>
655 {user && (
656 <a
657 href={`/${ownerName}/${repoName}/wiki/new`}
658 class="wiki-btn wiki-btn-primary"
659 >
660 <IconPlus />
661 New page
662 </a>
663 )}
664 </header>
665
666 <div class="wiki-grid">
667 <WikiSidebar
668 ownerName={ownerName}
669 repoName={repoName}
670 pages={pages}
671 user={user}
672 />
673 <main class="wiki-main">
674 {home ? (
675 <article
676 class="wiki-page-body"
1e162a8Claude677 dangerouslySetInnerHTML={{
678 __html: renderMarkdown(home.body || ""),
679 }}
680 />
a1be1e1Claude681 ) : (
682 <div class="wiki-empty">
683 <div class="wiki-empty-orb" aria-hidden="true" />
684 <div class="wiki-empty-inner">
685 <div class="wiki-empty-icon" aria-hidden="true">
686 <IconBook />
687 </div>
688 <h3 class="wiki-empty-title">No wiki yet</h3>
689 <p class="wiki-empty-sub">
690 Spin up a Home page to give visitors a tour of the
691 repository, its conventions, or its philosophy.
692 </p>
693 {user ? (
694 <a
695 href={`/${ownerName}/${repoName}/wiki/new`}
696 class="wiki-btn wiki-btn-primary"
697 >
698 <IconPlus />
699 Create the Home page
700 </a>
701 ) : (
702 <p
703 style="margin:0;color:var(--text-muted);font-size:13px"
704 >
705 Sign in to start the wiki.
706 </p>
707 )}
708 </div>
709 </div>
710 )}
711 </main>
712 </div>
1e162a8Claude713 </div>
a1be1e1Claude714 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude715 </Layout>
716 );
717});
718
719// All pages index
720wikiRoutes.get("/:owner/:repo/wiki/pages", softAuth, async (c) => {
721 const { owner: ownerName, repo: repoName } = c.req.param();
722 const user = c.get("user");
723 const resolved = await resolveRepo(ownerName, repoName);
724 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
725 let rows: any[] = [];
726 try {
727 rows = await db
728 .select()
729 .from(wikiPages)
730 .where(eq(wikiPages.repositoryId, resolved.repo.id))
731 .orderBy(wikiPages.title);
732 } catch {
733 rows = [];
734 }
735 return c.html(
736 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
737 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude738 <div class="wiki-wrap">
739 <header class="wiki-head">
740 <div class="wiki-head-text">
741 <div class="wiki-eyebrow">
742 <span class="wiki-eyebrow-dot" aria-hidden="true" />
743 Repository · Wiki · All pages
744 </div>
745 <h1 class="wiki-title">
746 <span class="wiki-title-grad">Every page in the wiki.</span>
747 </h1>
748 <p class="wiki-sub">
749 Alphabetical index of pages with current revision counters.
750 </p>
751 </div>
752 {user && (
753 <a
754 href={`/${ownerName}/${repoName}/wiki/new`}
755 class="wiki-btn wiki-btn-primary"
756 >
757 <IconPlus />
758 New page
759 </a>
760 )}
761 </header>
762
763 {rows.length === 0 ? (
764 <div class="wiki-empty">
765 <div class="wiki-empty-orb" aria-hidden="true" />
766 <div class="wiki-empty-inner">
767 <div class="wiki-empty-icon" aria-hidden="true">
768 <IconBook />
769 </div>
770 <h3 class="wiki-empty-title">No pages</h3>
771 <p class="wiki-empty-sub">
772 Create your first page to start documenting this repository.
773 </p>
774 </div>
775 </div>
776 ) : (
777 <div class="wiki-list">
778 {rows.map((p) => {
779 const ts = p.updatedAt
780 ? formatRelative(p.updatedAt as unknown as string)
781 : null;
782 return (
783 <div class="wiki-list-row">
784 <div style="min-width:0">
785 <a
786 href={`/${ownerName}/${repoName}/wiki/${p.slug}`}
787 class="wiki-list-name"
788 >
789 {p.title}
790 </a>
791 </div>
792 <div class="wiki-list-meta">
793 {ts && <span>edited {ts}</span>}
794 <span class="wiki-rev-chip">r{p.revision}</span>
795 {user && (
796 <a
797 class="wiki-btn wiki-btn-ghost"
798 href={`/${ownerName}/${repoName}/wiki/${p.slug}/edit`}
799 style="padding:5px 10px;font-size:11.5px"
800 >
801 <IconEdit />
802 Edit
803 </a>
804 )}
805 </div>
806 </div>
807 );
808 })}
809 </div>
810 )}
811 </div>
812 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude813 </Layout>
814 );
815});
816
817// New page form
818wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
819 const { owner: ownerName, repo: repoName } = c.req.param();
820 const user = c.get("user");
821 const resolved = await resolveRepo(ownerName, repoName);
822 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
823 return c.html(
824 <Layout title="New wiki page" user={user}>
825 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude826 <div class="wiki-wrap">
827 <header class="wiki-head">
828 <div class="wiki-head-text">
829 <div class="wiki-eyebrow">
830 <span class="wiki-eyebrow-dot" aria-hidden="true" />
831 Repository · Wiki · New page
832 </div>
833 <h1 class="wiki-title">
834 <span class="wiki-title-grad">Start a new page.</span>
835 </h1>
836 <p class="wiki-sub">
837 Markdown is rendered with GFM extensions: tables, task lists,
838 fenced code, autolinks.
839 </p>
840 </div>
841 </header>
842
843 <form
844 method="post"
845 action={`/${ownerName}/${repoName}/wiki`}
846 class="wiki-editor"
847 >
848 <div>
849 <label class="wiki-field-label" for="wiki-new-title">Title</label>
850 <input
851 id="wiki-new-title"
852 type="text"
853 name="title"
854 placeholder="Page title"
855 required
856 aria-label="Page title"
857 class="wiki-input"
858 />
859 </div>
860 <div>
861 <label class="wiki-field-label" for="wiki-new-body">Markdown</label>
862 <textarea
863 id="wiki-new-body"
864 name="body"
865 rows={16}
866 placeholder="# Page title\n\nWrite something."
867 class="wiki-textarea"
868 ></textarea>
869 </div>
870 <div class="wiki-editor-foot">
871 <span class="wiki-editor-hint">
872 The slug is generated from the title.
873 </span>
874 <a
875 class="wiki-btn wiki-btn-ghost"
876 href={`/${ownerName}/${repoName}/wiki`}
877 >
878 Cancel
879 </a>
880 <button type="submit" class="wiki-btn wiki-btn-primary">
881 Create page
882 </button>
883 </div>
884 </form>
885 </div>
886 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude887 </Layout>
888 );
889});
890
891// Create
892wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
893 const { owner: ownerName, repo: repoName } = c.req.param();
894 const user = c.get("user")!;
895 const resolved = await resolveRepo(ownerName, repoName);
896 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
f0cd0beccantynz-alt897 const denied = await denyWikiWrite(
898 c,
899 resolved.repo,
900 user.id,
901 `/${ownerName}/${repoName}/wiki`
902 );
903 if (denied) return denied;
1e162a8Claude904
905 const form = await c.req.formData();
906 const title = (form.get("title") as string || "").trim();
907 const body = (form.get("body") as string || "").trim();
908 if (!title) {
909 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
910 }
911 const slug = slugifyTitle(title) || "page";
912
913 try {
914 const [page] = await db
915 .insert(wikiPages)
916 .values({
917 repositoryId: resolved.repo.id,
918 slug,
919 title,
920 body,
921 revision: 1,
922 updatedBy: user.id,
923 })
924 .returning({ id: wikiPages.id });
925 await db.insert(wikiRevisions).values({
926 pageId: page.id,
927 revision: 1,
928 title,
929 body,
930 message: "Initial",
931 authorId: user.id,
932 });
933 } catch {
934 // likely unique-violation on slug; redirect to the existing page
935 }
936 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
937});
938
939// View page
940wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
941 const { owner: ownerName, repo: repoName, slug } = c.req.param();
942 const user = c.get("user");
943 const resolved = await resolveRepo(ownerName, repoName);
944 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
945
946 let page: any = null;
947 try {
948 const [row] = await db
949 .select()
950 .from(wikiPages)
951 .where(
952 and(
953 eq(wikiPages.repositoryId, resolved.repo.id),
954 eq(wikiPages.slug, slug)
955 )
956 )
957 .limit(1);
958 if (row) page = row;
959 } catch {
960 // leave null
961 }
962 if (!page) return c.html(notFound(user, "Page not found"), 404);
963 const pages = await listPages(resolved.repo.id);
964 const isOwner = user && user.id === resolved.repo.ownerId;
a1be1e1Claude965 const editedTs = page.updatedAt
966 ? formatRelative(page.updatedAt as unknown as string)
967 : null;
1e162a8Claude968
969 return c.html(
970 <Layout title={`${page.title} — wiki`} user={user}>
971 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude972 <div class="wiki-wrap">
973 <header class="wiki-head">
974 <div class="wiki-head-text">
975 <div class="wiki-eyebrow">
976 <span class="wiki-eyebrow-dot" aria-hidden="true" />
977 Repository · Wiki
978 {editedTs && (
979 <>
980 {" · "}
981 <span>edited {editedTs}</span>
982 </>
1e162a8Claude983 )}
984 </div>
a1be1e1Claude985 <h1 class="wiki-title">
986 <span class="wiki-title-grad">{page.title}</span>
987 </h1>
988 <p class="wiki-sub">
989 Revision r{page.revision}. Edits append a new revision —
990 previous versions remain in the history.
991 </p>
1e162a8Claude992 </div>
a1be1e1Claude993 </header>
994
995 <div class="wiki-grid">
996 <WikiSidebar
997 ownerName={ownerName}
998 repoName={repoName}
999 pages={pages}
1000 user={user}
1e162a8Claude1001 />
a1be1e1Claude1002 <main class="wiki-main">
1003 <div class="wiki-page-head">
1004 <h2 class="wiki-page-title">{page.title}</h2>
1005 <div class="wiki-page-actions">
1006 <a
1007 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
1008 class="wiki-btn wiki-btn-ghost"
1009 >
1010 History
1011 </a>
1012 {user && (
1013 <a
1014 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
1015 class="wiki-btn wiki-btn-primary"
1016 >
1017 <IconEdit />
1018 Edit
1019 </a>
1020 )}
1021 {isOwner && (
1022 <form
1023 method="post"
1024 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
1025 style="display: inline;"
1026 onsubmit="return confirm('Delete this page?')"
1027 >
1028 <button type="submit" class="wiki-btn wiki-btn-danger">
1029 Delete
1030 </button>
1031 </form>
1032 )}
1033 </div>
1034 </div>
1035 <article
1036 class="wiki-page-body"
1037 dangerouslySetInnerHTML={{
1038 __html: renderMarkdown(page.body || ""),
1039 }}
1040 />
1041 </main>
1042 </div>
1e162a8Claude1043 </div>
a1be1e1Claude1044 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1045 </Layout>
1046 );
1047});
1048
1049// Edit form
1050wikiRoutes.get(
1051 "/:owner/:repo/wiki/:slug/edit",
1052 requireAuth,
1053 async (c) => {
1054 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1055 const user = c.get("user");
1056 const resolved = await resolveRepo(ownerName, repoName);
1057 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1058
1059 let page: any = null;
1060 try {
1061 const [row] = await db
1062 .select()
1063 .from(wikiPages)
1064 .where(
1065 and(
1066 eq(wikiPages.repositoryId, resolved.repo.id),
1067 eq(wikiPages.slug, slug)
1068 )
1069 )
1070 .limit(1);
1071 if (row) page = row;
1072 } catch {
1073 // leave null
1074 }
1075 if (!page) return c.html(notFound(user, "Page not found"), 404);
1076
1077 return c.html(
1078 <Layout title={`Edit ${page.title}`} user={user}>
1079 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1080 <div class="wiki-wrap">
1081 <header class="wiki-head">
1082 <div class="wiki-head-text">
1083 <div class="wiki-eyebrow">
1084 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1085 Repository · Wiki · Editing
1086 </div>
1087 <h1 class="wiki-title">
1088 <span class="wiki-title-grad">Edit "{page.title}"</span>
1089 </h1>
1090 <p class="wiki-sub">
1091 Currently at r{page.revision}. Saving appends a new
1092 revision; nothing is overwritten in history.
1093 </p>
1094 </div>
1095 </header>
1096
1097 <form
1098 method="post"
1099 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
1100 class="wiki-editor"
1e162a8Claude1101 >
a1be1e1Claude1102 <div>
1103 <label class="wiki-field-label" for="wiki-edit-title">Title</label>
1104 <input
1105 id="wiki-edit-title"
1106 type="text"
1107 name="title"
1108 value={page.title}
1109 required
1110 aria-label="Page title"
1111 class="wiki-input"
1112 />
1113 </div>
1114 <div>
1115 <label class="wiki-field-label" for="wiki-edit-body">Markdown</label>
1116 <textarea
1117 id="wiki-edit-body"
1118 name="body"
1119 rows={16}
1120 class="wiki-textarea"
1121 >{page.body}</textarea>
1122 </div>
1123 <div>
1124 <label class="wiki-field-label" for="wiki-edit-msg">
1125 Revision message (optional)
1126 </label>
1127 <input
1128 id="wiki-edit-msg"
1129 type="text"
1130 name="message"
1131 placeholder="What changed?"
1132 aria-label="Revision message"
1133 class="wiki-input"
1134 />
1135 </div>
1136 <div class="wiki-editor-foot">
1137 <span class="wiki-editor-hint">
1138 Next revision will be r{page.revision + 1}.
1139 </span>
1140 <a
1141 class="wiki-btn wiki-btn-ghost"
1142 href={`/${ownerName}/${repoName}/wiki/${slug}`}
1143 >
1144 Cancel
1145 </a>
1146 <button type="submit" class="wiki-btn wiki-btn-primary">
1147 Save revision
1148 </button>
1149 </div>
1150 </form>
1151 </div>
1152 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1153 </Layout>
1154 );
1155 }
1156);
1157
1158// Save edit
1159wikiRoutes.post(
1160 "/:owner/:repo/wiki/:slug/edit",
1161 requireAuth,
1162 async (c) => {
1163 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1164 const user = c.get("user")!;
1165 const resolved = await resolveRepo(ownerName, repoName);
1166 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
f0cd0beccantynz-alt1167 const denied = await denyWikiWrite(
1168 c,
1169 resolved.repo,
1170 user.id,
1171 `/${ownerName}/${repoName}/wiki/${slug}`
1172 );
1173 if (denied) return denied;
1e162a8Claude1174
1175 const form = await c.req.formData();
1176 const title = (form.get("title") as string || "").trim();
1177 const body = (form.get("body") as string || "").trim();
1178 const message = (form.get("message") as string || "").trim();
1179 if (!title) {
1180 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
1181 }
1182
1183 try {
1184 const [page] = await db
1185 .select()
1186 .from(wikiPages)
1187 .where(
1188 and(
1189 eq(wikiPages.repositoryId, resolved.repo.id),
1190 eq(wikiPages.slug, slug)
1191 )
1192 )
1193 .limit(1);
1194 if (page) {
1195 const nextRev = page.revision + 1;
1196 await db
1197 .update(wikiPages)
1198 .set({
1199 title,
1200 body,
1201 revision: nextRev,
1202 updatedAt: new Date(),
1203 updatedBy: user.id,
1204 })
1205 .where(eq(wikiPages.id, page.id));
1206 await db.insert(wikiRevisions).values({
1207 pageId: page.id,
1208 revision: nextRev,
1209 title,
1210 body,
1211 message: message || null,
1212 authorId: user.id,
1213 });
1214 }
1215 } catch {
1216 // swallow
1217 }
1218 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1219 }
1220);
1221
1222// Delete
1223wikiRoutes.post(
1224 "/:owner/:repo/wiki/:slug/delete",
1225 requireAuth,
1226 async (c) => {
1227 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1228 const user = c.get("user")!;
1229 const resolved = await resolveRepo(ownerName, repoName);
1230 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
f0cd0beccantynz-alt1231 const denied = await denyWikiWrite(
1232 c,
1233 resolved.repo,
1234 user.id,
1235 `/${ownerName}/${repoName}/wiki/${slug}`
1236 );
1237 if (denied) return denied;
1e162a8Claude1238 try {
1239 await db
1240 .delete(wikiPages)
1241 .where(
1242 and(
1243 eq(wikiPages.repositoryId, resolved.repo.id),
1244 eq(wikiPages.slug, slug)
1245 )
1246 );
1247 } catch {
1248 // swallow
1249 }
1250 return c.redirect(`/${ownerName}/${repoName}/wiki`);
1251 }
1252);
1253
1254// History
1255wikiRoutes.get(
1256 "/:owner/:repo/wiki/:slug/history",
1257 softAuth,
1258 async (c) => {
1259 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1260 const user = c.get("user");
1261 const resolved = await resolveRepo(ownerName, repoName);
1262 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1263
1264 let page: any = null;
1265 let revs: any[] = [];
1266 try {
1267 const [row] = await db
1268 .select()
1269 .from(wikiPages)
1270 .where(
1271 and(
1272 eq(wikiPages.repositoryId, resolved.repo.id),
1273 eq(wikiPages.slug, slug)
1274 )
1275 )
1276 .limit(1);
1277 if (row) {
1278 page = row;
1279 revs = await db
1280 .select({
1281 r: wikiRevisions,
1282 author: { username: users.username },
1283 })
1284 .from(wikiRevisions)
1285 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
1286 .where(eq(wikiRevisions.pageId, page.id))
1287 .orderBy(desc(wikiRevisions.revision));
1288 }
1289 } catch {
1290 // leave null
1291 }
1292 if (!page) return c.html(notFound(user, "Page not found"), 404);
1293
1294 return c.html(
1295 <Layout title={`${page.title} — history`} user={user}>
1296 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1297 <div class="wiki-wrap">
1298 <header class="wiki-head">
1299 <div class="wiki-head-text">
1300 <div class="wiki-eyebrow">
1301 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1302 Repository · Wiki · History
1303 </div>
1304 <h1 class="wiki-title">
1305 <span class="wiki-title-grad">{page.title}</span>
1306 </h1>
1307 <p class="wiki-sub">
1308 Every revision, newest first.{" "}
1309 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>
1310 View current page
1311 </a>.
1312 </p>
1313 </div>
1314 </header>
1315
1316 <div class="wiki-hist">
1e162a8Claude1317 {revs.map((rv) => (
a1be1e1Claude1318 <div class="wiki-hist-row">
1319 <div style="min-width:0">
1e162a8Claude1320 <a
1321 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
a1be1e1Claude1322 class="wiki-hist-name"
1e162a8Claude1323 >
a1be1e1Claude1324 r{rv.r.revision}
1e162a8Claude1325 </a>
1326 {rv.r.message && (
a1be1e1Claude1327 <span class="wiki-hist-msg">{rv.r.message}</span>
1e162a8Claude1328 )}
a1be1e1Claude1329 </div>
1330 <div class="wiki-hist-meta">
1331 <span>@{rv.author.username}</span>
1e162a8Claude1332 {user && user.id === resolved.repo.ownerId &&
1333 rv.r.revision !== page.revision && (
a1be1e1Claude1334 <form
1335 method="post"
1336 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
1337 style="display:inline"
1338 >
1339 <button
1340 type="submit"
1341 class="wiki-btn wiki-btn-ghost"
1342 style="padding:5px 10px;font-size:11.5px"
1e162a8Claude1343 >
a1be1e1Claude1344 Revert
1345 </button>
1346 </form>
1e162a8Claude1347 )}
a1be1e1Claude1348 </div>
1349 </div>
1e162a8Claude1350 ))}
a1be1e1Claude1351 </div>
1352 </div>
1353 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1354 </Layout>
1355 );
1356 }
1357);
1358
1359// View revision
1360wikiRoutes.get(
1361 "/:owner/:repo/wiki/:slug/revisions/:rev",
1362 softAuth,
1363 async (c) => {
1364 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1365 const user = c.get("user");
1366 const rev = Number(c.req.param("rev"));
1367 const resolved = await resolveRepo(ownerName, repoName);
1368 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1369
1370 let rv: any = null;
1371 try {
1372 const [page] = await db
1373 .select()
1374 .from(wikiPages)
1375 .where(
1376 and(
1377 eq(wikiPages.repositoryId, resolved.repo.id),
1378 eq(wikiPages.slug, slug)
1379 )
1380 )
1381 .limit(1);
1382 if (page) {
1383 const [r] = await db
1384 .select()
1385 .from(wikiRevisions)
1386 .where(
1387 and(
1388 eq(wikiRevisions.pageId, page.id),
1389 eq(wikiRevisions.revision, rev)
1390 )
1391 )
1392 .limit(1);
1393 if (r) rv = r;
1394 }
1395 } catch {
1396 // leave null
1397 }
1398 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
1399
1400 return c.html(
1401 <Layout title={`${rv.title} @ r${rev}`} user={user}>
1402 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1403 <div class="wiki-wrap">
1404 <header class="wiki-head">
1405 <div class="wiki-head-text">
1406 <div class="wiki-eyebrow">
1407 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1408 Repository · Wiki · Revision r{rev}
1409 </div>
1410 <h1 class="wiki-title">
1411 <span class="wiki-title-grad">{rv.title}</span>
1412 </h1>
1413 <p class="wiki-sub">
1414 Viewing revision {rev}.{" "}
1415 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>
1416 Back to current
1417 </a>.
1418 </p>
1419 </div>
1420 </header>
1421 <article
1422 class="wiki-page-body"
1423 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
1424 />
1e162a8Claude1425 </div>
a1be1e1Claude1426 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1427 </Layout>
1428 );
1429 }
1430);
1431
1432// Revert
1433wikiRoutes.post(
1434 "/:owner/:repo/wiki/:slug/revert/:rev",
1435 requireAuth,
1436 async (c) => {
1437 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1438 const rev = Number(c.req.param("rev"));
1439 const user = c.get("user")!;
1440 const resolved = await resolveRepo(ownerName, repoName);
1441 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
f0cd0beccantynz-alt1442 const denied = await denyWikiWrite(
1443 c,
1444 resolved.repo,
1445 user.id,
1446 `/${ownerName}/${repoName}/wiki/${slug}`
1447 );
1448 if (denied) return denied;
1e162a8Claude1449 try {
1450 const [page] = await db
1451 .select()
1452 .from(wikiPages)
1453 .where(
1454 and(
1455 eq(wikiPages.repositoryId, resolved.repo.id),
1456 eq(wikiPages.slug, slug)
1457 )
1458 )
1459 .limit(1);
1460 if (!page) {
1461 return c.redirect(`/${ownerName}/${repoName}/wiki`);
1462 }
1463 const [target] = await db
1464 .select()
1465 .from(wikiRevisions)
1466 .where(
1467 and(
1468 eq(wikiRevisions.pageId, page.id),
1469 eq(wikiRevisions.revision, rev)
1470 )
1471 )
1472 .limit(1);
1473 if (target) {
1474 const nextRev = page.revision + 1;
1475 await db
1476 .update(wikiPages)
1477 .set({
1478 title: target.title,
1479 body: target.body,
1480 revision: nextRev,
1481 updatedAt: new Date(),
1482 updatedBy: user.id,
1483 })
1484 .where(eq(wikiPages.id, page.id));
1485 await db.insert(wikiRevisions).values({
1486 pageId: page.id,
1487 revision: nextRev,
1488 title: target.title,
1489 body: target.body,
1490 message: `Reverted to revision ${rev}`,
1491 authorId: user.id,
1492 });
1493 }
1494 } catch {
1495 // swallow
1496 }
1497 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1498 }
1499);
1500
1501export default wikiRoutes;