import { Hono } from "hono"; import { html, raw } from "hono/html"; import { layout } from "../views/layouts/main"; import { requireAuth, type Session } from "../lib/session"; import { csrfField } from "../lib/csrf"; import { isValidTID } from "../lib/validation"; import { createMarkdownContent, getDocumentContentText, } from "../lib/content-types"; import { marked } from "marked"; import type { AppVariables } from "../types"; export const documentRoutes = new Hono<{ Variables: AppVariables }>(); const DOCUMENT_COLLECTION = "site.standard.document"; const PUBLICATION_COLLECTION = "site.standard.publication"; // List all documents documentRoutes.get("/", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const filter = c.req.query("filter") || "all"; try { const response = await session.agent!.com.atproto.repo.listRecords({ repo: session.did!, collection: DOCUMENT_COLLECTION, limit: 100, }); let documents = response.data.records; // Filter by draft/published status if (filter === "drafts") { documents = documents.filter((doc: any) => { const tags = doc.value.tags || []; return tags.includes("draft"); }); } else if (filter === "published") { documents = documents.filter((doc: any) => { const tags = doc.value.tags || []; return !tags.includes("draft"); }); } // Sort by publishedAt or updatedAt documents.sort((a: any, b: any) => { const dateA = new Date( a.value.updatedAt || a.value.publishedAt, ).getTime(); const dateB = new Date( b.value.updatedAt || b.value.publishedAt, ).getTime(); return dateB - dateA; }); const content = html`

Documents

New Document
All Drafts Published
${ documents.length === 0 ? html`

No documents yet. Create your first document.

` : html` ` }
`; return c.html(layout(content, { title: "Documents - sitebase", session })); } catch (error) { console.error("Error fetching documents:", error); const content = html`

Error loading documents. Please try again.

`; return c.html(layout(content, { title: "Documents - sitebase", session })); } }); // New document form documentRoutes.get("/new", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } // Get publication to use as site reference let publicationUri = ""; try { const response = await session.agent!.com.atproto.repo.listRecords({ repo: session.did!, collection: PUBLICATION_COLLECTION, limit: 1, }); if (response.data.records[0]) { publicationUri = response.data.records[0].uri; } } catch (e) { // No publication yet, will need URL } const csrfToken = c.get("csrfToken") as string; const content = html`

New Document

${csrfField(csrfToken)}
The URL path for this document (e.g., /my-post)
Only past dates allowed. Leave empty to use current date when publishing.
`; return c.html(layout(content, { title: "New Document - sitebase", session })); }); // Handle document creation documentRoutes.post("/new", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const body = await c.req.parseBody(); const title = body.title as string; const path = (body.path as string) || undefined; const description = (body.description as string) || undefined; const content = (body.content as string) || undefined; const tagsStr = (body.tags as string) || ""; const action = body.action as string; const publicationUri = body.publicationUri as string; const publishDateStr = body.publishDate as string; // Parse tags let tags = tagsStr .split(",") .map((t) => t.trim()) .filter((t) => t); // If publishing, remove draft tag if (action === "publish") { tags = tags.filter((t) => t !== "draft"); } else if (!tags.includes("draft")) { tags.push("draft"); } const now = new Date().toISOString(); // Determine publish date let publishedAt: string | undefined; if (action === "publish") { if (publishDateStr) { const parsedDate = new Date(publishDateStr); if (!isNaN(parsedDate.getTime())) { publishedAt = parsedDate.toISOString(); } } if (!publishedAt) { publishedAt = now; } } try { const rkey = generateTID(); // Determine site reference let site = publicationUri; if (!site) { // Fall back to a URL if no publication site = `https://${session.handle}.bsky.social`; } const record: Record = { $type: DOCUMENT_COLLECTION, title, site, publishedAt, updatedAt: now, }; if (path) record.path = path.startsWith("/") ? path : `/${path}`; if (description) record.description = description; if (content) { record.content = createMarkdownContent(content); record.textContent = content; } if (tags.length > 0) record.tags = tags; await session.agent!.com.atproto.repo.createRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, record, }); return c.redirect(`/documents/${rkey}`); } catch (error) { console.error("Error creating document:", error); return c.redirect("/documents/new?error=create_failed"); } }); // View single document documentRoutes.get("/:rkey", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); // Validate rkey format if (!isValidTID(rkey)) { return c.redirect("/documents"); } try { const response = await session.agent!.com.atproto.repo.getRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); const doc = response.data.value as any; const isDraft = (doc.tags || []).includes("draft"); const csrfToken = c.get("csrfToken") as string; const content = html`

${doc.title}

