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.tsxBlame752 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 *
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
240 method="POST"
241 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
249 style="padding: 8px;"
250 />
251 <textarea
252 name="body"
253 rows={16}
254 placeholder="Markdown body"
255 style="padding: 8px; font-family: monospace;"
256 ></textarea>
257 <button type="submit" class="btn btn-primary">
258 Create page
259 </button>
260 </form>
261 </Layout>
262 );
263});
264
265// Create
266wikiRoutes.post("/:owner/:repo/wiki", requireAuth, async (c) => {
267 const { owner: ownerName, repo: repoName } = c.req.param();
268 const user = c.get("user")!;
269 const resolved = await resolveRepo(ownerName, repoName);
270 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
271
272 const form = await c.req.formData();
273 const title = (form.get("title") as string || "").trim();
274 const body = (form.get("body") as string || "").trim();
275 if (!title) {
276 return c.redirect(`/${ownerName}/${repoName}/wiki/new`);
277 }
278 const slug = slugifyTitle(title) || "page";
279
280 try {
281 const [page] = await db
282 .insert(wikiPages)
283 .values({
284 repositoryId: resolved.repo.id,
285 slug,
286 title,
287 body,
288 revision: 1,
289 updatedBy: user.id,
290 })
291 .returning({ id: wikiPages.id });
292 await db.insert(wikiRevisions).values({
293 pageId: page.id,
294 revision: 1,
295 title,
296 body,
297 message: "Initial",
298 authorId: user.id,
299 });
300 } catch {
301 // likely unique-violation on slug; redirect to the existing page
302 }
303 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
304});
305
306// View page
307wikiRoutes.get("/:owner/:repo/wiki/:slug", softAuth, async (c) => {
308 const { owner: ownerName, repo: repoName, slug } = c.req.param();
309 const user = c.get("user");
310 const resolved = await resolveRepo(ownerName, repoName);
311 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
312
313 let page: any = null;
314 try {
315 const [row] = await db
316 .select()
317 .from(wikiPages)
318 .where(
319 and(
320 eq(wikiPages.repositoryId, resolved.repo.id),
321 eq(wikiPages.slug, slug)
322 )
323 )
324 .limit(1);
325 if (row) page = row;
326 } catch {
327 // leave null
328 }
329 if (!page) return c.html(notFound(user, "Page not found"), 404);
330 const pages = await listPages(resolved.repo.id);
331 const isOwner = user && user.id === resolved.repo.ownerId;
332
333 return c.html(
334 <Layout title={`${page.title} — wiki`} user={user}>
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <div style="display: flex; gap: 24px; margin-top: 16px;">
337 <WikiSidebar
338 ownerName={ownerName}
339 repoName={repoName}
340 pages={pages}
341 user={user}
342 />
343 <main style="flex: 1;">
344 <div style="display: flex; justify-content: space-between; align-items: center;">
345 <h1 style="margin: 0;">{page.title}</h1>
346 <div style="display: flex; gap: 8px;">
347 <a
348 href={`/${ownerName}/${repoName}/wiki/${slug}/history`}
349 class="btn"
350 >
351 History
352 </a>
353 {user && (
354 <a
355 href={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
356 class="btn"
357 >
358 Edit
359 </a>
360 )}
361 {isOwner && (
362 <form
363 method="POST"
364 action={`/${ownerName}/${repoName}/wiki/${slug}/delete`}
365 style="display: inline;"
366 onsubmit="return confirm('Delete this page?')"
367 >
368 <button type="submit" class="btn">
369 Delete
370 </button>
371 </form>
372 )}
373 </div>
374 </div>
375 <div
376 style="margin-top: 16px;"
377 dangerouslySetInnerHTML={{
378 __html: renderMarkdown(page.body || ""),
379 }}
380 />
381 </main>
382 </div>
383 </Layout>
384 );
385});
386
387// Edit form
388wikiRoutes.get(
389 "/:owner/:repo/wiki/:slug/edit",
390 requireAuth,
391 async (c) => {
392 const { owner: ownerName, repo: repoName, slug } = c.req.param();
393 const user = c.get("user");
394 const resolved = await resolveRepo(ownerName, repoName);
395 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
396
397 let page: any = null;
398 try {
399 const [row] = await db
400 .select()
401 .from(wikiPages)
402 .where(
403 and(
404 eq(wikiPages.repositoryId, resolved.repo.id),
405 eq(wikiPages.slug, slug)
406 )
407 )
408 .limit(1);
409 if (row) page = row;
410 } catch {
411 // leave null
412 }
413 if (!page) return c.html(notFound(user, "Page not found"), 404);
414
415 return c.html(
416 <Layout title={`Edit ${page.title}`} user={user}>
417 <RepoHeader owner={ownerName} repo={repoName} />
418 <h2 style="margin-top: 20px;">Edit "{page.title}"</h2>
419 <form
420 method="POST"
421 action={`/${ownerName}/${repoName}/wiki/${slug}/edit`}
422 style="display: flex; flex-direction: column; gap: 12px; margin-top: 16px;"
423 >
424 <input
425 type="text"
426 name="title"
427 value={page.title}
428 required
429 style="padding: 8px;"
430 />
431 <textarea
432 name="body"
433 rows={16}
434 style="padding: 8px; font-family: monospace;"
435 >
436 {page.body}
437 </textarea>
438 <input
439 type="text"
440 name="message"
441 placeholder="Revision message (optional)"
442 style="padding: 8px;"
443 />
444 <button type="submit" class="btn btn-primary">
445 Save
446 </button>
447 </form>
448 </Layout>
449 );
450 }
451);
452
453// Save edit
454wikiRoutes.post(
455 "/:owner/:repo/wiki/:slug/edit",
456 requireAuth,
457 async (c) => {
458 const { owner: ownerName, repo: repoName, slug } = c.req.param();
459 const user = c.get("user")!;
460 const resolved = await resolveRepo(ownerName, repoName);
461 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
462
463 const form = await c.req.formData();
464 const title = (form.get("title") as string || "").trim();
465 const body = (form.get("body") as string || "").trim();
466 const message = (form.get("message") as string || "").trim();
467 if (!title) {
468 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}/edit`);
469 }
470
471 try {
472 const [page] = await db
473 .select()
474 .from(wikiPages)
475 .where(
476 and(
477 eq(wikiPages.repositoryId, resolved.repo.id),
478 eq(wikiPages.slug, slug)
479 )
480 )
481 .limit(1);
482 if (page) {
483 const nextRev = page.revision + 1;
484 await db
485 .update(wikiPages)
486 .set({
487 title,
488 body,
489 revision: nextRev,
490 updatedAt: new Date(),
491 updatedBy: user.id,
492 })
493 .where(eq(wikiPages.id, page.id));
494 await db.insert(wikiRevisions).values({
495 pageId: page.id,
496 revision: nextRev,
497 title,
498 body,
499 message: message || null,
500 authorId: user.id,
501 });
502 }
503 } catch {
504 // swallow
505 }
506 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
507 }
508);
509
510// Delete
511wikiRoutes.post(
512 "/:owner/:repo/wiki/:slug/delete",
513 requireAuth,
514 async (c) => {
515 const { owner: ownerName, repo: repoName, slug } = c.req.param();
516 const user = c.get("user")!;
517 const resolved = await resolveRepo(ownerName, repoName);
518 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
519 if (user.id !== resolved.repo.ownerId) {
520 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
521 }
522 try {
523 await db
524 .delete(wikiPages)
525 .where(
526 and(
527 eq(wikiPages.repositoryId, resolved.repo.id),
528 eq(wikiPages.slug, slug)
529 )
530 );
531 } catch {
532 // swallow
533 }
534 return c.redirect(`/${ownerName}/${repoName}/wiki`);
535 }
536);
537
538// History
539wikiRoutes.get(
540 "/:owner/:repo/wiki/:slug/history",
541 softAuth,
542 async (c) => {
543 const { owner: ownerName, repo: repoName, slug } = c.req.param();
544 const user = c.get("user");
545 const resolved = await resolveRepo(ownerName, repoName);
546 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
547
548 let page: any = null;
549 let revs: any[] = [];
550 try {
551 const [row] = await db
552 .select()
553 .from(wikiPages)
554 .where(
555 and(
556 eq(wikiPages.repositoryId, resolved.repo.id),
557 eq(wikiPages.slug, slug)
558 )
559 )
560 .limit(1);
561 if (row) {
562 page = row;
563 revs = await db
564 .select({
565 r: wikiRevisions,
566 author: { username: users.username },
567 })
568 .from(wikiRevisions)
569 .innerJoin(users, eq(wikiRevisions.authorId, users.id))
570 .where(eq(wikiRevisions.pageId, page.id))
571 .orderBy(desc(wikiRevisions.revision));
572 }
573 } catch {
574 // leave null
575 }
576 if (!page) return c.html(notFound(user, "Page not found"), 404);
577
578 return c.html(
579 <Layout title={`${page.title} — history`} user={user}>
580 <RepoHeader owner={ownerName} repo={repoName} />
581 <h2 style="margin-top: 20px;">
582 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{page.title}</a> —
583 history
584 </h2>
585 <table class="file-table">
586 <tbody>
587 {revs.map((rv) => (
588 <tr>
589 <td>
590 <a
591 href={`/${ownerName}/${repoName}/wiki/${slug}/revisions/${rv.r.revision}`}
592 >
593 Revision {rv.r.revision}
594 </a>
595 {rv.r.message && (
596 <span style="color: var(--text-muted);">
597 {" "}
598 — {rv.r.message}
599 </span>
600 )}
601 </td>
602 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
603 by @{rv.author.username}
604 {user && user.id === resolved.repo.ownerId &&
605 rv.r.revision !== page.revision && (
606 <>
607 {" "}
608 ·{" "}
609 <form
610 method="POST"
611 action={`/${ownerName}/${repoName}/wiki/${slug}/revert/${rv.r.revision}`}
612 style="display: inline;"
613 >
614 <button type="submit" class="btn" style="font-size: 11px;">
615 Revert to this
616 </button>
617 </form>
618 </>
619 )}
620 </td>
621 </tr>
622 ))}
623 </tbody>
624 </table>
625 </Layout>
626 );
627 }
628);
629
630// View revision
631wikiRoutes.get(
632 "/:owner/:repo/wiki/:slug/revisions/:rev",
633 softAuth,
634 async (c) => {
635 const { owner: ownerName, repo: repoName, slug } = c.req.param();
636 const user = c.get("user");
637 const rev = Number(c.req.param("rev"));
638 const resolved = await resolveRepo(ownerName, repoName);
639 if (!resolved) return c.html(notFound(user, "Repository not found"), 404);
640
641 let rv: any = null;
642 try {
643 const [page] = await db
644 .select()
645 .from(wikiPages)
646 .where(
647 and(
648 eq(wikiPages.repositoryId, resolved.repo.id),
649 eq(wikiPages.slug, slug)
650 )
651 )
652 .limit(1);
653 if (page) {
654 const [r] = await db
655 .select()
656 .from(wikiRevisions)
657 .where(
658 and(
659 eq(wikiRevisions.pageId, page.id),
660 eq(wikiRevisions.revision, rev)
661 )
662 )
663 .limit(1);
664 if (r) rv = r;
665 }
666 } catch {
667 // leave null
668 }
669 if (!rv) return c.html(notFound(user, "Revision not found"), 404);
670
671 return c.html(
672 <Layout title={`${rv.title} @ r${rev}`} user={user}>
673 <RepoHeader owner={ownerName} repo={repoName} />
674 <div style="margin-top: 16px; color: var(--text-muted);">
675 Viewing revision {rev} of{" "}
676 <a href={`/${ownerName}/${repoName}/wiki/${slug}`}>{rv.title}</a>
677 </div>
678 <h1>{rv.title}</h1>
679 <div
680 dangerouslySetInnerHTML={{ __html: renderMarkdown(rv.body || "") }}
681 />
682 </Layout>
683 );
684 }
685);
686
687// Revert
688wikiRoutes.post(
689 "/:owner/:repo/wiki/:slug/revert/:rev",
690 requireAuth,
691 async (c) => {
692 const { owner: ownerName, repo: repoName, slug } = c.req.param();
693 const rev = Number(c.req.param("rev"));
694 const user = c.get("user")!;
695 const resolved = await resolveRepo(ownerName, repoName);
696 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
697 if (user.id !== resolved.repo.ownerId) {
698 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
699 }
700 try {
701 const [page] = await db
702 .select()
703 .from(wikiPages)
704 .where(
705 and(
706 eq(wikiPages.repositoryId, resolved.repo.id),
707 eq(wikiPages.slug, slug)
708 )
709 )
710 .limit(1);
711 if (!page) {
712 return c.redirect(`/${ownerName}/${repoName}/wiki`);
713 }
714 const [target] = await db
715 .select()
716 .from(wikiRevisions)
717 .where(
718 and(
719 eq(wikiRevisions.pageId, page.id),
720 eq(wikiRevisions.revision, rev)
721 )
722 )
723 .limit(1);
724 if (target) {
725 const nextRev = page.revision + 1;
726 await db
727 .update(wikiPages)
728 .set({
729 title: target.title,
730 body: target.body,
731 revision: nextRev,
732 updatedAt: new Date(),
733 updatedBy: user.id,
734 })
735 .where(eq(wikiPages.id, page.id));
736 await db.insert(wikiRevisions).values({
737 pageId: page.id,
738 revision: nextRev,
739 title: target.title,
740 body: target.body,
741 message: `Reverted to revision ${rev}`,
742 authorId: user.id,
743 });
744 }
745 } catch {
746 // swallow
747 }
748 return c.redirect(`/${ownerName}/${repoName}/wiki/${slug}`);
749 }
750);
751
752export default wikiRoutes;