Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1443 lines · 1 contributor
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;
143 color: #fca5a5;
144 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
486function notFound(user: any, label = "Page not found") {
487 return (
488 <Layout title={label} user={user}>
489 <div class="empty-state">
490 <h2>{label}</h2>
491 </div>
492 </Layout>
493 );
494}
495
a1be1e1Claude496function IconBook() {
497 return (
498 <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">
499 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
500 <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" />
501 </svg>
502 );
503}
504function IconPlus() {
505 return (
506 <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">
507 <line x1="12" y1="5" x2="12" y2="19" />
508 <line x1="5" y1="12" x2="19" y2="12" />
509 </svg>
510 );
511}
512function IconEdit() {
513 return (
514 <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">
515 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
516 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
517 </svg>
518 );
519}
520
1e162a8Claude521function WikiSidebar(props: {
522 ownerName: string;
523 repoName: string;
524 pages: { slug: string; title: string }[];
525 user: any;
526}) {
527 const { ownerName, repoName, pages, user } = props;
528 return (
a1be1e1Claude529 <aside class="wiki-side">
530 <div class="wiki-side-title">Pages</div>
531 {pages.length === 0 ? (
532 <div class="wiki-side-empty">No pages yet.</div>
533 ) : (
534 <ul class="wiki-side-list">
535 {pages.map((p) => (
536 <li>
537 <a
538 href={`/${ownerName}/${repoName}/wiki/${p.slug}`}
539 class="wiki-side-link"
540 >
541 {p.title}
542 </a>
543 </li>
544 ))}
545 </ul>
546 )}
1e162a8Claude547 {user && (
a1be1e1Claude548 <div class="wiki-side-foot">
1e162a8Claude549 <a
550 href={`/${ownerName}/${repoName}/wiki/new`}
a1be1e1Claude551 class="wiki-btn wiki-btn-primary"
1e162a8Claude552 >
a1be1e1Claude553 <IconPlus />
554 New page
1e162a8Claude555 </a>
556 </div>
557 )}
558 </aside>
559 );
560}
561
562async function listPages(repoId: string) {
563 try {
564 return await db
565 .select({ slug: wikiPages.slug, title: wikiPages.title })
566 .from(wikiPages)
567 .where(eq(wikiPages.repositoryId, repoId))
568 .orderBy(wikiPages.title);
569 } catch {
570 return [];
571 }
572}
573
574// Root — render "home" page if exists, else CTA
575wikiRoutes.get("/:owner/:repo/wiki", softAuth, async (c) => {
576 const { owner: ownerName, repo: repoName } = c.req.param();
577 const user = c.get("user");
578 const resolved = await resolveRepo(ownerName, repoName);
579 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
580 const pages = await listPages(resolved.repo.id);
581
582 let home: any = null;
583 try {
584 const [row] = await db
585 .select()
586 .from(wikiPages)
587 .where(
588 and(
589 eq(wikiPages.repositoryId, resolved.repo.id),
590 eq(wikiPages.slug, "home")
591 )
592 )
593 .limit(1);
594 if (row) home = row;
595 } catch {
596 // leave null
597 }
598
599 return c.html(
600 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
601 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude602 <div class="wiki-wrap">
603 <header class="wiki-head">
604 <div class="wiki-head-text">
605 <div class="wiki-eyebrow">
606 <span class="wiki-eyebrow-dot" aria-hidden="true" />
607 Repository · Wiki
608 </div>
609 <h1 class="wiki-title">
610 <span class="wiki-title-grad">
611 {home ? home.title : "Wiki home."}
612 </span>
613 </h1>
614 <p class="wiki-sub">
615 Markdown pages with full revision history. Anyone can read;
616 signed-in users can edit when allowed by the owner.
617 </p>
618 </div>
619 {user && (
620 <a
621 href={`/${ownerName}/${repoName}/wiki/new`}
622 class="wiki-btn wiki-btn-primary"
623 >
624 <IconPlus />
625 New page
626 </a>
627 )}
628 </header>
629
630 <div class="wiki-grid">
631 <WikiSidebar
632 ownerName={ownerName}
633 repoName={repoName}
634 pages={pages}
635 user={user}
636 />
637 <main class="wiki-main">
638 {home ? (
639 <article
640 class="wiki-page-body"
1e162a8Claude641 dangerouslySetInnerHTML={{
642 __html: renderMarkdown(home.body || ""),
643 }}
644 />
a1be1e1Claude645 ) : (
646 <div class="wiki-empty">
647 <div class="wiki-empty-orb" aria-hidden="true" />
648 <div class="wiki-empty-inner">
649 <div class="wiki-empty-icon" aria-hidden="true">
650 <IconBook />
651 </div>
652 <h3 class="wiki-empty-title">No wiki yet</h3>
653 <p class="wiki-empty-sub">
654 Spin up a Home page to give visitors a tour of the
655 repository, its conventions, or its philosophy.
656 </p>
657 {user ? (
658 <a
659 href={`/${ownerName}/${repoName}/wiki/new`}
660 class="wiki-btn wiki-btn-primary"
661 >
662 <IconPlus />
663 Create the Home page
664 </a>
665 ) : (
666 <p
667 style="margin:0;color:var(--text-muted);font-size:13px"
668 >
669 Sign in to start the wiki.
670 </p>
671 )}
672 </div>
673 </div>
674 )}
675 </main>
676 </div>
1e162a8Claude677 </div>
a1be1e1Claude678 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude679 </Layout>
680 );
681});
682
683// All pages index
684wikiRoutes.get("/:owner/:repo/wiki/pages", softAuth, async (c) => {
685 const { owner: ownerName, repo: repoName } = c.req.param();
686 const user = c.get("user");
687 const resolved = await resolveRepo(ownerName, repoName);
688 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
689 let rows: any[] = [];
690 try {
691 rows = await db
692 .select()
693 .from(wikiPages)
694 .where(eq(wikiPages.repositoryId, resolved.repo.id))
695 .orderBy(wikiPages.title);
696 } catch {
697 rows = [];
698 }
699 return c.html(
700 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
701 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude702 <div class="wiki-wrap">
703 <header class="wiki-head">
704 <div class="wiki-head-text">
705 <div class="wiki-eyebrow">
706 <span class="wiki-eyebrow-dot" aria-hidden="true" />
707 Repository · Wiki · All pages
708 </div>
709 <h1 class="wiki-title">
710 <span class="wiki-title-grad">Every page in the wiki.</span>
711 </h1>
712 <p class="wiki-sub">
713 Alphabetical index of pages with current revision counters.
714 </p>
715 </div>
716 {user && (
717 <a
718 href={`/${ownerName}/${repoName}/wiki/new`}
719 class="wiki-btn wiki-btn-primary"
720 >
721 <IconPlus />
722 New page
723 </a>
724 )}
725 </header>
726
727 {rows.length === 0 ? (
728 <div class="wiki-empty">
729 <div class="wiki-empty-orb" aria-hidden="true" />
730 <div class="wiki-empty-inner">
731 <div class="wiki-empty-icon" aria-hidden="true">
732 <IconBook />
733 </div>
734 <h3 class="wiki-empty-title">No pages</h3>
735 <p class="wiki-empty-sub">
736 Create your first page to start documenting this repository.
737 </p>
738 </div>
739 </div>
740 ) : (
741 <div class="wiki-list">
742 {rows.map((p) => {
743 const ts = p.updatedAt
744 ? formatRelative(p.updatedAt as unknown as string)
745 : null;
746 return (
747 <div class="wiki-list-row">
748 <div style="min-width:0">
749 <a
750 href={`/${ownerName}/${repoName}/wiki/${p.slug}`}
751 class="wiki-list-name"
752 >
753 {p.title}
754 </a>
755 </div>
756 <div class="wiki-list-meta">
757 {ts && <span>edited {ts}</span>}
758 <span class="wiki-rev-chip">r{p.revision}</span>
759 {user && (
760 <a
761 class="wiki-btn wiki-btn-ghost"
762 href={`/${ownerName}/${repoName}/wiki/${p.slug}/edit`}
763 style="padding:5px 10px;font-size:11.5px"
764 >
765 <IconEdit />
766 Edit
767 </a>
768 )}
769 </div>
770 </div>
771 );
772 })}
773 </div>
774 )}
775 </div>
776 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude777 </Layout>
778 );
779});
780
781// New page form
782wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
783 const { owner: ownerName, repo: repoName } = c.req.param();
784 const user = c.get("user");
785 const resolved = await resolveRepo(ownerName, repoName);
786 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
787 return c.html(
788 <Layout title="New wiki page" user={user}>
789 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude790 <div class="wiki-wrap">
791 <header class="wiki-head">
792 <div class="wiki-head-text">
793 <div class="wiki-eyebrow">
794 <span class="wiki-eyebrow-dot" aria-hidden="true" />
795 Repository · Wiki · New page
796 </div>
797 <h1 class="wiki-title">
798 <span class="wiki-title-grad">Start a new page.</span>
799 </h1>
800 <p class="wiki-sub">
801 Markdown is rendered with GFM extensions: tables, task lists,
802 fenced code, autolinks.
803 </p>
804 </div>
805 </header>
806
807 <form
808 method="post"
809 action={`/${ownerName}/${repoName}/wiki`}
810 class="wiki-editor"
811 >
812 <div>
813 <label class="wiki-field-label" for="wiki-new-title">Title</label>
814 <input
815 id="wiki-new-title"
816 type="text"
817 name="title"
818 placeholder="Page title"
819 required
820 aria-label="Page title"
821 class="wiki-input"
822 />
823 </div>
824 <div>
825 <label class="wiki-field-label" for="wiki-new-body">Markdown</label>
826 <textarea
827 id="wiki-new-body"
828 name="body"
829 rows={16}
830 placeholder="# Page title\n\nWrite something."
831 class="wiki-textarea"
832 ></textarea>
833 </div>
834 <div class="wiki-editor-foot">
835 <span class="wiki-editor-hint">
836 The slug is generated from the title.
837 </span>
838 <a
839 class="wiki-btn wiki-btn-ghost"
840 href={`/${ownerName}/${repoName}/wiki`}
841 >
842 Cancel
843 </a>
844 <button type="submit" class="wiki-btn wiki-btn-primary">
845 Create page
846 </button>
847 </div>
848 </form>
849 </div>
850 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude851 </Layout>
852 );
853});
854
855// Create
856wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
857 const { owner: ownerName, repo: repoName } = c.req.param();
858 const user = c.get("user")!;
859 const resolved = await resolveRepo(ownerName, repoName);
860 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
861
862 const form = await c.req.formData();
863 const title = (form.get("title") as string || "").trim();
864 const body = (form.get("body") as string || "").trim();
865 if (!title) {
866 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
867 }
868 const slug = slugifyTitle(title) || "page";
869
870 try {
871 const [page] = await db
872 .insert(wikiPages)
873 .values({
874 repositoryId: resolved.repo.id,
875 slug,
876 title,
877 body,
878 revision: 1,
879 updatedBy: user.id,
880 })
881 .returning({ id: wikiPages.id });
882 await db.insert(wikiRevisions).values({
883 pageId: page.id,
884 revision: 1,
885 title,
886 body,
887 message: "Initial",
888 authorId: user.id,
889 });
890 } catch {
891 // likely unique-violation on slug; redirect to the existing page
892 }
893 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
894});
895
896// View page
897wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
898 const { owner: ownerName, repo: repoName, slug } = c.req.param();
899 const user = c.get("user");
900 const resolved = await resolveRepo(ownerName, repoName);
901 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
902
903 let page: any = null;
904 try {
905 const [row] = await db
906 .select()
907 .from(wikiPages)
908 .where(
909 and(
910 eq(wikiPages.repositoryId, resolved.repo.id),
911 eq(wikiPages.slug, slug)
912 )
913 )
914 .limit(1);
915 if (row) page = row;
916 } catch {
917 // leave null
918 }
919 if (!page) return c.html(notFound(user, "Page not found"), 404);
920 const pages = await listPages(resolved.repo.id);
921 const isOwner = user && user.id === resolved.repo.ownerId;
a1be1e1Claude922 const editedTs = page.updatedAt
923 ? formatRelative(page.updatedAt as unknown as string)
924 : null;
1e162a8Claude925
926 return c.html(
927 <Layout title={`${page.title} — wiki`} user={user}>
928 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude929 <div class="wiki-wrap">
930 <header class="wiki-head">
931 <div class="wiki-head-text">
932 <div class="wiki-eyebrow">
933 <span class="wiki-eyebrow-dot" aria-hidden="true" />
934 Repository · Wiki
935 {editedTs && (
936 <>
937 {" · "}
938 <span>edited {editedTs}</span>
939 </>
1e162a8Claude940 )}
941 </div>
a1be1e1Claude942 <h1 class="wiki-title">
943 <span class="wiki-title-grad">{page.title}</span>
944 </h1>
945 <p class="wiki-sub">
946 Revision r{page.revision}. Edits append a new revision —
947 previous versions remain in the history.
948 </p>
1e162a8Claude949 </div>
a1be1e1Claude950 </header>
951
952 <div class="wiki-grid">
953 <WikiSidebar
954 ownerName={ownerName}
955 repoName={repoName}
956 pages={pages}
957 user={user}
1e162a8Claude958 />
a1be1e1Claude959 <main class="wiki-main">
960 <div class="wiki-page-head">
961 <h2 class="wiki-page-title">{page.title}</h2>
962 <div class="wiki-page-actions">
963 <a
964 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
965 class="wiki-btn wiki-btn-ghost"
966 >
967 History
968 </a>
969 {user && (
970 <a
971 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
972 class="wiki-btn wiki-btn-primary"
973 >
974 <IconEdit />
975 Edit
976 </a>
977 )}
978 {isOwner && (
979 <form
980 method="post"
981 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
982 style="display: inline;"
983 onsubmit="return confirm('Delete this page?')"
984 >
985 <button type="submit" class="wiki-btn wiki-btn-danger">
986 Delete
987 </button>
988 </form>
989 )}
990 </div>
991 </div>
992 <article
993 class="wiki-page-body"
994 dangerouslySetInnerHTML={{
995 __html: renderMarkdown(page.body || ""),
996 }}
997 />
998 </main>
999 </div>
1e162a8Claude1000 </div>
a1be1e1Claude1001 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1002 </Layout>
1003 );
1004});
1005
1006// Edit form
1007wikiRoutes.get(
1008 "/:owner/:repo/wiki/:slug/edit",
1009 requireAuth,
1010 async (c) => {
1011 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1012 const user = c.get("user");
1013 const resolved = await resolveRepo(ownerName, repoName);
1014 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1015
1016 let page: any = null;
1017 try {
1018 const [row] = await db
1019 .select()
1020 .from(wikiPages)
1021 .where(
1022 and(
1023 eq(wikiPages.repositoryId, resolved.repo.id),
1024 eq(wikiPages.slug, slug)
1025 )
1026 )
1027 .limit(1);
1028 if (row) page = row;
1029 } catch {
1030 // leave null
1031 }
1032 if (!page) return c.html(notFound(user, "Page not found"), 404);
1033
1034 return c.html(
1035 <Layout title={`Edit ${page.title}`} user={user}>
1036 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1037 <div class="wiki-wrap">
1038 <header class="wiki-head">
1039 <div class="wiki-head-text">
1040 <div class="wiki-eyebrow">
1041 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1042 Repository · Wiki · Editing
1043 </div>
1044 <h1 class="wiki-title">
1045 <span class="wiki-title-grad">Edit "{page.title}"</span>
1046 </h1>
1047 <p class="wiki-sub">
1048 Currently at r{page.revision}. Saving appends a new
1049 revision; nothing is overwritten in history.
1050 </p>
1051 </div>
1052 </header>
1053
1054 <form
1055 method="post"
1056 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
1057 class="wiki-editor"
1e162a8Claude1058 >
a1be1e1Claude1059 <div>
1060 <label class="wiki-field-label" for="wiki-edit-title">Title</label>
1061 <input
1062 id="wiki-edit-title"
1063 type="text"
1064 name="title"
1065 value={page.title}
1066 required
1067 aria-label="Page title"
1068 class="wiki-input"
1069 />
1070 </div>
1071 <div>
1072 <label class="wiki-field-label" for="wiki-edit-body">Markdown</label>
1073 <textarea
1074 id="wiki-edit-body"
1075 name="body"
1076 rows={16}
1077 class="wiki-textarea"
1078 >{page.body}</textarea>
1079 </div>
1080 <div>
1081 <label class="wiki-field-label" for="wiki-edit-msg">
1082 Revision message (optional)
1083 </label>
1084 <input
1085 id="wiki-edit-msg"
1086 type="text"
1087 name="message"
1088 placeholder="What changed?"
1089 aria-label="Revision message"
1090 class="wiki-input"
1091 />
1092 </div>
1093 <div class="wiki-editor-foot">
1094 <span class="wiki-editor-hint">
1095 Next revision will be r{page.revision + 1}.
1096 </span>
1097 <a
1098 class="wiki-btn wiki-btn-ghost"
1099 href={`/${ownerName}/${repoName}/wiki/${slug}`}
1100 >
1101 Cancel
1102 </a>
1103 <button type="submit" class="wiki-btn wiki-btn-primary">
1104 Save revision
1105 </button>
1106 </div>
1107 </form>
1108 </div>
1109 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1110 </Layout>
1111 );
1112 }
1113);
1114
1115// Save edit
1116wikiRoutes.post(
1117 "/:owner/:repo/wiki/:slug/edit",
1118 requireAuth,
1119 async (c) => {
1120 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1121 const user = c.get("user")!;
1122 const resolved = await resolveRepo(ownerName, repoName);
1123 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1124
1125 const form = await c.req.formData();
1126 const title = (form.get("title") as string || "").trim();
1127 const body = (form.get("body") as string || "").trim();
1128 const message = (form.get("message") as string || "").trim();
1129 if (!title) {
1130 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
1131 }
1132
1133 try {
1134 const [page] = await db
1135 .select()
1136 .from(wikiPages)
1137 .where(
1138 and(
1139 eq(wikiPages.repositoryId, resolved.repo.id),
1140 eq(wikiPages.slug, slug)
1141 )
1142 )
1143 .limit(1);
1144 if (page) {
1145 const nextRev = page.revision + 1;
1146 await db
1147 .update(wikiPages)
1148 .set({
1149 title,
1150 body,
1151 revision: nextRev,
1152 updatedAt: new Date(),
1153 updatedBy: user.id,
1154 })
1155 .where(eq(wikiPages.id, page.id));
1156 await db.insert(wikiRevisions).values({
1157 pageId: page.id,
1158 revision: nextRev,
1159 title,
1160 body,
1161 message: message || null,
1162 authorId: user.id,
1163 });
1164 }
1165 } catch {
1166 // swallow
1167 }
1168 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1169 }
1170);
1171
1172// Delete
1173wikiRoutes.post(
1174 "/:owner/:repo/wiki/:slug/delete",
1175 requireAuth,
1176 async (c) => {
1177 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1178 const user = c.get("user")!;
1179 const resolved = await resolveRepo(ownerName, repoName);
1180 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1181 if (user.id !== resolved.repo.ownerId) {
1182 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1183 }
1184 try {
1185 await db
1186 .delete(wikiPages)
1187 .where(
1188 and(
1189 eq(wikiPages.repositoryId, resolved.repo.id),
1190 eq(wikiPages.slug, slug)
1191 )
1192 );
1193 } catch {
1194 // swallow
1195 }
1196 return c.redirect(`/${ownerName}/${repoName}/wiki`);
1197 }
1198);
1199
1200// History
1201wikiRoutes.get(
1202 "/:owner/:repo/wiki/:slug/history",
1203 softAuth,
1204 async (c) => {
1205 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1206 const user = c.get("user");
1207 const resolved = await resolveRepo(ownerName, repoName);
1208 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1209
1210 let page: any = null;
1211 let revs: any[] = [];
1212 try {
1213 const [row] = await db
1214 .select()
1215 .from(wikiPages)
1216 .where(
1217 and(
1218 eq(wikiPages.repositoryId, resolved.repo.id),
1219 eq(wikiPages.slug, slug)
1220 )
1221 )
1222 .limit(1);
1223 if (row) {
1224 page = row;
1225 revs = await db
1226 .select({
1227 r: wikiRevisions,
1228 author: { username: users.username },
1229 })
1230 .from(wikiRevisions)
1231 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
1232 .where(eq(wikiRevisions.pageId, page.id))
1233 .orderBy(desc(wikiRevisions.revision));
1234 }
1235 } catch {
1236 // leave null
1237 }
1238 if (!page) return c.html(notFound(user, "Page not found"), 404);
1239
1240 return c.html(
1241 <Layout title={`${page.title} — history`} user={user}>
1242 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1243 <div class="wiki-wrap">
1244 <header class="wiki-head">
1245 <div class="wiki-head-text">
1246 <div class="wiki-eyebrow">
1247 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1248 Repository · Wiki · History
1249 </div>
1250 <h1 class="wiki-title">
1251 <span class="wiki-title-grad">{page.title}</span>
1252 </h1>
1253 <p class="wiki-sub">
1254 Every revision, newest first.{" "}
1255 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>
1256 View current page
1257 </a>.
1258 </p>
1259 </div>
1260 </header>
1261
1262 <div class="wiki-hist">
1e162a8Claude1263 {revs.map((rv) => (
a1be1e1Claude1264 <div class="wiki-hist-row">
1265 <div style="min-width:0">
1e162a8Claude1266 <a
1267 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
a1be1e1Claude1268 class="wiki-hist-name"
1e162a8Claude1269 >
a1be1e1Claude1270 r{rv.r.revision}
1e162a8Claude1271 </a>
1272 {rv.r.message && (
a1be1e1Claude1273 <span class="wiki-hist-msg">{rv.r.message}</span>
1e162a8Claude1274 )}
a1be1e1Claude1275 </div>
1276 <div class="wiki-hist-meta">
1277 <span>@{rv.author.username}</span>
1e162a8Claude1278 {user && user.id === resolved.repo.ownerId &&
1279 rv.r.revision !== page.revision && (
a1be1e1Claude1280 <form
1281 method="post"
1282 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
1283 style="display:inline"
1284 >
1285 <button
1286 type="submit"
1287 class="wiki-btn wiki-btn-ghost"
1288 style="padding:5px 10px;font-size:11.5px"
1e162a8Claude1289 >
a1be1e1Claude1290 Revert
1291 </button>
1292 </form>
1e162a8Claude1293 )}
a1be1e1Claude1294 </div>
1295 </div>
1e162a8Claude1296 ))}
a1be1e1Claude1297 </div>
1298 </div>
1299 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1300 </Layout>
1301 );
1302 }
1303);
1304
1305// View revision
1306wikiRoutes.get(
1307 "/:owner/:repo/wiki/:slug/revisions/:rev",
1308 softAuth,
1309 async (c) => {
1310 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1311 const user = c.get("user");
1312 const rev = Number(c.req.param("rev"));
1313 const resolved = await resolveRepo(ownerName, repoName);
1314 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
1315
1316 let rv: any = null;
1317 try {
1318 const [page] = await db
1319 .select()
1320 .from(wikiPages)
1321 .where(
1322 and(
1323 eq(wikiPages.repositoryId, resolved.repo.id),
1324 eq(wikiPages.slug, slug)
1325 )
1326 )
1327 .limit(1);
1328 if (page) {
1329 const [r] = await db
1330 .select()
1331 .from(wikiRevisions)
1332 .where(
1333 and(
1334 eq(wikiRevisions.pageId, page.id),
1335 eq(wikiRevisions.revision, rev)
1336 )
1337 )
1338 .limit(1);
1339 if (r) rv = r;
1340 }
1341 } catch {
1342 // leave null
1343 }
1344 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
1345
1346 return c.html(
1347 <Layout title={`${rv.title} @ r${rev}`} user={user}>
1348 <RepoHeader owner={ownerName} repo={repoName} />
a1be1e1Claude1349 <div class="wiki-wrap">
1350 <header class="wiki-head">
1351 <div class="wiki-head-text">
1352 <div class="wiki-eyebrow">
1353 <span class="wiki-eyebrow-dot" aria-hidden="true" />
1354 Repository · Wiki · Revision r{rev}
1355 </div>
1356 <h1 class="wiki-title">
1357 <span class="wiki-title-grad">{rv.title}</span>
1358 </h1>
1359 <p class="wiki-sub">
1360 Viewing revision {rev}.{" "}
1361 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>
1362 Back to current
1363 </a>.
1364 </p>
1365 </div>
1366 </header>
1367 <article
1368 class="wiki-page-body"
1369 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
1370 />
1e162a8Claude1371 </div>
a1be1e1Claude1372 <style dangerouslySetInnerHTML={{ __html: wikiStyles }} />
1e162a8Claude1373 </Layout>
1374 );
1375 }
1376);
1377
1378// Revert
1379wikiRoutes.post(
1380 "/:owner/:repo/wiki/:slug/revert/:rev",
1381 requireAuth,
1382 async (c) => {
1383 const { owner: ownerName, repo: repoName, slug } = c.req.param();
1384 const rev = Number(c.req.param("rev"));
1385 const user = c.get("user")!;
1386 const resolved = await resolveRepo(ownerName, repoName);
1387 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
1388 if (user.id !== resolved.repo.ownerId) {
1389 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1390 }
1391 try {
1392 const [page] = await db
1393 .select()
1394 .from(wikiPages)
1395 .where(
1396 and(
1397 eq(wikiPages.repositoryId, resolved.repo.id),
1398 eq(wikiPages.slug, slug)
1399 )
1400 )
1401 .limit(1);
1402 if (!page) {
1403 return c.redirect(`/${ownerName}/${repoName}/wiki`);
1404 }
1405 const [target] = await db
1406 .select()
1407 .from(wikiRevisions)
1408 .where(
1409 and(
1410 eq(wikiRevisions.pageId, page.id),
1411 eq(wikiRevisions.revision, rev)
1412 )
1413 )
1414 .limit(1);
1415 if (target) {
1416 const nextRev = page.revision + 1;
1417 await db
1418 .update(wikiPages)
1419 .set({
1420 title: target.title,
1421 body: target.body,
1422 revision: nextRev,
1423 updatedAt: new Date(),
1424 updatedBy: user.id,
1425 })
1426 .where(eq(wikiPages.id, page.id));
1427 await db.insert(wikiRevisions).values({
1428 pageId: page.id,
1429 revision: nextRev,
1430 title: target.title,
1431 body: target.body,
1432 message: `Reverted to revision ${rev}`,
1433 authorId: user.id,
1434 });
1435 }
1436 } catch {
1437 // swallow
1438 }
1439 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
1440 }
1441);
1442
1443export default wikiRoutes;