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
18 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 −0
package.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": {

view file

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+}

view file

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();

view file

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;

view file

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);

view file

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;

view file

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+}

view file

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+}

view file

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+	"&": "&amp;",13+	"<": "&lt;",14+	">": "&gt;",15+	'"': "&quot;",16+	"'": "&#39;",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+}

view file

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+}

view file

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+}

view file

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+}

view file

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+}

view file

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 }

view file

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");

view file

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&#39;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("&lt;script&gt;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("&#39;hi&#39;");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+});

view file

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);

view file

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 

view file