de50af6ed620
add the public repo page, file viewer and raw files behind the visibility gate, with the svg timeline, shiki highlighting and kepr 2's look (v3.0.7)
- time
- 2026-09-26 17:57 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- aab0488febf0
↓ kepr-de50af6e.tar.gzatlas at this save
files changed18 files
package.json+1 −1public/css/kepr.css+754 −0public/js/kepr.js+83 −0src/main.ts+1 −1src/mirror/queries.ts+1 −1src/mirror/store.ts+4 −0src/pages/files.ts+132 −0src/pages/highlight.ts+44 −0src/pages/html.ts+42 −0src/pages/layout.ts+112 −0src/pages/markdown.ts+56 −0src/pages/repo.ts+112 −0src/pages/timeline.ts+41 −0src/server/app.ts+22 −2tests/fixture.ts+2 −0tests/pages.test.ts+161 −0tests/server.test.ts+4 −3tests/sync.test.ts+2 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.6",3+ "version": "3.0.7",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+754 −0
@@ -0,0 +1,754 @@1+/* Kepr 3: Kepr 2's look (kepr.uk, gold theme), ported only as far as the pages that exist use it. */2+3+:root {4+ --bg: #090706;5+ --bg-surface: #0e0c09;6+ --bg-elevated: #1b1710;7+ --border: #2a2018;8+ --text: #c8b898;9+ --text-secondary: #7a6a50;10+ --text-muted: #7a6a50;11+ --text-faint: #4a3a28;12+ --text-heading: #e8d8b8;13+ --accent: #c89a5a;14+ --accent-bright: #d9b88c;15+ --accent-dim: #85663c;16+ --accent-flash: #5a88c8;17+ --accent-tint-04: rgba(200, 154, 90, 0.04);18+ --accent-tint-12: rgba(200, 154, 90, 0.12);19+ --sage: #7a9a68;20+ --sans: "Quicksand", system-ui, sans-serif;21+ --mono: "Martian Mono", ui-monospace, monospace;22+}23+24+* {25+ box-sizing: border-box;26+ scrollbar-width: thin;27+ scrollbar-color: var(--border) transparent;28+}29+30+html,31+body {32+ margin: 0;33+ padding: 0;34+ background: var(--bg);35+ color: var(--text);36+ font-family: var(--sans);37+ font-size: 16px;38+ line-height: 1.6;39+ overflow-x: hidden;40+}41+42+a {43+ color: inherit;44+ text-decoration: none;45+}46+a:hover {47+ text-decoration: underline;48+ text-decoration-color: var(--accent);49+ text-decoration-thickness: 1px;50+ text-underline-offset: 4px;51+}52+53+/* ----- top bar ------------------------------------------------------------ */54+55+body > nav {56+ display: flex;57+ align-items: center;58+ padding: 24px 48px;59+ font-size: 0.6875rem;60+ letter-spacing: 0.04em;61+}62+.wordmark {63+ display: flex;64+ color: var(--accent);65+}66+.wordmark:hover {67+ text-decoration: none;68+}69+.wordmark-icon {70+ display: block;71+ width: 32px;72+ height: 32px;73+ color: var(--accent);74+}75+.wordmark-icon polygon {76+ fill: none;77+ stroke: currentColor;78+ stroke-linejoin: round;79+}80+.tri-outer polygon {81+ stroke-width: 2;82+}83+.tri-inner polygon {84+ stroke-width: 1.5;85+}86+.tri-outer,87+.tri-inner {88+ transform-origin: 16px 16px;89+}90+.tri-outer {91+ animation:92+ tri-ccw 12s linear infinite,93+ accent-flash 9s ease-in-out infinite;94+}95+.tri-inner {96+ animation:97+ tri-cw 8s linear infinite,98+ accent-flash 6.7s ease-in-out infinite;99+}100+@keyframes tri-ccw {101+ to {102+ transform: rotate(-360deg);103+ }104+}105+@keyframes tri-cw {106+ to {107+ transform: rotate(360deg);108+ }109+}110+@keyframes accent-flash {111+ 0%,112+ 84%,113+ 100% {114+ color: var(--accent);115+ }116+ 92% {117+ color: var(--accent-flash);118+ }119+}120+.wordmark-by {121+ margin-left: 10px;122+ font-size: 0.625rem;123+ color: var(--text-faint);124+ letter-spacing: 0.03em;125+}126+.nav-links {127+ margin-left: auto;128+ display: inline-flex;129+ gap: 28px;130+ font-family: var(--mono);131+}132+.nav-links a {133+ color: var(--text-secondary);134+}135+136+main {137+ max-width: 980px;138+ margin: 0 auto;139+ padding: 24px 48px 120px;140+}141+142+footer {143+ text-align: center;144+ padding: 80px 24px 48px;145+}146+footer .credit {147+ font-family: var(--mono);148+ font-size: 0.6875rem;149+ color: var(--text-muted);150+}151+152+.mono {153+ font-family: var(--mono);154+ font-size: 0.8125rem;155+ color: var(--text-secondary);156+}157+.muted {158+ color: var(--text-muted);159+}160+161+@keyframes fadeUp {162+ from {163+ opacity: 0;164+ transform: translateY(6px);165+ }166+ to {167+ opacity: 1;168+ transform: translateY(0);169+ }170+}171+@keyframes fadeIn {172+ from {173+ opacity: 0;174+ }175+}176+177+/* ----- repo header -------------------------------------------------------- */178+179+.repo-head {180+ margin: 20px 0 24px;181+}182+.repo-name {183+ font-family: "Comfortaa", var(--sans);184+ font-weight: 700;185+ font-size: 4rem;186+ line-height: 1.05;187+ margin: -0.1em 0 10px;188+ color: var(--text);189+ overflow-wrap: anywhere;190+ animation: fadeUp 280ms ease both;191+}192+.repo-contributor {193+ margin: 4px 0 0;194+ font-size: 0.8125rem;195+ animation: fadeUp 280ms 40ms ease both;196+}197+.repo-contributor a:hover {198+ color: var(--accent);199+}200+.repo-desc,201+.repo-homepage {202+ margin: 0 0 10px;203+ font-size: 1.0625rem;204+ color: var(--text-secondary);205+ line-height: 1.75;206+ animation: fadeUp 280ms 30ms ease both;207+}208+.repo-desc p {209+ margin: 12px 0 0;210+}211+.repo-desc a,212+.repo-homepage a {213+ color: var(--accent);214+}215+.repo-desc code {216+ font-family: var(--mono);217+ font-size: 0.85em;218+ color: var(--accent);219+}220+.lang-badges {221+ display: flex;222+ flex-wrap: wrap;223+ gap: 6px;224+ margin: 8px 0 6px;225+}226+.lang-badge {227+ font-family: var(--mono);228+ font-size: 0.6875rem;229+ color: var(--accent-dim);230+ background: var(--bg-surface);231+ border: 1px solid var(--border);232+ border-radius: 3px;233+ padding: 1px 7px;234+ letter-spacing: 0.02em;235+}236+.repo-meta {237+ margin: 0;238+ font-size: 0.875rem;239+ color: var(--text-muted);240+ line-height: 1.7;241+ animation: fadeUp 280ms 60ms ease both;242+}243+.repo-tabbar {244+ display: flex;245+ align-items: center;246+ justify-content: space-between;247+ flex-wrap: wrap;248+ gap: 14px 20px;249+ margin-top: 22px;250+}251+.repo-tabs {252+ display: flex;253+ gap: 24px;254+ flex-wrap: wrap;255+ font-family: var(--mono);256+ animation: fadeUp 280ms 90ms ease both;257+}258+.repo-tab {259+ font-size: 0.6875rem;260+ color: var(--text-muted);261+ padding: 0 0 11px;262+ border-bottom: 1px solid transparent;263+ transition:264+ color 140ms,265+ border-color 140ms;266+}267+.repo-tab:hover {268+ color: var(--accent);269+ text-decoration: none;270+}271+.install-lines {272+ display: flex;273+ flex-direction: column;274+ align-items: flex-end;275+ gap: 6px;276+ max-width: 100%;277+ margin-left: auto;278+ animation: fadeUp 280ms 120ms ease both;279+}280+.install-line {281+ display: flex;282+ align-items: center;283+ gap: 12px;284+ max-width: 100%;285+ font-family: var(--mono);286+ font-size: 0.75rem;287+ color: var(--text-muted);288+ background: var(--bg-surface);289+ border: 1px solid var(--border);290+ border-radius: 3px;291+ padding: 5px 11px;292+}293+.install-line code {294+ white-space: pre;295+ overflow-x: auto;296+ font-family: inherit;297+}298+.copy-btn {299+ background: none;300+ border: 0;301+ padding: 0;302+ cursor: pointer;303+ font: inherit;304+ font-size: 0.625rem;305+ color: var(--text-faint);306+ letter-spacing: 0.04em;307+}308+.copy-btn:hover,309+.copy-btn.copied {310+ color: var(--accent);311+}312+313+/* ----- sections ----------------------------------------------------------- */314+315+.repo-section {316+ margin: 36px 0 0;317+ animation: fadeIn 320ms 80ms ease both;318+}319+.repo-section-toggle > summary,320+.repo-details-toggle > summary {321+ list-style: none;322+ cursor: pointer;323+ user-select: none;324+}325+.repo-section-toggle > summary::-webkit-details-marker,326+.repo-details-toggle > summary::-webkit-details-marker {327+ display: none;328+}329+.section-head {330+ display: flex;331+ align-items: baseline;332+ gap: 12px;333+ padding-bottom: 8px;334+ border-bottom: 1px solid var(--border);335+ margin-bottom: 4px;336+}337+.section-head::after,338+.repo-details-head::after {339+ content: "▸";340+ font-size: 0.5625rem;341+ opacity: 0.4;342+ transition: transform 120ms;343+}344+.section-head::after {345+ margin-left: auto;346+}347+details[open] > .section-head::after,348+details[open] > .repo-details-head::after {349+ transform: rotate(90deg);350+}351+.section-label {352+ font-size: 0.6875rem;353+ letter-spacing: 0.12em;354+ color: var(--text-secondary);355+ text-transform: uppercase;356+}357+.section-count {358+ font-family: var(--mono);359+ font-size: 0.6875rem;360+ color: var(--accent);361+}362+363+/* ----- timeline ----------------------------------------------------------- */364+365+.timeline {366+ display: block;367+ margin: 18px 0 4px;368+ overflow: visible;369+}370+.timeline-line {371+ stroke: var(--text-faint);372+ stroke-width: 1;373+ stroke-dasharray: 4 4;374+}375+.timeline .dot circle {376+ fill: var(--accent);377+ transition:378+ fill-opacity 120ms,379+ r 120ms;380+}381+.timeline .d0 circle {382+ fill: var(--accent-dim);383+ fill-opacity: 0.45;384+}385+.timeline .d1 circle {386+ fill-opacity: 0.55;387+}388+.timeline .d2 circle {389+ fill-opacity: 0.8;390+}391+.timeline .d3 circle {392+ fill: var(--accent-bright);393+ fill-opacity: 1;394+}395+.timeline .dot:hover circle,396+.timeline .dot:focus circle {397+ fill: var(--accent-bright);398+ fill-opacity: 1;399+}400+.timeline-legend {401+ margin: 0 0 14px;402+ font-size: 0.625rem;403+ color: var(--text-faint);404+}405+.timeline-tip {406+ position: fixed;407+ z-index: 200;408+ max-width: 340px;409+ padding: 0.45rem 0.7rem;410+ background: #0f0c08;411+ border: 1px solid var(--border);412+ border-radius: 4px;413+ pointer-events: none;414+ line-height: 1.5;415+}416+.timeline-tip .tip-id {417+ font-family: var(--mono);418+ font-size: 12px;419+ color: var(--accent);420+}421+.timeline-tip .tip-msg {422+ margin-top: 3px;423+ font-style: italic;424+ font-size: 15px;425+ color: var(--text-heading);426+}427+.timeline-tip .tip-meta {428+ margin-top: 4px;429+ font-family: var(--mono);430+ font-size: 10px;431+ color: var(--text-muted);432+}433+434+/* ----- history ------------------------------------------------------------ */435+436+.history-row {437+ position: relative;438+ display: grid;439+ grid-template-columns: 110px 1fr auto;440+ align-items: baseline;441+ gap: 0 10px;442+ padding: 9px 8px;443+ border-bottom: 1px solid var(--border);444+ transition: background 100ms;445+}446+.history-row::before {447+ content: "";448+ position: absolute;449+ left: 0;450+ top: 0;451+ bottom: 0;452+ width: 2px;453+ background: var(--accent);454+ transform: scaleY(0);455+ transition: transform 120ms ease;456+}457+.history-row:hover {458+ background: var(--accent-tint-04);459+ text-decoration: none;460+}461+.history-row:hover::before {462+ transform: scaleY(1);463+}464+.history-id {465+ font-family: var(--mono);466+ font-size: 0.75rem;467+ color: var(--text-secondary);468+}469+.history-msg {470+ min-width: 0;471+ line-height: 1.7;472+ overflow: hidden;473+ text-overflow: ellipsis;474+ white-space: nowrap;475+ transition: color 100ms;476+}477+.history-row:hover .history-msg {478+ color: var(--accent);479+}480+.history-when {481+ font-family: var(--mono);482+ font-size: 0.6875rem;483+ color: var(--text-muted);484+ white-space: nowrap;485+}486+.pager {487+ display: flex;488+ justify-content: space-between;489+ margin: 14px 0 0;490+ font-size: 0.6875rem;491+}492+.pager a:last-child {493+ margin-left: auto;494+}495+.pager a:hover {496+ color: var(--accent);497+}498+499+/* ----- README and rendered Markdown --------------------------------------- */500+501+.repo-details {502+ margin: 36px 0 0;503+ animation: fadeIn 320ms 80ms ease both;504+}505+.repo-details-toggle {506+ border-bottom: 1px solid var(--border);507+ padding-bottom: 24px;508+}509+.repo-details-head {510+ display: flex;511+ align-items: center;512+ gap: 6px;513+ padding: 8px 0;514+ font-family: var(--mono);515+ font-size: 0.6875rem;516+ letter-spacing: 0.12em;517+ color: var(--text-secondary);518+ text-transform: uppercase;519+}520+.markdown {521+ font-size: 1.05rem;522+ line-height: 1.7;523+ overflow-wrap: break-word;524+}525+.markdown h1,526+.markdown h2,527+.markdown h3,528+.markdown h4 {529+ font-weight: 400;530+ margin: 1.4em 0 0.4em;531+ color: var(--text-heading);532+}533+.markdown h1 {534+ font-size: 1.5rem;535+}536+.markdown h2 {537+ font-size: 1.3rem;538+}539+.markdown h3 {540+ font-size: 1.15rem;541+}542+.markdown p {543+ margin: 0.8em 0;544+}545+.markdown ul,546+.markdown ol {547+ margin: 0.8em 0 0.8em 1.5em;548+ padding: 0;549+}550+.markdown a {551+ color: var(--accent);552+}553+.markdown code {554+ font-family: var(--mono);555+ font-size: 0.82em;556+ color: var(--accent);557+ background: var(--bg-surface);558+ padding: 0.15em 0.35em;559+ border-radius: 3px;560+}561+.markdown pre {562+ background: var(--bg-surface);563+ border-radius: 4px;564+ padding: 1em 1.2em;565+ overflow-x: auto;566+ margin: 1em 0;567+}568+.markdown pre code {569+ background: none;570+ padding: 0;571+ color: var(--text);572+ font-size: 0.85em;573+}574+.markdown blockquote {575+ border-left: 3px solid var(--border);576+ margin: 1em 0;577+ padding: 0.1em 1em;578+ color: var(--text-secondary);579+}580+.markdown hr {581+ border: none;582+ border-top: 1px solid var(--border);583+ margin: 1.5em 0;584+}585+.markdown img {586+ max-width: 100%;587+}588+.markdown table {589+ border-collapse: collapse;590+ display: block;591+ overflow-x: auto;592+}593+.markdown th,594+.markdown td {595+ border: 1px solid var(--border);596+ padding: 4px 10px;597+}598+599+/* ----- file viewer -------------------------------------------------------- */600+601+.breadcrumb {602+ margin: 0 0 4px;603+ font-size: 0.75rem;604+ overflow-wrap: anywhere;605+}606+.breadcrumb a:hover {607+ color: var(--accent);608+}609+.file-page-title {610+ font-size: 1.75rem;611+ font-weight: 400;612+ margin: 4px 0 12px;613+ overflow-wrap: anywhere;614+}615+.file-meta,616+.file-toggle {617+ margin: 0 0 12px;618+ font-size: 0.6875rem;619+}620+.file-meta a,621+.file-toggle a.active {622+ color: var(--accent);623+}624+.file-image img {625+ max-width: 100%;626+ background: var(--bg-surface);627+ border: 1px solid var(--border);628+}629+.file-view {630+ border: 1px solid var(--border);631+ background: var(--bg-surface);632+ overflow-x: auto;633+}634+.code {635+ margin: 0;636+ padding: 16px 0;637+ font-family: var(--mono);638+ font-size: 0.8125rem;639+ line-height: 1.65;640+ tab-size: 4;641+}642+/* A grid, so tab stops count from the start of the code rather than the line number. */643+.code .line {644+ display: grid;645+ grid-template-columns: 6em 1fr;646+ padding-right: 24px;647+ scroll-margin-top: 40vh;648+}649+.code .line:target,650+.code .line.hl {651+ background: var(--accent-tint-12);652+}653+.code .lc {654+ white-space: pre;655+}656+.code .ln {657+ padding-right: 1.5em;658+ text-align: right;659+ color: var(--text-faint);660+ user-select: none;661+}662+.code .ln:hover {663+ color: var(--accent);664+ text-decoration: none;665+}666+667+/* Shiki's CSS-variables theme roles, in Kepr 2's highlight.js colours. */668+.tk-comment {669+ color: var(--text-muted);670+ font-style: italic;671+}672+.tk-keyword {673+ color: var(--accent);674+}675+.tk-string,676+.tk-string-expression {677+ color: var(--sage);678+}679+.tk-constant {680+ color: var(--accent-bright);681+}682+.tk-function {683+ color: var(--accent);684+}685+.tk-parameter {686+ color: var(--text);687+}688+.tk-punctuation {689+ color: var(--text-secondary);690+}691+.tk-link {692+ color: var(--accent);693+ text-decoration: underline;694+}695+.tk-inserted {696+ color: var(--sage);697+}698+.tk-deleted {699+ color: #c45c5c;700+}701+.tk-i {702+ font-style: italic;703+}704+705+/* ----- phone -------------------------------------------------------------- */706+707+@media (max-width: 768px) {708+ body > nav {709+ padding: 20px 16px;710+ }711+ .nav-links {712+ gap: 18px;713+ }714+ main {715+ padding: 16px 16px 80px;716+ }717+ .repo-name {718+ font-size: 2.75rem;719+ }720+ .install-lines {721+ align-items: stretch;722+ margin-left: 0;723+ width: 100%;724+ }725+ .history-row {726+ grid-template-columns: 1fr auto;727+ }728+ .history-id {729+ grid-column: 1 / -1;730+ }731+ .code .line {732+ grid-template-columns: 4.5em 1fr;733+ }734+ .code .ln {735+ padding-right: 1em;736+ }737+}738+739+/* Later in the file than every animation it stops, so it wins without !important. */740+@media (prefers-reduced-motion: reduce) {741+ .tri-outer,742+ .tri-inner,743+ .repo-name,744+ .repo-contributor,745+ .repo-desc,746+ .repo-homepage,747+ .repo-meta,748+ .repo-tabs,749+ .install-lines,750+ .repo-section,751+ .repo-details {752+ animation: none;753+ }754+}public/js/kepr.js+83 −0
@@ -0,0 +1,83 @@1+// Kepr's browser islands (§18). Every page is complete without this file; it only adds the2+// styled timeline tooltip, copy buttons and line-range highlighting.3+4+function timelineTips() {5+ const dots = document.querySelectorAll(".timeline .dot");6+ if (dots.length === 0) return;7+ const tip = document.createElement("div");8+ tip.className = "timeline-tip";9+ tip.hidden = true;10+ document.body.append(tip);11+ for (const dot of dots) {12+ // The SVG <title> is the no-JavaScript tooltip; lift it out so the two never show together.13+ const title = dot.querySelector("title");14+ const [id, message, meta] = (title?.textContent ?? "").split("\n");15+ title?.remove();16+ dot.setAttribute("aria-label", `${id} ${message}`);17+ dot.addEventListener("pointerenter", (event) => {18+ tip.replaceChildren(line("tip-id", id), line("tip-msg", message), line("tip-meta", meta));19+ tip.hidden = false;20+ const left = event.clientX + 14;21+ tip.style.left = `${left + 340 > window.innerWidth ? Math.max(8, event.clientX - 354) : left}px`;22+ tip.style.top = `${event.clientY - 10}px`;23+ });24+ dot.addEventListener("pointerleave", () => {25+ tip.hidden = true;26+ });27+ }28+}29+30+function line(className, text) {31+ const div = document.createElement("div");32+ div.className = className;33+ div.textContent = text ?? "";34+ return div;35+}36+37+function copyButtons() {38+ for (const button of document.querySelectorAll(".copy-btn[data-copy]")) {39+ button.hidden = false;40+ button.addEventListener("click", async () => {41+ await navigator.clipboard.writeText(button.dataset.copy);42+ button.textContent = "copied";43+ button.classList.add("copied");44+ setTimeout(() => {45+ button.textContent = "copy";46+ button.classList.remove("copied");47+ }, 1400);48+ });49+ }50+}51+52+// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.53+function lineRanges() {54+ const code = document.querySelector(".code");55+ if (!code) return;56+ let anchor = null;57+ const mark = () => {58+ for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");59+ const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);60+ if (!match) return;61+ const from = Number(match[1]);62+ const to = Number(match[2] ?? match[1]);63+ anchor = from;64+ for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {65+ document.getElementById(`L${n}`)?.classList.add("hl");66+ }67+ document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });68+ };69+ code.addEventListener("click", (event) => {70+ const link = event.target.closest(".ln");71+ if (!link || !event.shiftKey || anchor === null) return;72+ event.preventDefault();73+ const n = Number(link.textContent);74+ history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);75+ mark();76+ });77+ window.addEventListener("hashchange", mark);78+ mark();79+}80+81+timelineTips();82+copyButtons();83+lineRanges();src/main.ts+1 −1
@@ -14,7 +14,7 @@1414 mirrorDir: join(config.dataDir, "mirror"),1515 });1616 // Bun.serve reads the port from $PORT (default 3000).17- const server = Bun.serve({ fetch: createApp(config).fetch });17+ const server = Bun.serve({ fetch: createApp(config, kepr).fetch });1818 console.log(`kepr listening on ${server.url}`);1919 } catch (error) {2020 if (!(error instanceof ConfigError)) throw error;src/mirror/queries.ts+1 −1
@@ -75,7 +75,7 @@7575 return file ? { ...file, executable: Boolean(file.executable) } : null;7676 }7777 78-export function objectBytes(db: Database, hash: string): Uint8Array | null {78+export function objectBytes(db: Database, hash: string): Uint8Array<ArrayBuffer> | null {7979 const row = db8080 .query<{ data: Uint8Array }, [string]>("SELECT data FROM objects WHERE hash = ?")8181 .get(hash);src/mirror/store.ts+4 −0
@@ -86,6 +86,9 @@8686 html: "HTML",8787 };8888 89+// Vendored code is someone else's: counting Koh 3's bundled SQLite made it read as 290k lines of C.90+const VENDORED = /(^|\/)(vendor|third_party|node_modules)\//;91+8992 const open = new Map<string, Database>();9093 9194 /** The mirror of one repo at `<dir>/<repo>.db`, created on first use and kept open. */@@ -206,6 +209,7 @@206209 let lines = 0;207210 const byLanguage = new Map<string, number>();208211 for (const file of files) {212+ if (VENDORED.test(file.path)) continue;209213 const bytes = inflateSync(file.data);210214 if (bytes.includes(0)) continue;211215 let count = 0;src/pages/files.ts+132 −0
@@ -0,0 +1,132 @@1+import type { Context, Hono } from "hono";2+import { isSafePath } from "../bucket/repo.ts";3+import { type FileEntry, fileAt, headId, objectBytes, saveByPrefix } from "../mirror/queries.ts";4+import { highlightLines } from "./highlight.ts";5+import { type Html, html } from "./html.ts";6+import {7+ cacheFor,8+ formatSize,9+ notFound,10+ openPublicRepo,11+ type PageContext,12+ page,13+ pathUrl,14+} from "./layout.ts";15+import { renderMarkdown } from "./markdown.ts";16+17+const SHOWN_BYTES = 1024 * 1024;18+const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i;19+const MARKDOWN = /\.(md|markdown)$/i;20+// Binary types a browser can show or save as they are; anything else textual stays text/plain (§21.8).21+const PASS_THROUGH = /^(image|audio|video|font)\/|^application\/(pdf|zip|gzip|wasm)$/;22+23+type Found = {24+ repo: string;25+ path: string;26+ at: string | null;27+ file: FileEntry;28+ bytes: Uint8Array<ArrayBuffer>;29+};30+31+export function registerFileRoutes(app: Hono, ctx: PageContext): void {32+ app.get("/:repo/files/:path{.+}", async (c) => {33+ const found = findFile(c, ctx);34+ if (!found) return notFound(c, ctx);35+ cacheFor(c, found.at !== null);36+ const title = found.path.split("/").pop() as string;37+ const body = html`${breadcrumb(found)}38+<h1 class="file-page-title">${title}</h1>39+${await fileBody(found, c.req.query("view") === "source")}`;40+ return c.html(41+ page(ctx, {42+ title: `${title} · ${found.repo}`,43+ description: found.path,44+ path: c.req.path,45+ body,46+ }),47+ );48+ });49+50+ app.get("/:repo/raw/:path{.+}", (c) => {51+ const found = findFile(c, ctx);52+ if (!found) return notFound(c, ctx);53+ cacheFor(c, found.at !== null);54+ c.header("Content-Type", contentType(found.path, found.bytes));55+ return c.body(found.bytes);56+ });57+}58+59+/** The file named by the route at `?at=<id>` or HEAD, behind the visibility gate; null means 404. */60+function findFile(c: Context, ctx: PageContext): Found | null {61+ const name = c.req.param("repo") as string;62+ const path = c.req.param("path") as string;63+ const repo = openPublicRepo(ctx, name);64+ if (!repo || !isSafePath(path)) return null;65+ const at = c.req.query("at") ?? null;66+ const saveId = at === null ? headId(repo.mirror) : (saveByPrefix(repo.mirror, at)?.id ?? null);67+ const file = saveId ? fileAt(repo.mirror, saveId, path) : null;68+ const bytes = file && objectBytes(repo.mirror, file.hash);69+ return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null;70+}71+72+function isText(bytes: Uint8Array): boolean {73+ if (bytes.subarray(0, 8000).includes(0)) return false;74+ try {75+ new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, SHOWN_BYTES));76+ return true;77+ } catch {78+ // A multi-byte character cut at the 1 MB mark is still text.79+ return bytes.length > SHOWN_BYTES;80+ }81+}82+83+function contentType(path: string, bytes: Uint8Array): string {84+ const type = Bun.file(path).type;85+ if (type.startsWith("image/")) return type;86+ if (isText(bytes)) return "text/plain; charset=utf-8";87+ return PASS_THROUGH.test(type) ? type : "application/octet-stream";88+}89+90+function rawHref(found: Found): string {91+ return `/${found.repo}/raw/${pathUrl(found.path)}${found.at ? `?at=${found.at}` : ""}`;92+}93+94+function breadcrumb(found: Found): Html {95+ const dirs = found.path.split("/").slice(0, -1);96+ return html`<p class="breadcrumb mono"><a href="/${found.repo}">${found.repo}</a>${dirs.map((dir) => ` / ${dir}`)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;97+}98+99+async function fileBody(found: Found, source: boolean): Promise<Html> {100+ const size = formatSize(found.file.size);101+ const raw = rawHref(found);102+ if (IMAGE.test(found.path)) {103+ return html`<p class="file-meta mono">${size} · <a href="${raw}">raw</a></p><div class="file-image"><img src="${raw}" alt="${found.path}"></div>`;104+ }105+ if (!isText(found.bytes)) {106+ return html`<p class="file-meta mono">binary file · ${size} · <a href="${raw}" download>download</a></p>`;107+ }108+ const truncated = found.bytes.length > SHOWN_BYTES;109+ const text = new TextDecoder().decode(found.bytes.subarray(0, SHOWN_BYTES));110+ const meta = html`<p class="file-meta mono">${size}${truncated && " · showing the first 1 MB"} · <a href="${raw}">raw</a></p>`;111+ if (MARKDOWN.test(found.path)) {112+ const at = found.at ? `at=${found.at}&` : "";113+ const toggle = html`<p class="file-toggle mono"><a href="?${found.at ? `at=${found.at}` : ""}"${!source && html` class="active"`}>rendered</a> · <a href="?${at}view=source"${source && html` class="active"`}>source</a></p>`;114+ if (!source) {115+ const dir = found.path.split("/").slice(0, -1).join("/");116+ const rendered = renderMarkdown(text, { repo: found.repo, dir, at: found.at });117+ return html`${meta}${toggle}<div class="markdown file-markdown">${rendered}</div>`;118+ }119+ return html`${meta}${toggle}${await codeView(text, found.path)}`;120+ }121+ return html`${meta}${await codeView(text, found.path)}`;122+}123+124+// Each line is its own anchor target, so `#L42` scrolls and highlights through CSS alone; the125+// script only adds ranges (`#L42-L50`).126+async function codeView(text: string, path: string): Promise<Html> {127+ const lines = await highlightLines(text, path);128+ return html`<div class="file-view"><pre class="code"><code>${lines.map(129+ (line, i) =>130+ html`<span class="line" id="L${i + 1}"><a class="ln" href="#L${i + 1}">${i + 1}</a><span class="lc">${line}</span></span>`,131+ )}</code></pre></div>`;132+}src/pages/highlight.ts+44 −0
@@ -0,0 +1,44 @@1+import {2+ type BundledLanguage,3+ bundledLanguages,4+ createCssVariablesTheme,5+ createHighlighter,6+ type Highlighter,7+} from "shiki";8+import { type Html, html } from "./html.ts";9+10+// Shiki's CSS-variables theme names each token's role; the role becomes a class, so the colours11+// live in kepr.css and the CSP needs no inline styles.12+const theme = createCssVariablesTheme({ name: "kepr", variablePrefix: "--tk-" });13+const ROLE = /^var\(--tk-token-([a-z-]+)\)$/;14+const ITALIC = 1;15+16+let highlighter: Promise<Highlighter> | undefined;17+18+function languageOf(path: string): BundledLanguage | null {19+ const name = path.split("/").pop()?.toLowerCase() ?? "";20+ const ext = name.includes(".") ? (name.split(".").pop() as string) : name;21+ return ext in bundledLanguages ? (ext as BundledLanguage) : null;22+}23+24+/** Each line of `code` as highlighted markup; plain escaped lines for a language Shiki lacks. */25+export async function highlightLines(code: string, path: string): Promise<Html[]> {26+ const lines = code.replace(/\n$/, "").split("\n");27+ const lang = languageOf(path);28+ if (!lang) return lines.map((line) => html`${line}`);29+ highlighter ??= createHighlighter({ themes: [theme], langs: [] });30+ const shiki = await highlighter;31+ await shiki.loadLanguage(lang);32+ return shiki.codeToTokensBase(code.replace(/\n$/, ""), { lang, theme }).map(33+ (tokens) =>34+ html`${tokens.map((token) => {35+ const role = ROLE.exec(token.color ?? "")?.[1];36+ const classes = [role && `tk-${role}`, (token.fontStyle ?? 0) & ITALIC && "tk-i"].filter(37+ Boolean,38+ );39+ return classes.length40+ ? html`<span class="${classes.join(" ")}">${token.content}</span>`41+ : token.content;42+ })}`,43+ );44+}src/pages/html.ts+42 −0
@@ -0,0 +1,42 @@1+/** Markup that is already safe: built by `html`, or rendered by markdown-it with raw HTML off. */2+export class Html {3+ constructor(readonly value: string) {}4+ toString(): string {5+ return this.value;6+ }7+}8+9+export type Part = Html | string | number | null | undefined | false | Part[];10+11+const ENTITIES: Record<string, string> = {12+ "&": "&",13+ "<": "<",14+ ">": ">",15+ '"': """,16+ "'": "'",17+};18+19+/** The one escaping helper (§18): every interpolated value goes through it unless it is `Html`. */20+export function escapeHtml(text: string): string {21+ return text.replace(/[&<>"']/g, (c) => ENTITIES[c] as string);22+}23+24+function render(part: Part): string {25+ if (part instanceof Html) return part.value;26+ if (Array.isArray(part)) return part.map(render).join("");27+ if (part === null || part === undefined || part === false) return "";28+ return escapeHtml(String(part));29+}30+31+export function html(strings: TemplateStringsArray, ...values: Part[]): Html {32+ let out = strings[0] as string;33+ values.forEach((value, i) => {34+ out += render(value) + strings[i + 1];35+ });36+ return new Html(out);37+}38+39+/** Trusts markup produced elsewhere; only for markdown-it output. */40+export function trusted(markup: string): Html {41+ return new Html(markup);42+}src/pages/layout.ts+112 −0
@@ -0,0 +1,112 @@1+import type { Database } from "bun:sqlite";2+import type { Context } from "hono";3+import { openMirror } from "../mirror/store.ts";4+import { publicRepo, type RepoSettings, setting } from "../server/db.ts";5+import { type Html, html } from "./html.ts";6+7+export type PageContext = { domain: string; kepr: Database; mirrorDir: string };8+9+export type PublicRepo = { settings: RepoSettings; mirror: Database };10+11+const HEAD_CACHE = "public, max-age=60";12+// Content at a save never changes, but strip can remove it, so it is long-lived rather than immutable.13+const SAVE_CACHE = "public, max-age=31536000";14+const FONTS =15+ "https://fonts.bunny.net/css?family=bellota:700|comfortaa:700|martian-mono:400|quicksand:400,600|sacramento:400&display=swap";16+17+/** The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike. */18+export function openPublicRepo(ctx: PageContext, name: string): PublicRepo | null {19+ const settings = publicRepo(ctx.kepr, name);20+ return settings ? { settings, mirror: openMirror(ctx.mirrorDir, name) } : null;21+}22+23+export function cacheFor(c: Context, atSave: boolean): void {24+ c.header("Cache-Control", atSave ? SAVE_CACHE : HEAD_CACHE);25+}26+27+export function notFound(c: Context, ctx: PageContext): Response {28+ c.header("Cache-Control", "no-store");29+ const body = html`<h1 class="file-page-title">Not found</h1>30+ <p class="muted">Nothing lives at this address.</p>`;31+ return c.html(page(ctx, { title: "Not found", description: "", path: c.req.path, body }), 404);32+}33+34+type PageOptions = { title: string; description: string; path: string; body: Html };35+36+export function page(ctx: PageContext, options: PageOptions): string {37+ const site = setting(ctx.kepr, "site_name") ?? "kepr";38+ const branding = setting(ctx.kepr, "branding");39+ const title = `${options.title} — ${site}`;40+ const url = `https://${ctx.domain}${options.path}`;41+ return html`<!doctype html>42+<html lang="en">43+<head>44+<meta charset="utf-8">45+<meta name="viewport" content="width=device-width, initial-scale=1">46+<title>${title}</title>47+<meta name="description" content="${options.description}">48+<meta property="og:site_name" content="${site}">49+<meta property="og:type" content="website">50+<meta property="og:url" content="${url}">51+<meta property="og:title" content="${title}">52+<meta property="og:description" content="${options.description}">53+<meta name="twitter:card" content="summary">54+<meta name="twitter:title" content="${title}">55+<meta name="twitter:description" content="${options.description}">56+<meta name="theme-color" content="#090706">57+<link rel="canonical" href="${url}">58+<link rel="preconnect" href="https://fonts.bunny.net">59+<link rel="stylesheet" href="${FONTS}">60+<link rel="stylesheet" href="/static/css/kepr.css">61+<script src="/static/js/kepr.js" defer></script>62+</head>63+<body>64+<nav>65+<a class="wordmark" href="/" aria-label="${site}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>66+${branding && html`<span class="wordmark-by">${branding}</span>`}67+<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a></span>68+</nav>69+<main>70+${options.body}71+</main>72+<footer><div class="credit">${site}</div></footer>73+</body>74+</html>`.value;75+}76+77+const UNITS: [number, string][] = [78+ [365 * 86400, "year"],79+ [30 * 86400, "month"],80+ [7 * 86400, "week"],81+ [86400, "day"],82+ [3600, "hour"],83+ [60, "minute"],84+];85+86+export function relativeTime(unixSeconds: number, now = Date.now() / 1000): string {87+ const elapsed = now - unixSeconds;88+ for (const [seconds, unit] of UNITS) {89+ const n = Math.floor(elapsed / seconds);90+ if (n >= 1) return `${n} ${unit}${n === 1 ? "" : "s"} ago`;91+ }92+ return "just now";93+}94+95+export function formatDate(unixSeconds: number): string {96+ return new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");97+}98+99+export function plural(n: number, noun: string): string {100+ return `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`;101+}102+103+export function formatSize(bytes: number): string {104+ if (bytes < 1024) return `${bytes} B`;105+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;106+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;107+}108+109+/** Percent-encodes each segment of a repo path for use in a URL. */110+export function pathUrl(path: string): string {111+ return path.split("/").map(encodeURIComponent).join("/");112+}src/pages/markdown.ts+56 −0
@@ -0,0 +1,56 @@1+import MarkdownIt from "markdown-it";2+import { type Html, trusted } from "./html.ts";3+import { pathUrl } from "./layout.ts";4+5+/** Where a Markdown file lives, so its relative links and images can point at the repo's raw files. */6+export type MarkdownBase = { repo: string; dir: string; at: string | null };7+8+const SCHEME = /^([a-z][a-z0-9+.-]*):/i;9+const ALLOWED_SCHEMES = ["http", "https", "mailto"];10+11+// §25: raw HTML is off, so markdown-it's own escaping is the whole sanitizer.12+const md = new MarkdownIt({ html: false, linkify: true });13+md.validateLink = (url) => {14+ const scheme = SCHEME.exec(url.trim());15+ return !scheme || ALLOWED_SCHEMES.includes((scheme[1] as string).toLowerCase());16+};17+md.core.ruler.push("kepr_raw_links", (state) => {18+ const base = state.env as MarkdownBase;19+ const visit = (tokens: typeof state.tokens) => {20+ for (const token of tokens) {21+ const attr = token.type === "link_open" ? "href" : token.type === "image" ? "src" : null;22+ const url = attr && token.attrGet(attr);23+ if (attr && typeof url === "string") token.attrSet(attr, rawUrl(url, base));24+ if (token.children) visit(token.children);25+ }26+ };27+ visit(state.tokens);28+});29+30+function rawUrl(url: string, base: MarkdownBase): string {31+ if (SCHEME.test(url) || url.startsWith("#") || url.startsWith("//")) return url;32+ // A leading `/` means the repo root, as it does on a code host.33+ const resolved = new URL(url, `http://repo/${base.dir ? `${pathUrl(base.dir)}/` : ""}`);34+ const at = base.at ? `?at=${base.at}` : "";35+ return `/${base.repo}/raw${resolved.pathname}${at}${resolved.hash}`;36+}37+38+export function renderMarkdown(source: string, base: MarkdownBase): Html {39+ return trusted(md.render(source, base));40+}41+42+/** The README's first paragraph (§21.4), as markup and as plain text for link previews. */43+export function firstParagraph(44+ source: string,45+ base: MarkdownBase,46+): { html: Html; text: string } | null {47+ const tokens = md.parse(source, base);48+ const index = tokens.findIndex((token) => token.type === "paragraph_open");49+ const inline = tokens[index + 1];50+ if (index < 0 || !inline?.children) return null;51+ const text = inline.children52+ .filter((token) => token.type === "text" || token.type === "code_inline")53+ .map((token) => token.content)54+ .join("");55+ return { html: trusted(md.renderer.renderInline(inline.children, md.options, base)), text };56+}src/pages/repo.ts+112 −0
@@ -0,0 +1,112 @@1+import type { Hono } from "hono";2+import {3+ fileAt,4+ filesAt,5+ headId,6+ history,7+ objectBytes,8+ repoStats,9+ timeline,10+} from "../mirror/queries.ts";11+import { setting } from "../server/db.ts";12+import { type Html, html } from "./html.ts";13+import {14+ cacheFor,15+ notFound,16+ openPublicRepo,17+ type PageContext,18+ type PublicRepo,19+ page,20+ plural,21+ relativeTime,22+} from "./layout.ts";23+import { firstParagraph, renderMarkdown } from "./markdown.ts";24+import { timelineSvg } from "./timeline.ts";25+26+const PAGE_SIZE = 50;27+const HTTP_URL = /^https?:\/\//i;28+29+export function registerRepoPage(app: Hono, ctx: PageContext): void {30+ app.get("/:repo", (c) => {31+ const repo = openPublicRepo(ctx, c.req.param("repo"));32+ if (!repo) return notFound(c, ctx);33+ const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);34+ const readme = readmeAtHead(repo);35+ const name = repo.settings.name;36+ const intro =37+ readme === null ? null : firstParagraph(readme, { repo: name, dir: "", at: null });38+ cacheFor(c, false);39+ return c.html(40+ page(ctx, {41+ title: name,42+ description: intro?.text ?? "",43+ path: `/${name}`,44+ body: html`${header(ctx, repo, intro?.html ?? null)}45+${headId(repo.mirror) && sections(repo, pageNumber)}46+${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,47+ }),48+ );49+ });50+}51+52+function readmeAtHead(repo: PublicRepo): string | null {53+ const head = headId(repo.mirror);54+ const path =55+ head && filesAt(repo.mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;56+ const file = head && path ? fileAt(repo.mirror, head, path) : null;57+ const bytes = file && objectBytes(repo.mirror, file.hash);58+ return bytes ? new TextDecoder().decode(bytes) : null;59+}60+61+function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {62+ const { name, homepage, installLines } = repo.settings;63+ const stats = repoStats(repo.mirror);64+ const handle = setting(ctx.kepr, "handle");65+ const meta = [66+ plural(stats.saves, "save"),67+ stats.lines > 0 && plural(stats.lines, "line"),68+ stats.updated !== null && `updated ${relativeTime(stats.updated)}`,69+ ].filter(Boolean);70+ return html`<header class="repo-head">71+<h1 class="repo-name">${name}</h1>72+${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}73+${intro && html`<div class="repo-desc"><p>${intro}</p></div>`}74+${HTTP_URL.test(homepage) && html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`}75+${stats.language && html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`}76+<p class="repo-meta">${meta.join(" · ")}</p>77+<div class="repo-tabbar">78+<nav class="repo-tabs" aria-label="Repository panels"><a class="repo-tab" href="/${name}/issues">issues</a><a class="repo-tab" href="/${name}/releases">builds</a><a class="repo-tab" href="/${name}/archives">archives</a></nav>79+${installLines.length > 0 && html`<div class="install-lines">${installLines.map((line) => html`<div class="install-line"><code>${line}</code><button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button></div>`)}</div>`}80+</div>81+</header>`;82+}83+84+function sections(repo: PublicRepo, pageNumber: number): Html {85+ const name = repo.settings.name;86+ const saves = timeline(repo.mirror);87+ const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);88+ const count = plural(saves.length, "save");89+ const older = pageNumber * PAGE_SIZE < saves.length;90+ return html`<section class="repo-section">91+<details class="repo-section-toggle" open>92+<summary class="section-head"><span class="section-label">timeline</span><span class="section-count">${count}</span></summary>93+${timelineSvg(name, saves)}94+</details>95+</section>96+<section class="repo-section" id="history">97+<details class="repo-section-toggle" open>98+<summary class="section-head"><span class="section-label">history</span><span class="section-count">${count}</span></summary>99+${rows.map((save) => html`<a class="history-row" href="/${name}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`)}100+${(pageNumber > 1 || older) && html`<p class="pager mono">${pageNumber > 1 && html`<a href="?page=${pageNumber - 1}#history">← newer</a>`}${older && html`<a href="?page=${pageNumber + 1}#history">older →</a>`}</p>`}101+</details>102+</section>`;103+}104+105+function details(readme: Html): Html {106+ return html`<section class="repo-details">107+<details class="repo-details-toggle" open>108+<summary class="repo-details-head">details</summary>109+<div class="repo-details-body markdown">${readme}</div>110+</details>111+</section>`;112+}src/pages/timeline.ts+41 −0
@@ -0,0 +1,41 @@1+import type { Save } from "../mirror/queries.ts";2+import { type Html, html } from "./html.ts";3+import { formatDate } from "./layout.ts";4+5+const DAY = 86400;6+// Kepr 2's labels for saves per day across the whole history.7+const PACE: [number, string][] = [8+ [1, "very active"],9+ [0.3, "active"],10+ [0.1, "occasional"],11+];12+13+/**14+ * §21.4's dotted timeline as SVG, so it draws without JavaScript: one dot per save on a dashed15+ * line, x by time (as a percentage, so it scales without squashing dots), brighter and fuller16+ * where neighbours crowd within a fortieth of the span.17+ */18+export function timelineSvg(repo: string, saves: Save[]): Html {19+ const first = saves[0]?.created_at ?? 0;20+ const span = (saves[saves.length - 1]?.created_at ?? 0) - first;21+ const reach = Math.max(span / 40, 3600);22+ const times = saves.map((save) => save.created_at);23+ const dots = saves.map((save) => {24+ const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;25+ // ponytail: O(n²) neighbour count; a sliding window over sorted times if a repo reaches ~5,000 saves.26+ const near = times.filter((t) => Math.abs(t - save.created_at) <= reach).length;27+ const level = near >= 8 ? 3 : near >= 4 ? 2 : near >= 2 ? 1 : 0;28+ const meta = [formatDate(save.created_at), save.agent, save.session]29+ .filter(Boolean)30+ .join(" · ");31+ const tip = `${save.id}\n${save.message.split("\n")[0]}\n${meta}`;32+ return html`<a href="/${repo}/saves/${save.id}" class="dot d${level}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;33+ });34+ const perDay = saves.length / Math.max(span / DAY, 1);35+ const pace = PACE.find(([min]) => perDay > min)?.[1] ?? "quiet";36+ return html`<svg class="timeline" width="100%" height="56" role="img" aria-label="${saves.length} saves over time">37+<line class="timeline-line" x1="0" x2="100%" y1="28" y2="28"/>38+${dots}39+</svg>40+<p class="timeline-legend mono">${pace} · density = activity · position = time</p>`;41+}src/server/app.ts+22 −2
@@ -1,12 +1,20 @@1+import type { Database } from "bun:sqlite";2+import { join } from "node:path";13 import { Hono } from "hono";24 import { bodyLimit } from "hono/body-limit";35 import { secureHeaders } from "hono/secure-headers";6+import { isSafePath } from "../bucket/repo.ts";7+import { registerFileRoutes } from "../pages/files.ts";8+import { registerRepoPage } from "../pages/repo.ts";49 import type { Config } from "./config.ts";510 611 const MAX_BODY_BYTES = 64 * 1024;12+const PUBLIC_DIR = join(import.meta.dir, "../../public");13+// §18 allows Bunny Fonts: its stylesheet and the font files it points at.14+const FONTS = "https://fonts.bunny.net";715 8-/** The Hono app with §25's headers and body cap; routes join it phase by phase. */9-export function createApp(config: Config): Hono {16+/** The Hono app with §25's headers and body cap, and every route built so far. */17+export function createApp(config: Config, kepr: Database): Hono {1018 const app = new Hono();1119 // §25 allows images from the presigned host, which is the bucket endpoint's origin.1220 const imgSrc =@@ -16,6 +24,8 @@1624 contentSecurityPolicy: {1725 defaultSrc: ["'self'"],1826 scriptSrc: ["'self'"],27+ styleSrc: ["'self'", FONTS],28+ fontSrc: [FONTS],1929 imgSrc,2030 objectSrc: ["'none'"],2131 baseUri: ["'none'"],@@ -28,5 +38,15 @@2838 app.use(2939 bodyLimit({ maxSize: MAX_BODY_BYTES, onError: (c) => c.text("Request body too large", 413) }),3040 );41+ app.get("/static/:path{.+}", async (c) => {42+ const path = c.req.param("path");43+ const file = Bun.file(join(PUBLIC_DIR, path));44+ if (!isSafePath(path) || !(await file.exists())) return c.notFound();45+ c.header("Cache-Control", "public, max-age=3600");46+ return c.body(await file.bytes(), 200, { "Content-Type": file.type });47+ });48+ const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };49+ registerRepoPage(app, ctx);50+ registerFileRoutes(app, ctx);3151 return app;3252 }tests/fixture.ts+2 −0
@@ -44,6 +44,7 @@4444 await mkdir(home);4545 await mkdir(join(repoDir, "src/lib/deep"), { recursive: true });4646 await mkdir(join(repoDir, "docs"));47+ await mkdir(join(repoDir, "vendor"));4748 const koh = (...args: string[]) => run(repoDir, home, args);4849 4950 koh("init");@@ -52,6 +53,7 @@5253 await writeFile(join(repoDir, "docs/logo.png"), PNG);5354 await writeFile(join(repoDir, "secret.txt"), "hunter2\n");5455 await writeFile(join(repoDir, "src/lib/deep/util.ts"), "export const one = 1;\n");56+ await writeFile(join(repoDir, "vendor/big.c"), "int x;\n".repeat(100));5557 koh("save", "add docs, a secret and a deep helper");5658 const first = JSON.parse(koh("log", "--json")).head_id as string;5759 koh("flag", first, "--reason", "holds a secret");tests/pages.test.ts+161 −0
@@ -0,0 +1,161 @@1+import { beforeAll, describe, expect, test } from "bun:test";2+import { mkdtemp } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import type { Hono } from "hono";6+import { openBucket } from "../src/bucket/reader.ts";7+import { syncAll } from "../src/bucket/sync.ts";8+import { fileAt, objectBytes } from "../src/mirror/queries.ts";9+import { openMirror } from "../src/mirror/store.ts";10+import { createApp } from "../src/server/app.ts";11+import { openKeprDb } from "../src/server/db.ts";12+import { type Fixture, makeKohBucket } from "./fixture.ts";13+14+let fx: Fixture;15+let app: Hono;16+let mirrorDir: string;17+const get = (path: string) => app.request(path);18+const body = async (path: string) => (await get(path)).text();19+20+beforeAll(async () => {21+ fx = await makeKohBucket();22+ const secretRepo = await makeKohBucket("hidden");23+ const dataDir = await mkdtemp(join(tmpdir(), "kepr-pages-"));24+ mirrorDir = join(dataDir, "mirror");25+ const kepr = openKeprDb(dataDir);26+ for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir]) {27+ await syncAll({ bucket: openBucket({ kind: "folder", path: bucketDir }), kepr, mirrorDir });28+ }29+ kepr.run(30+ "UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",31+ );32+ app = createApp(33+ {34+ domain: "kepr.example",35+ dataDir,36+ adminPasswordHash: "$argon2id$x",37+ bucket: { kind: "folder", path: fx.bucketDir },38+ },39+ kepr,40+ );41+});42+43+const [head, , second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);44+45+describe("the visibility gate", () => {46+ test("a private repo and an unknown one are the same 404, everywhere", async () => {47+ for (const suffix of ["", "/files/src/main.ts", "/raw/src/main.ts"]) {48+ const hidden = await get(`/hidden${suffix}`);49+ const missing = await get(`/nowhere${suffix}`);50+ expect(hidden.status).toBe(404);51+ expect(missing.status).toBe(404);52+ expect((await hidden.text()).replaceAll("/hidden", "/nowhere")).toBe(await missing.text());53+ }54+ });55+});56+57+describe("the repo page", () => {58+ test("renders header, timeline, history and README at HEAD with a short cache", async () => {59+ const response = await get("/demo");60+ expect(response.status).toBe(200);61+ expect(response.headers.get("cache-control")).toBe("public, max-age=60");62+ const page = await response.text();63+ expect(page).toContain('<h1 class="repo-name">demo</h1>');64+ expect(page).toContain("A small repo for <strong>Kepr's</strong> tests.");65+ expect(page).toContain(66+ '<meta property="og:description" content="A small repo for Kepr's tests.">',67+ );68+ expect(page).toContain('href="https://demo.example"');69+ expect(page).toContain('<span class="lang-badge">TypeScript</span>');70+ expect(page).toContain("3 saves");71+ expect(page).toContain('data-copy="nix profile add x"');72+ expect(page.match(/class="dot d\d"/g)?.length).toBe(3);73+ expect(page).toContain("density = activity · position = time");74+ const history = [...page.matchAll(/class="history-id">([0-9a-f]+)</g)].map((m) => m[1]);75+ expect(history).toEqual([head(), second(), first()]);76+ });77+78+ test("checkpoints never appear", async () => {79+ expect(await body("/demo")).not.toContain(fx.saveIds[1] as string);80+ });81+82+ test("the README points relative links and images at raw files and drops unsafe links", async () => {83+ const page = await body("/demo");84+ expect(page).toContain('<a href="/demo/raw/docs/guide.md">the guide</a>');85+ expect(page).toContain('<img src="/demo/raw/docs/logo.png" alt="logo">');86+ expect(page).toContain('<a href="https://example.com">the site</a>');87+ expect(page).not.toContain('href="javascript:');88+ expect(page).toContain("<script>alert");89+ expect(page).not.toContain("<script>alert");90+ });91+});92+93+describe("the file viewer", () => {94+ test("shows a file at HEAD and at an old save, with line anchors", async () => {95+ const now = await get("/demo/files/src/main.ts");96+ expect(now.headers.get("cache-control")).toBe("public, max-age=60");97+ const nowPage = await now.text();98+ expect(nowPage).toContain("hello");99+ expect(nowPage).toContain(100+ '<span class="line" id="L2"><a class="ln" href="#L2">2</a><span class="lc">',101+ );102+ expect(nowPage).toContain('class="tk-');103+104+ const then = await get(`/demo/files/src/main.ts?at=${second().slice(0, 6)}`);105+ expect(then.headers.get("cache-control")).toBe("public, max-age=31536000");106+ const thenPage = await then.text();107+ expect(thenPage).toContain("'hi'");108+ expect(thenPage).not.toContain("hello");109+ expect(thenPage).toContain(`/demo/raw/src/main.ts?at=${second()}`);110+ });111+112+ test("a file absent at that save, a bad save or an unsafe path is a 404", async () => {113+ expect((await get(`/demo/files/src/main.ts?at=${first()}`)).status).toBe(404);114+ expect((await get("/demo/files/src/main.ts?at=zzzzzz")).status).toBe(404);115+ expect((await get("/demo/files/src/main.ts?at=abc")).status).toBe(404);116+ expect((await get("/demo/files/src%2F..%2FREADME.md")).status).toBe(404);117+ expect((await get("/demo/files/nothing.txt")).status).toBe(404);118+ });119+120+ test("Markdown renders, with a source toggle", async () => {121+ const rendered = await body("/demo/files/docs/guide.md");122+ expect(rendered).toContain("<h1>Guide</h1>");123+ const source = await body("/demo/files/docs/guide.md?view=source");124+ expect(source).toContain('id="L1"');125+ expect(source).not.toContain("<h1>Guide</h1>");126+ });127+128+ test("an image displays inline from its raw file", async () => {129+ expect(await body("/demo/files/docs/logo.png")).toContain('<img src="/demo/raw/docs/logo.png"');130+ });131+});132+133+describe("raw files", () => {134+ test("are the exact object bytes, text as text/plain", async () => {135+ const mirror = openMirror(mirrorDir, "demo");136+ for (const [path, type] of [137+ ["src/main.ts", "text/plain; charset=utf-8"],138+ ["docs/logo.png", "image/png"],139+ ["README.md", "text/plain; charset=utf-8"],140+ ] as const) {141+ const response = await get(`/demo/raw/${path}`);142+ const file = fileAt(mirror, head(), path);143+ expect(response.headers.get("content-type")).toBe(type);144+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(145+ objectBytes(mirror, file?.hash as string) as Uint8Array<ArrayBuffer>,146+ );147+ }148+ expect(await (await get(`/demo/raw/src/main.ts?at=${second()}`)).text()).toContain("'hi'");149+ });150+});151+152+test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {153+ const css = await get("/static/css/kepr.css");154+ expect(css.status).toBe(200);155+ expect(css.headers.get("content-type")).toContain("text/css");156+ expect((await get("/static/../package.json")).status).toBe(404);157+ const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";158+ expect(csp).toContain("style-src 'self' https://fonts.bunny.net");159+ expect(csp).toContain("font-src https://fonts.bunny.net");160+ expect(csp).not.toContain("unsafe-inline");161+});tests/server.test.ts+4 −3
@@ -1,3 +1,4 @@1+import { Database } from "bun:sqlite";12 import { expect, test } from "bun:test";23 import { createApp } from "../src/server/app.ts";34 import type { Config } from "../src/server/config.ts";@@ -22,14 +23,14 @@2223 .split("\n")2324 .filter((line) => line.trim() !== "" && !line.startsWith("#")),2425 );25- const registered = createApp(config)26+ const registered = createApp(config, new Database(":memory:"))2627 .routes.filter((route) => !(route.method === "ALL" && route.path === "/*"))2728 .map((route) => `${route.method} ${route.path}`);2829 expect(registered.filter((route) => !listed.has(route))).toEqual([]);2930 });3031 3132 test("responses carry the §25 content security policy", async () => {32- const response = await createApp(config).request("/");33+ const response = await createApp(config, new Database(":memory:")).request("/");3334 const csp = response.headers.get("content-security-policy") ?? "";3435 expect(csp).toContain("script-src 'self'");3536 expect(csp).not.toContain("unsafe-inline");@@ -39,7 +40,7 @@3940 });4041 4142 test("request bodies over 64 KB are refused", async () => {42- const app = createApp(config);43+ const app = createApp(config, new Database(":memory:"));4344 app.post("/echo", async (c) => c.text(String((await c.req.text()).length)));4445 const post = (bytes: number) => app.request("/echo", { method: "POST", body: "x".repeat(bytes) });4546 expect((await post(64 * 1024)).status).toBe(200);tests/sync.test.ts+2 −0
@@ -83,6 +83,8 @@8383 expect(stats.language).toBe("TypeScript");8484 // README 9 + guide 3 + secret 1 + util 1 + main 2 + .kohignore8585 expect(stats.lines).toBeGreaterThan(16);86+ // vendor/big.c's 100 lines of C count toward neither.87+ expect(stats.lines).toBeLessThan(100);8688 expect(stats.updated).toBe(saveByPrefix(t.mirror(), head())?.created_at ?? -1);8789 });8890