${ isDraft ? html`Draft` : html`Published` } ${ doc.publishedAt ? html`Published: ${new Date(doc.publishedAt).toLocaleDateString()}` : "" } ${doc.path ? html`Path: ${doc.path}` : ""}
${ doc.description ? html`

${doc.description}

` : "" }
${(() => { const text = getDocumentContentText(doc); if (!text) return html`

(No content)

`; const htmlContent = marked.parse(text) as string; return html`
${raw(htmlContent)}
`; })()}
Edit ${ isDraft ? html`
${csrfField(csrfToken)}
` : html`
${csrfField(csrfToken)}
` }
${csrfField(csrfToken)}
Back to List
`; return c.html( layout(content, { title: `${doc.title} - sitebase`, session }), ); } catch (error) { console.error("Error fetching document:", error); return c.redirect("/documents"); } }); // Edit document form documentRoutes.get("/:rkey/edit", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); if (!isValidTID(rkey)) { return c.redirect("/documents"); } try { const response = await session.agent!.com.atproto.repo.getRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); const doc = response.data.value as any; const csrfToken = c.get("csrfToken") as string; const content = html`

Edit Document

${csrfField(csrfToken)}
Only past dates allowed. Set to change the published date.
Cancel
`; return c.html( layout(content, { title: `Edit: ${doc.title} - sitebase`, session }), ); } catch (error) { console.error("Error fetching document:", error); return c.redirect("/documents"); } }); // Handle document update documentRoutes.post("/:rkey/edit", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); if (!isValidTID(rkey)) { return c.redirect("/documents"); } const body = await c.req.parseBody(); try { // Get existing record const existing = await session.agent!.com.atproto.repo.getRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); const oldDoc = existing.data.value as any; const title = body.title as string; const path = (body.path as string) || undefined; const description = (body.description as string) || undefined; const content = (body.content as string) || undefined; const tagsStr = (body.tags as string) || ""; const publishDateStr = body.publishDate as string; const tags = tagsStr .split(",") .map((t) => t.trim()) .filter((t) => t); // Determine publishedAt let publishedAt = oldDoc.publishedAt; if (publishDateStr) { const parsedDate = new Date(publishDateStr); if (!isNaN(parsedDate.getTime())) { publishedAt = parsedDate.toISOString(); } } const record: Record = { $type: DOCUMENT_COLLECTION, title, site: oldDoc.site, publishedAt, updatedAt: new Date().toISOString(), }; if (path) record.path = path.startsWith("/") ? path : `/${path}`; if (description) record.description = description; if (content) { record.content = createMarkdownContent(content); record.textContent = content; } if (tags.length > 0) record.tags = tags; await session.agent!.com.atproto.repo.putRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, record, }); return c.redirect(`/documents/${rkey}`); } catch (error) { console.error("Error updating document:", error); return c.redirect(`/documents/${rkey}/edit?error=update_failed`); } }); // Publish document documentRoutes.post("/:rkey/publish", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); if (!isValidTID(rkey)) { return c.redirect("/documents"); } try { const existing = await session.agent!.com.atproto.repo.getRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); const doc = existing.data.value as any; const tags = (doc.tags || []).filter((t: string) => t !== "draft"); const record = { ...doc, tags: tags.length > 0 ? tags : undefined, publishedAt: doc.publishedAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; await session.agent!.com.atproto.repo.putRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, record, }); return c.redirect(`/documents/${rkey}`); } catch (error) { console.error("Error publishing document:", error); return c.redirect(`/documents/${rkey}?error=publish_failed`); } }); // Unpublish document (add draft tag) documentRoutes.post("/:rkey/unpublish", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); if (!isValidTID(rkey)) { return c.redirect("/documents"); } try { const existing = await session.agent!.com.atproto.repo.getRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); const doc = existing.data.value as any; const tags = [...(doc.tags || []), "draft"]; const record = { ...doc, tags, updatedAt: new Date().toISOString(), }; await session.agent!.com.atproto.repo.putRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, record, }); return c.redirect(`/documents/${rkey}`); } catch (error) { console.error("Error unpublishing document:", error); return c.redirect(`/documents/${rkey}?error=unpublish_failed`); } }); // Delete document documentRoutes.post("/:rkey/delete", async (c) => { let session: Session; try { session = requireAuth(c); } catch { return c.redirect("/auth/login"); } const rkey = c.req.param("rkey"); if (!isValidTID(rkey)) { return c.redirect("/documents"); } try { await session.agent!.com.atproto.repo.deleteRecord({ repo: session.did!, collection: DOCUMENT_COLLECTION, rkey, }); return c.redirect("/documents"); } catch (error) { console.error("Error deleting document:", error); return c.redirect(`/documents/${rkey}?error=delete_failed`); } }); // Generate a TID (timestamp-based ID) function generateTID(): string { const now = Date.now() * 1000; const clockId = Math.floor(Math.random() * 1024); const tid = (BigInt(now) << 10n) | BigInt(clockId); return tid.toString(36).padStart(13, "0"); }