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.tsxBlame755 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 *
8 * Never throws.
9 */
10
11import { Hono } from "hono";
12import { and, eq, desc } from "drizzle-orm";
13import { db } from "../db";
14import {
15 wikiPages,
16 wikiRevisions,
17 repositories,
18 users,
19} from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader } from "../views/components";
22import { renderMarkdown } from "../lib/markdown";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25
26/** lowercase-alphanumerics joined by single dashes, trimmed. */
27export function slugifyTitle(title: string): string {
28 return title
29 .toLowerCase()
30 .normalize("NFKD")
31 .replace(/[^a-z0-9\s-]/g, "")
32 .replace(/\s+/g, "-")
33 .replace(/-+/g, "-")
34 .replace(/^-+|-+$/g, "");
35}
36
37const wikiRoutes = new Hono<AuthEnv>();
38
39async function resolveRepo(ownerName: string, repoName: string) {
40 try {
41 const [owner] = await db
42 .select()
43 .from(users)
44 .where(eq(users.username, ownerName))
45 .limit(1);
46 if (!owner) return null;
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, owner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repo) return null;
58 return { owner, repo };
59 } catch {
60 return null;
61 }
62}
63
64function notFound(user: any, label = "Page not found") {
65 return (
66 <Layout title={label} user={user}>
67 <div class="empty-state">
68 <h2>{label}</h2>
69 </div>
70 </Layout>
71 );
72}
73
74function WikiSidebar(props: {
75 ownerName: string;
76 repoName: string;
77 pages: { slug: string; title: string }[];
78 user: any;
79}) {
80 const { ownerName, repoName, pages, user } = props;
81 return (
82 <aside style="min-width: 220px; border-right: 1px solid var(--border); padding-right: 16px;">
83 <div style="font-weight: 600; margin-bottom: 8px;">Pages</div>
84 <ul style="list-style: none; padding: 0; margin: 0;">
85 {pages.map((p) => (
86 <li>
87 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>{p.title}</a>
88 </li>
89 ))}
90 </ul>
91 {user && (
92 <div style="margin-top: 16px;">
93 <a
94 href={`/${ownerName}/${repoName}/wiki/new`}
95 class="btn btn-primary"
96 >
97 + New page
98 </a>
99 </div>
100 )}
101 </aside>
102 );
103}
104
105async function listPages(repoId: string) {
106 try {
107 return await db
108 .select({ slug: wikiPages.slug, title: wikiPages.title })
109 .from(wikiPages)
110 .where(eq(wikiPages.repositoryId, repoId))
111 .orderBy(wikiPages.title);
112 } catch {
113 return [];
114 }
115}
116
117// Root — render "home" page if exists, else CTA
118wikiRoutes.get("/:owner/:repo/wiki", softAuth, async (c) => {
119 const { owner: ownerName, repo: repoName } = c.req.param();
120 const user = c.get("user");
121 const resolved = await resolveRepo(ownerName, repoName);
122 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
123 const pages = await listPages(resolved.repo.id);
124
125 let home: any = null;
126 try {
127 const [row] = await db
128 .select()
129 .from(wikiPages)
130 .where(
131 and(
132 eq(wikiPages.repositoryId, resolved.repo.id),
133 eq(wikiPages.slug, "home")
134 )
135 )
136 .limit(1);
137 if (row) home = row;
138 } catch {
139 // leave null
140 }
141
142 return c.html(
143 <Layout title={`Wiki — ${ownerName}/${repoName}`} user={user}>
144 <RepoHeader owner={ownerName} repo={repoName} />
145 <div style="display: flex; gap: 24px; margin-top: 16px;">
146 <WikiSidebar
147 ownerName={ownerName}
148 repoName={repoName}
149 pages={pages}
150 user={user}
151 />
152 <main style="flex: 1;">
153 {home ? (
154 <>
155 <h1>{home.title}</h1>
156 <div
157 dangerouslySetInnerHTML={{
158 __html: renderMarkdown(home.body || ""),
159 }}
160 />
161 </>
162 ) : (
163 <div class="empty-state">
164 <h2>No wiki yet</h2>
165 {user ? (
166 <a
167 href={`/${ownerName}/${repoName}/wiki/new`}
168 class="btn btn-primary"
169 >
170 Create the Home page
171 </a>
172 ) : (
173 <p>Nothing here yet.</p>
174 )}
175 </div>
176 )}
177 </main>
178 </div>
179 </Layout>
180 );
181});
182
183// All pages index
184wikiRoutes.get("/:owner/:repo/wiki/pages", softAuth, async (c) => {
185 const { owner: ownerName, repo: repoName } = c.req.param();
186 const user = c.get("user");
187 const resolved = await resolveRepo(ownerName, repoName);
188 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
189 let rows: any[] = [];
190 try {
191 rows = await db
192 .select()
193 .from(wikiPages)
194 .where(eq(wikiPages.repositoryId, resolved.repo.id))
195 .orderBy(wikiPages.title);
196 } catch {
197 rows = [];
198 }
199 return c.html(
200 <Layout title={`Wiki pages — ${ownerName}/${repoName}`} user={user}>
201 <RepoHeader owner={ownerName} repo={repoName} />
202 <h2 style="margin-top: 16px;">Wiki pages</h2>
203 {rows.length === 0 ? (
204 <div class="empty-state">
205 <p>No pages.</p>
206 </div>
207 ) : (
208 <table class="file-table">
209 <tbody>
210 {rows.map((p) => (
211 <tr>
212 <td>
213 <a href={`/${ownerName}/${repoName}/wiki/${p.slug}`}>
214 {p.title}
215 </a>
216 </td>
217 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
218 r{p.revision}
219 </td>
220 </tr>
221 ))}
222 </tbody>
223 </table>
224 )}
225 </Layout>
226 );
227});
228
229// New page form
230wikiRoutes.get("/:owner/:repo/wiki/new", requireAuth, async (c) => {
231 const { owner: ownerName, repo: repoName } = c.req.param();
232 const user = c.get("user");
233 const resolved = await resolveRepo(ownerName, repoName);
234 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
235 return c.html(
236 <Layout title="New wiki page" user={user}>
237 <RepoHeader owner={ownerName} repo={repoName} />
238 <h2 style="margin-top: 20px;">New wiki page</h2>
239 <form
001af43Claude240 method="post"
1e162a8Claude241 action={`/${ownerName}/${repoName}/wiki`}
242 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
243 >
244 <input
245 type="text"
246 name="title"
247 placeholder="Page title"
248 required
2c3ba6ecopilot-swe-agent[bot]249 aria-label="Page title"
1e162a8Claude250 style="padding: 8px;"
251 />
252 <textarea
253 name="body"
254 rows={16}
255 placeholder="Markdown body"
256 style="padding: 8px; font-family: monospace;"
257 ></textarea>
258 <button type="submit" class="btn btn-primary">
259 Create page
260 </button>
261 </form>
262 </Layout>
263 );
264});
265
266// Create
267wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
268 const { owner: ownerName, repo: repoName } = c.req.param();
269 const user = c.get("user")!;
270 const resolved = await resolveRepo(ownerName, repoName);
271 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
272
273 const form = await c.req.formData();
274 const title = (form.get("title") as string || "").trim();
275 const body = (form.get("body") as string || "").trim();
276 if (!title) {
277 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
278 }
279 const slug = slugifyTitle(title) || "page";
280
281 try {
282 const [page] = await db
283 .insert(wikiPages)
284 .values({
285 repositoryId: resolved.repo.id,
286 slug,
287 title,
288 body,
289 revision: 1,
290 updatedBy: user.id,
291 })
292 .returning({ id: wikiPages.id });
293 await db.insert(wikiRevisions).values({
294 pageId: page.id,
295 revision: 1,
296 title,
297 body,
298 message: "Initial",
299 authorId: user.id,
300 });
301 } catch {
302 // likely unique-violation on slug; redirect to the existing page
303 }
304 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
305});
306
307// View page
308wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
309 const { owner: ownerName, repo: repoName, slug } = c.req.param();
310 const user = c.get("user");
311 const resolved = await resolveRepo(ownerName, repoName);
312 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
313
314 let page: any = null;
315 try {
316 const [row] = await db
317 .select()
318 .from(wikiPages)
319 .where(
320 and(
321 eq(wikiPages.repositoryId, resolved.repo.id),
322 eq(wikiPages.slug, slug)
323 )
324 )
325 .limit(1);
326 if (row) page = row;
327 } catch {
328 // leave null
329 }
330 if (!page) return c.html(notFound(user, "Page not found"), 404);
331 const pages = await listPages(resolved.repo.id);
332 const isOwner = user && user.id === resolved.repo.ownerId;
333
334 return c.html(
335 <Layout title={`${page.title} — wiki`} user={user}>
336 <RepoHeader owner={ownerName} repo={repoName} />
337 <div style="display: flex; gap: 24px; margin-top: 16px;">
338 <WikiSidebar
339 ownerName={ownerName}
340 repoName={repoName}
341 pages={pages}
342 user={user}
343 />
344 <main style="flex: 1;">
345 <div style="display: flex; justify-content: space-between; align-items: center;">
346 <h1 style="margin: 0;">{page.title}</h1>
347 <div style="display: flex; gap: 8px;">
348 <a
349 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
350 class="btn"
351 >
352 History
353 </a>
354 {user && (
355 <a
356 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
357 class="btn"
358 >
359 Edit
360 </a>
361 )}
362 {isOwner && (
363 <form
001af43Claude364 method="post"
1e162a8Claude365 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
366 style="display: inline;"
367 onsubmit="return confirm('Delete this page?')"
368 >
369 <button type="submit" class="btn">
370 Delete
371 </button>
372 </form>
373 )}
374 </div>
375 </div>
376 <div
377 style="margin-top: 16px;"
378 dangerouslySetInnerHTML={{
379 __html: renderMarkdown(page.body || ""),
380 }}
381 />
382 </main>
383 </div>
384 </Layout>
385 );
386});
387
388// Edit form
389wikiRoutes.get(
390 "/:owner/:repo/wiki/:slug/edit",
391 requireAuth,
392 async (c) => {
393 const { owner: ownerName, repo: repoName, slug } = c.req.param();
394 const user = c.get("user");
395 const resolved = await resolveRepo(ownerName, repoName);
396 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
397
398 let page: any = null;
399 try {
400 const [row] = await db
401 .select()
402 .from(wikiPages)
403 .where(
404 and(
405 eq(wikiPages.repositoryId, resolved.repo.id),
406 eq(wikiPages.slug, slug)
407 )
408 )
409 .limit(1);
410 if (row) page = row;
411 } catch {
412 // leave null
413 }
414 if (!page) return c.html(notFound(user, "Page not found"), 404);
415
416 return c.html(
417 <Layout title={`Edit ${page.title}`} user={user}>
418 <RepoHeader owner={ownerName} repo={repoName} />
419 <h2 style="margin-top: 20px;">Edit "{page.title}"</h2>
420 <form
001af43Claude421 method="post"
1e162a8Claude422 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
423 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
424 >
425 <input
426 type="text"
427 name="title"
428 value={page.title}
429 required
2c3ba6ecopilot-swe-agent[bot]430 aria-label="Page title"
1e162a8Claude431 style="padding: 8px;"
432 />
433 <textarea
434 name="body"
435 rows={16}
436 style="padding: 8px; font-family: monospace;"
437 >
438 {page.body}
439 </textarea>
440 <input
441 type="text"
442 name="message"
443 placeholder="Revision message (optional)"
2c3ba6ecopilot-swe-agent[bot]444 aria-label="Revision message"
1e162a8Claude445 style="padding: 8px;"
446 />
447 <button type="submit" class="btn btn-primary">
448 Save
449 </button>
450 </form>
451 </Layout>
452 );
453 }
454);
455
456// Save edit
457wikiRoutes.post(
458 "/:owner/:repo/wiki/:slug/edit",
459 requireAuth,
460 async (c) => {
461 const { owner: ownerName, repo: repoName, slug } = c.req.param();
462 const user = c.get("user")!;
463 const resolved = await resolveRepo(ownerName, repoName);
464 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
465
466 const form = await c.req.formData();
467 const title = (form.get("title") as string || "").trim();
468 const body = (form.get("body") as string || "").trim();
469 const message = (form.get("message") as string || "").trim();
470 if (!title) {
471 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
472 }
473
474 try {
475 const [page] = await db
476 .select()
477 .from(wikiPages)
478 .where(
479 and(
480 eq(wikiPages.repositoryId, resolved.repo.id),
481 eq(wikiPages.slug, slug)
482 )
483 )
484 .limit(1);
485 if (page) {
486 const nextRev = page.revision + 1;
487 await db
488 .update(wikiPages)
489 .set({
490 title,
491 body,
492 revision: nextRev,
493 updatedAt: new Date(),
494 updatedBy: user.id,
495 })
496 .where(eq(wikiPages.id, page.id));
497 await db.insert(wikiRevisions).values({
498 pageId: page.id,
499 revision: nextRev,
500 title,
501 body,
502 message: message || null,
503 authorId: user.id,
504 });
505 }
506 } catch {
507 // swallow
508 }
509 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
510 }
511);
512
513// Delete
514wikiRoutes.post(
515 "/:owner/:repo/wiki/:slug/delete",
516 requireAuth,
517 async (c) => {
518 const { owner: ownerName, repo: repoName, slug } = c.req.param();
519 const user = c.get("user")!;
520 const resolved = await resolveRepo(ownerName, repoName);
521 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
522 if (user.id !== resolved.repo.ownerId) {
523 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
524 }
525 try {
526 await db
527 .delete(wikiPages)
528 .where(
529 and(
530 eq(wikiPages.repositoryId, resolved.repo.id),
531 eq(wikiPages.slug, slug)
532 )
533 );
534 } catch {
535 // swallow
536 }
537 return c.redirect(`/${ownerName}/${repoName}/wiki`);
538 }
539);
540
541// History
542wikiRoutes.get(
543 "/:owner/:repo/wiki/:slug/history",
544 softAuth,
545 async (c) => {
546 const { owner: ownerName, repo: repoName, slug } = c.req.param();
547 const user = c.get("user");
548 const resolved = await resolveRepo(ownerName, repoName);
549 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
550
551 let page: any = null;
552 let revs: any[] = [];
553 try {
554 const [row] = await db
555 .select()
556 .from(wikiPages)
557 .where(
558 and(
559 eq(wikiPages.repositoryId, resolved.repo.id),
560 eq(wikiPages.slug, slug)
561 )
562 )
563 .limit(1);
564 if (row) {
565 page = row;
566 revs = await db
567 .select({
568 r: wikiRevisions,
569 author: { username: users.username },
570 })
571 .from(wikiRevisions)
572 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
573 .where(eq(wikiRevisions.pageId, page.id))
574 .orderBy(desc(wikiRevisions.revision));
575 }
576 } catch {
577 // leave null
578 }
579 if (!page) return c.html(notFound(user, "Page not found"), 404);
580
581 return c.html(
582 <Layout title={`${page.title} — history`} user={user}>
583 <RepoHeader owner={ownerName} repo={repoName} />
584 <h2 style="margin-top: 20px;">
585 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{page.title}</a> —
586 history
587 </h2>
588 <table class="file-table">
589 <tbody>
590 {revs.map((rv) => (
591 <tr>
592 <td>
593 <a
594 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
595 >
596 Revision {rv.r.revision}
597 </a>
598 {rv.r.message && (
599 <span style="color: var(--text-muted);">
600 {" "}
601 — {rv.r.message}
602 </span>
603 )}
604 </td>
605 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
606 by @{rv.author.username}
607 {user && user.id === resolved.repo.ownerId &&
608 rv.r.revision !== page.revision && (
609 <>
610 {" "}
611 ·{" "}
612 <form
001af43Claude613 method="post"
1e162a8Claude614 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
615 style="display: inline;"
616 >
617 <button type="submit" class="btn" style="font-size: 11px;">
618 Revert to this
619 </button>
620 </form>
621 </>
622 )}
623 </td>
624 </tr>
625 ))}
626 </tbody>
627 </table>
628 </Layout>
629 );
630 }
631);
632
633// View revision
634wikiRoutes.get(
635 "/:owner/:repo/wiki/:slug/revisions/:rev",
636 softAuth,
637 async (c) => {
638 const { owner: ownerName, repo: repoName, slug } = c.req.param();
639 const user = c.get("user");
640 const rev = Number(c.req.param("rev"));
641 const resolved = await resolveRepo(ownerName, repoName);
642 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
643
644 let rv: any = null;
645 try {
646 const [page] = await db
647 .select()
648 .from(wikiPages)
649 .where(
650 and(
651 eq(wikiPages.repositoryId, resolved.repo.id),
652 eq(wikiPages.slug, slug)
653 )
654 )
655 .limit(1);
656 if (page) {
657 const [r] = await db
658 .select()
659 .from(wikiRevisions)
660 .where(
661 and(
662 eq(wikiRevisions.pageId, page.id),
663 eq(wikiRevisions.revision, rev)
664 )
665 )
666 .limit(1);
667 if (r) rv = r;
668 }
669 } catch {
670 // leave null
671 }
672 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
673
674 return c.html(
675 <Layout title={`${rv.title} @ r${rev}`} user={user}>
676 <RepoHeader owner={ownerName} repo={repoName} />
677 <div style="margin-top: 16px; color: var(--text-muted);">
678 Viewing revision {rev} of{" "}
679 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{rv.title}</a>
680 </div>
681 <h1>{rv.title}</h1>
682 <div
683 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
684 />
685 </Layout>
686 );
687 }
688);
689
690// Revert
691wikiRoutes.post(
692 "/:owner/:repo/wiki/:slug/revert/:rev",
693 requireAuth,
694 async (c) => {
695 const { owner: ownerName, repo: repoName, slug } = c.req.param();
696 const rev = Number(c.req.param("rev"));
697 const user = c.get("user")!;
698 const resolved = await resolveRepo(ownerName, repoName);
699 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
700 if (user.id !== resolved.repo.ownerId) {
701 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
702 }
703 try {
704 const [page] = await db
705 .select()
706 .from(wikiPages)
707 .where(
708 and(
709 eq(wikiPages.repositoryId, resolved.repo.id),
710 eq(wikiPages.slug, slug)
711 )
712 )
713 .limit(1);
714 if (!page) {
715 return c.redirect(`/${ownerName}/${repoName}/wiki`);
716 }
717 const [target] = await db
718 .select()
719 .from(wikiRevisions)
720 .where(
721 and(
722 eq(wikiRevisions.pageId, page.id),
723 eq(wikiRevisions.revision, rev)
724 )
725 )
726 .limit(1);
727 if (target) {
728 const nextRev = page.revision + 1;
729 await db
730 .update(wikiPages)
731 .set({
732 title: target.title,
733 body: target.body,
734 revision: nextRev,
735 updatedAt: new Date(),
736 updatedBy: user.id,
737 })
738 .where(eq(wikiPages.id, page.id));
739 await db.insert(wikiRevisions).values({
740 pageId: page.id,
741 revision: nextRev,
742 title: target.title,
743 body: target.body,
744 message: `Reverted to revision ${rev}`,
745 authorId: user.id,
746 });
747 }
748 } catch {
749 // swallow
750 }
751 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
752 }
753);
754
755export default wikiRoutes;