a self-hosted git server in one binary — everyone reads, only the owner writes
Makefile | 5 +-
README.md | 17 +
web/.gitignore | 41 ++
web/app/[repo]/blob/[ref]/[...path]/page.tsx | 66 ++++
web/app/[repo]/commit/[hash]/page.tsx | 33 ++
web/app/[repo]/commits/[[...ref]]/page.tsx | 46 +++
web/app/[repo]/layout.tsx | 40 ++
web/app/[repo]/page.tsx | 47 +++
web/app/[repo]/refs/page.tsx | 62 +++
web/app/[repo]/tree/[ref]/[[...path]]/page.tsx | 23 ++
web/app/error.tsx | 20 +
web/app/globals.css | 130 +++++++
web/app/icon.svg | 12 +
web/app/layout.tsx | 46 +++
web/app/not-found.tsx | 15 +
web/app/page.tsx | 63 ++++
web/bun.lock | 497 +++++++++++++++++++++++++
web/components/CloneBox.tsx | 26 ++
web/components/Crumbs.tsx | 41 ++
web/components/Diff.tsx | 56 +++
web/components/Markdown.tsx | 43 +++
web/components/Offline.tsx | 31 ++
web/components/RepoNav.tsx | 46 +++
web/components/Starfield.tsx | 34 ++
web/components/TreeTable.tsx | 63 ++++
web/lib/api.ts | 96 +++++
web/lib/diff.ts | 71 ++++
web/lib/format.ts | 31 ++
web/lib/params.ts | 13 +
web/lib/shiki.ts | 46 +++
web/next.config.ts | 7 +
web/package.json | 37 ++
web/postcss.config.mjs | 7 +
web/tsconfig.json | 34 ++
34 files changed, 1844 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 6baabd1..b348798 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
VERSION := $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo dev)
INSTALL_DIR := $(HOME)/.local/bin
-.PHONY: all build install update test clean
+.PHONY: all build install update test web clean
all: build install
@@ -21,5 +21,8 @@ update: all
test:
go test ./...
+web:
+ cd web && bun install && bun run build
+
clean:
rm -rf dist
diff --git a/README.md b/README.md
index 67e9be0..ab3efe1 100644
--- a/README.md
+++ b/README.md
@@ -92,6 +92,23 @@ GET /api/repos/{name}/refs branches and tags
Omitting `ref` uses the repository's default branch.
+## The frontend (`web/`)
+
+`web/` is a Next.js app that reads that API and turns the archive into a designed reading experience — serif hero, syntax-highlighted files (shiki), rendered READMEs with working relative images, per-file diff views with dual line numbers. It fetches server-side, so the backend needs no CORS and its address stays out of the browser.
+
+```sh
+cd web
+bun install
+FRIEREN_API_URL=http://localhost:7420 bun dev
+```
+
+It deploys anywhere Next.js runs; on Vercel, set two things on the project:
+
+- `FRIEREN_API_URL` — the public URL of your frieren backend (e.g. `https://git.example.com`)
+- `FRIEREN_CLONE_URL` — optional; shown in clone commands when it differs from the API URL
+
+Until the backend is reachable, the site renders a graceful "archive unreachable" state with setup instructions, and recovers on its own once the server answers.
+
## What it deliberately isn't
No issues, no pull requests, no user accounts, no markdown rendering yet — it hosts and shows git repositories, and stops there. The single-writer model is the point: if you need collaborators with write access, you want a full forge like Forgejo.
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..5ef6a52
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/web/app/[repo]/blob/[ref]/[...path]/page.tsx b/web/app/[repo]/blob/[ref]/[...path]/page.tsx
new file mode 100644
index 0000000..6524531
--- /dev/null
+++ b/web/app/[repo]/blob/[ref]/[...path]/page.tsx
@@ -0,0 +1,66 @@
+import { notFound } from "next/navigation";
+import { getBlob, rawUrl } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import { byteSize } from "@/lib/format";
+import { highlight, langForFile } from "@/lib/shiki";
+import Crumbs from "@/components/Crumbs";
+
+type Props = { params: Promise<{ repo: string; ref: string; path: string[] }> };
+
+export default async function BlobPage({ params }: Props) {
+ const p = await params;
+ const repo = dec(p.repo);
+ const ref = dec(p.ref);
+ const path = decPath(p.path);
+ const blob = await getBlob(repo, ref, path);
+ if (!blob) notFound();
+
+ const html = blob.binary || blob.truncated ? null : await highlight(blob.content, langForFile(path));
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-wrap items-baseline justify-between gap-3">
+ <Crumbs repo={repo} refName={ref} path={path} leafIsLink={false} />
+ <p className="font-mono text-xs text-dim">
+ {byteSize(blob.size)} ·{" "}
+ <a href={rawUrl(repo, ref, path)} className="text-fog hover:text-frost">
+ raw
+ </a>
+ </p>
+ </div>
+ {blob.binary ? (
+ <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">
+ Binary file.{" "}
+ <a href={rawUrl(repo, ref, path)} className="text-frost hover:underline">
+ Download the raw bytes.
+ </a>
+ </p>
+ ) : blob.truncated ? (
+ <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">
+ Too large to display here.{" "}
+ <a href={rawUrl(repo, ref, path)} className="text-frost hover:underline">
+ View raw instead.
+ </a>
+ </p>
+ ) : html ? (
+ <div
+ className="overflow-x-auto border border-line bg-abyss px-0 py-3 [&_pre]:px-4"
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ ) : (
+ <div className="overflow-x-auto border border-line bg-abyss px-4 py-3">
+ <pre className="shiki">
+ <code>
+ {blob.content.replace(/\n$/, "").split("\n").map((line, i) => (
+ <span key={i} className="line">
+ {line}
+ {"\n"}
+ </span>
+ ))}
+ </code>
+ </pre>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/web/app/[repo]/commit/[hash]/page.tsx b/web/app/[repo]/commit/[hash]/page.tsx
new file mode 100644
index 0000000..8699f4b
--- /dev/null
+++ b/web/app/[repo]/commit/[hash]/page.tsx
@@ -0,0 +1,33 @@
+import { notFound } from "next/navigation";
+import { getCommit } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { fullDate, timeAgo } from "@/lib/format";
+import Diff from "@/components/Diff";
+
+type Props = { params: Promise<{ repo: string; hash: string }> };
+
+export default async function CommitPage({ params }: Props) {
+ const p = await params;
+ const repo = dec(p.repo);
+ const commit = await getCommit(repo, dec(p.hash));
+ if (!commit) notFound();
+
+ return (
+ <div className="space-y-6">
+ <header className="border border-line bg-panel px-5 py-4">
+ <h2 className="text-lg text-snow">{commit.subject}</h2>
+ <p className="mt-2 font-mono text-xs text-dim">{commit.hash}</p>
+ <p className="mt-1 font-mono text-xs text-fog">
+ {commit.author} · {fullDate(commit.when)} ({timeAgo(commit.when)})
+ </p>
+ </header>
+ <Diff patch={commit.patch} />
+ {commit.truncated && (
+ <p className="font-mono text-xs text-dim">
+ diff truncated — see the full change locally with{" "}
+ <code className="text-fog">git show {commit.short}</code>
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/web/app/[repo]/commits/[[...ref]]/page.tsx b/web/app/[repo]/commits/[[...ref]]/page.tsx
new file mode 100644
index 0000000..4d7614d
--- /dev/null
+++ b/web/app/[repo]/commits/[[...ref]]/page.tsx
@@ -0,0 +1,46 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getRepo, getCommits } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+
+type Props = { params: Promise<{ repo: string; ref?: string[] }> };
+
+export default async function CommitsPage({ params }: Props) {
+ const p = await params;
+ const repo = dec(p.repo);
+ const info = await getRepo(repo);
+ if (!info) notFound();
+ const ref = p.ref?.length ? decPath(p.ref) : info.default;
+ const commits = await getCommits(repo, ref);
+ if (!commits) notFound();
+
+ return (
+ <div className="space-y-4">
+ <p className="font-mono text-xs text-dim">
+ history of <span className="text-frost">{ref}</span>
+ {commits.length === 100 && " · latest 100"}
+ </p>
+ {commits.length === 0 ? (
+ <p className="border border-line bg-panel px-4 py-8 text-sm text-fog">No commits yet.</p>
+ ) : (
+ <div className="border border-line bg-panel">
+ {commits.map((c) => (
+ <Link
+ key={c.hash}
+ href={`/${repo}/commit/${c.hash}`}
+ className="flex items-baseline gap-4 border-b border-line px-4 py-2.5 text-sm last:border-b-0 hover:bg-raise"
+ >
+ <span className="shrink-0 font-mono text-xs text-gold/80">{c.short}</span>
+ <span className="min-w-0 flex-1 truncate text-snow">{c.subject}</span>
+ <span className="hidden shrink-0 font-mono text-xs text-dim sm:inline">
+ {c.author}
+ </span>
+ <span className="shrink-0 font-mono text-xs text-dim">{timeAgo(c.when)}</span>
+ </Link>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/web/app/[repo]/layout.tsx b/web/app/[repo]/layout.tsx
new file mode 100644
index 0000000..1e72c35
--- /dev/null
+++ b/web/app/[repo]/layout.tsx
@@ -0,0 +1,40 @@
+import { notFound } from "next/navigation";
+import type { Metadata } from "next";
+import { getRepo, cloneUrl, BackendOffline } from "@/lib/api";
+import { dec } from "@/lib/params";
+import RepoNav from "@/components/RepoNav";
+import CloneBox from "@/components/CloneBox";
+import Offline from "@/components/Offline";
+
+type Props = { children: React.ReactNode; params: Promise<{ repo: string }> };
+
+export async function generateMetadata({ params }: { params: Promise<{ repo: string }> }): Promise<Metadata> {
+ const { repo } = await params;
+ return { title: dec(repo) };
+}
+
+export default async function RepoLayout({ children, params }: Props) {
+ const name = dec((await params).repo);
+ let repo;
+ try {
+ repo = await getRepo(name);
+ } catch (e) {
+ if (e instanceof BackendOffline) return <Offline />;
+ throw e;
+ }
+ if (!repo) notFound();
+
+ return (
+ <div>
+ <div className="mb-6 flex flex-wrap items-start justify-between gap-4">
+ <div>
+ <h1 className="font-display text-3xl">{repo.name}</h1>
+ {repo.description && <p className="mt-1 text-sm text-fog">{repo.description}</p>}
+ </div>
+ <CloneBox url={cloneUrl(repo.name)} />
+ </div>
+ <RepoNav repo={repo.name} defaultBranch={repo.default} />
+ <div className="pt-6">{children}</div>
+ </div>
+ );
+}
diff --git a/web/app/[repo]/page.tsx b/web/app/[repo]/page.tsx
new file mode 100644
index 0000000..4415bb6
--- /dev/null
+++ b/web/app/[repo]/page.tsx
@@ -0,0 +1,47 @@
+import { notFound } from "next/navigation";
+import { getRepo, getTree, getReadme, cloneUrl } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+import TreeTable from "@/components/TreeTable";
+import Markdown from "@/components/Markdown";
+
+export default async function RepoOverview({ params }: { params: Promise<{ repo: string }> }) {
+ const name = dec((await params).repo);
+ const repo = await getRepo(name);
+ if (!repo) notFound();
+
+ if (repo.empty) {
+ return (
+ <div className="border border-line bg-panel px-6 py-10">
+ <p className="font-mono text-sm text-gold">❄ an empty vessel</p>
+ <p className="mt-3 text-sm text-fog">The owner hasn't pushed anything yet:</p>
+ <pre className="mt-4 overflow-x-auto border border-line bg-abyss px-4 py-3 font-mono text-xs text-fog">
+ {`git remote add frieren ${cloneUrl(repo.name)}\ngit push frieren ${repo.default}`}
+ </pre>
+ </div>
+ );
+ }
+
+ const [entries, readme] = await Promise.all([
+ getTree(name, repo.default, ""),
+ getReadme(name, repo.default),
+ ]);
+
+ return (
+ <div className="space-y-8">
+ <p className="font-mono text-xs text-dim">
+ <span className="text-gold/70">▪</span> {repo.default} · last commit{" "}
+ {timeAgo(repo.lastCommit)}
+ </p>
+ <TreeTable repo={repo.name} refName={repo.default} dir="" entries={entries ?? []} />
+ {readme && (
+ <section>
+ <h2 className="mb-3 border-b border-line pb-2 font-mono text-xs text-dim">
+ {readme.name}
+ </h2>
+ <Markdown repo={repo.name} refName={repo.default} source={readme.content} />
+ </section>
+ )}
+ </div>
+ );
+}
diff --git a/web/app/[repo]/refs/page.tsx b/web/app/[repo]/refs/page.tsx
new file mode 100644
index 0000000..c2ba0ba
--- /dev/null
+++ b/web/app/[repo]/refs/page.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getRefs } from "@/lib/api";
+import { dec } from "@/lib/params";
+import { timeAgo } from "@/lib/format";
+
+type Props = { params: Promise<{ repo: string }> };
+
+function RefList({
+ repo,
+ kind,
+ refs,
+}: {
+ repo: string;
+ kind: "branch" | "tag";
+ refs: { name: string; short: string; when: string; subject: string }[];
+}) {
+ return (
+ <section>
+ <h2 className="mb-3 font-mono text-xs text-dim">
+ {kind === "branch" ? "branches" : "tags"}
+ </h2>
+ {refs.length === 0 ? (
+ <p className="text-sm text-dim">none</p>
+ ) : (
+ <div className="border border-line bg-panel">
+ {refs.map((r) => (
+ <div
+ key={r.name}
+ className="flex items-baseline gap-4 border-b border-line px-4 py-2.5 text-sm last:border-b-0"
+ >
+ {kind === "branch" ? (
+ <Link
+ href={`/${repo}/tree/${encodeURIComponent(r.name)}`}
+ className="shrink-0 font-mono text-frost hover:underline"
+ >
+ {r.name}
+ </Link>
+ ) : (
+ <span className="shrink-0 font-mono text-gold/90">{r.name}</span>
+ )}
+ <span className="min-w-0 flex-1 truncate text-fog">{r.subject}</span>
+ <span className="shrink-0 font-mono text-xs text-dim">{timeAgo(r.when)}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </section>
+ );
+}
+
+export default async function RefsPage({ params }: Props) {
+ const repo = dec((await params).repo);
+ const refs = await getRefs(repo);
+ if (!refs) notFound();
+ return (
+ <div className="space-y-8">
+ <RefList repo={repo} kind="branch" refs={refs.branches} />
+ <RefList repo={repo} kind="tag" refs={refs.tags} />
+ </div>
+ );
+}
diff --git a/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx b/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx
new file mode 100644
index 0000000..3db9605
--- /dev/null
+++ b/web/app/[repo]/tree/[ref]/[[...path]]/page.tsx
@@ -0,0 +1,23 @@
+import { notFound } from "next/navigation";
+import { getTree } from "@/lib/api";
+import { dec, decPath } from "@/lib/params";
+import TreeTable from "@/components/TreeTable";
+import Crumbs from "@/components/Crumbs";
+
+type Props = { params: Promise<{ repo: string; ref: string; path?: string[] }> };
+
+export default async function TreePage({ params }: Props) {
+ const p = await params;
+ const repo = dec(p.repo);
+ const ref = dec(p.ref);
+ const path = decPath(p.path);
+ const entries = await getTree(repo, ref, path);
+ if (!entries) notFound();
+
+ return (
+ <div className="space-y-4">
+ <Crumbs repo={repo} refName={ref} path={path} leafIsLink={false} />
+ <TreeTable repo={repo} refName={ref} dir={path} entries={entries} />
+ </div>
+ );
+}
diff --git a/web/app/error.tsx b/web/app/error.tsx
new file mode 100644
index 0000000..fc6a6ac
--- /dev/null
+++ b/web/app/error.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+export default function Error({ reset }: { error: Error; reset: () => void }) {
+ return (
+ <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10">
+ <p className="font-mono text-sm text-gold">❄ the archive is unreachable</p>
+ <p className="mt-4 text-sm leading-relaxed text-fog">
+ The backend git server didn't answer. It may be waking up, restarting, or
+ offline — the repositories themselves are safe on the owner's machine.
+ </p>
+ <button
+ type="button"
+ onClick={reset}
+ className="mt-6 border border-line px-4 py-1.5 font-mono text-xs text-fog transition-colors hover:border-frost/60 hover:text-snow"
+ >
+ try again
+ </button>
+ </div>
+ );
+}
diff --git a/web/app/globals.css b/web/app/globals.css
new file mode 100644
index 0000000..70a4c76
--- /dev/null
+++ b/web/app/globals.css
@@ -0,0 +1,130 @@
+@import "tailwindcss";
+
+@theme inline {
+ --color-night: #0b0c10;
+ --color-abyss: #08090c;
+ --color-panel: #101218;
+ --color-raise: #161923;
+ --color-line: #1f232e;
+ --color-fog: #8a93a3;
+ --color-dim: #5c6472;
+ --color-snow: #dfe3ea;
+ --color-frost: #5eead4;
+ --color-gold: #e8c170;
+ --color-add: #86efac;
+ --color-add-bg: #0d2818;
+ --color-del: #fca5a5;
+ --color-del-bg: #2d1214;
+ --font-body: var(--font-inter), system-ui, sans-serif;
+ --font-mono: var(--font-jbmono), ui-monospace, monospace;
+ --font-display: var(--font-serif), Georgia, serif;
+}
+
+html {
+ background: var(--color-night);
+ color-scheme: dark;
+}
+
+body {
+ background: var(--color-night);
+ color: var(--color-snow);
+ font-family: var(--font-body);
+ -webkit-font-smoothing: antialiased;
+}
+
+::selection {
+ background: color-mix(in srgb, var(--color-frost) 30%, transparent);
+}
+
+/* ——— code blocks ——— */
+
+.shiki {
+ background: transparent !important;
+ counter-reset: ln;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+}
+
+.shiki code {
+ display: block;
+ width: fit-content;
+ min-width: 100%;
+}
+
+.shiki .line::before {
+ counter-increment: ln;
+ content: counter(ln);
+ display: inline-block;
+ width: 3.5ch;
+ margin-right: 2ch;
+ text-align: right;
+ color: var(--color-dim);
+ user-select: none;
+}
+
+/* ——— rendered markdown ——— */
+
+.prose-frost {
+ font-size: 0.9375rem;
+ line-height: 1.7;
+ color: var(--color-snow);
+}
+.prose-frost h1,
+.prose-frost h2,
+.prose-frost h3,
+.prose-frost h4 {
+ font-family: var(--font-display);
+ font-weight: 400;
+ letter-spacing: 0.01em;
+ margin: 1.6em 0 0.5em;
+ color: var(--color-snow);
+}
+.prose-frost h1 { font-size: 1.75rem; margin-top: 0.4em; }
+.prose-frost h2 { font-size: 1.4rem; border-bottom: 1px solid var(--color-line); padding-bottom: 0.3em; }
+.prose-frost h3 { font-size: 1.15rem; }
+.prose-frost p { margin: 0.8em 0; }
+.prose-frost a { color: var(--color-frost); }
+.prose-frost a:hover { text-decoration: underline; }
+.prose-frost code {
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+ background: var(--color-raise);
+ border: 1px solid var(--color-line);
+ padding: 0.1em 0.35em;
+}
+.prose-frost pre {
+ background: var(--color-abyss);
+ border: 1px solid var(--color-line);
+ padding: 0.9rem 1.1rem;
+ overflow-x: auto;
+ margin: 1em 0;
+ font-size: 0.8125rem;
+ line-height: 1.6;
+}
+.prose-frost pre code { background: none; border: 0; padding: 0; font-size: inherit; }
+.prose-frost ul, .prose-frost ol { padding-left: 1.4em; margin: 0.8em 0; }
+.prose-frost ul { list-style: none; }
+.prose-frost ul > li::before {
+ content: "▪";
+ color: var(--color-frost);
+ display: inline-block;
+ width: 1.2em;
+ margin-left: -1.2em;
+ font-size: 0.7em;
+ vertical-align: 0.15em;
+}
+.prose-frost ol { list-style: decimal; }
+.prose-frost li { margin: 0.3em 0; }
+.prose-frost blockquote {
+ border-left: 2px solid var(--color-gold);
+ padding-left: 1em;
+ color: var(--color-fog);
+ margin: 1em 0;
+}
+.prose-frost table { border-collapse: collapse; margin: 1em 0; font-size: 0.875rem; }
+.prose-frost th, .prose-frost td { border: 1px solid var(--color-line); padding: 0.4em 0.8em; text-align: left; }
+.prose-frost th { background: var(--color-raise); font-weight: 600; }
+.prose-frost img { max-width: 100%; border: 1px solid var(--color-line); }
+.prose-frost hr { border: 0; border-top: 1px solid var(--color-line); margin: 1.6em 0; }
+.prose-frost [align="center"] { text-align: center; }
+.prose-frost [align="center"] img { display: inline-block; }
diff --git a/web/app/icon.svg b/web/app/icon.svg
new file mode 100644
index 0000000..9fb64c9
--- /dev/null
+++ b/web/app/icon.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <rect width="32" height="32" fill="#0b0c10"/>
+ <g fill="#5eead4" shape-rendering="crispEdges">
+ <rect x="14" y="4" width="4" height="24"/>
+ <rect x="4" y="14" width="24" height="4"/>
+ <rect x="8" y="8" width="4" height="4"/>
+ <rect x="20" y="8" width="4" height="4"/>
+ <rect x="8" y="20" width="4" height="4"/>
+ <rect x="20" y="20" width="4" height="4"/>
+ </g>
+ <rect x="14" y="14" width="4" height="4" fill="#e8c170" shape-rendering="crispEdges"/>
+</svg>
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..b172258
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google";
+import Link from "next/link";
+import Starfield from "@/components/Starfield";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jbmono" });
+const serif = Instrument_Serif({
+ subsets: ["latin"],
+ weight: "400",
+ style: ["normal", "italic"],
+ variable: "--font-serif",
+});
+
+export const metadata: Metadata = {
+ title: { default: "frieren", template: "%s · frieren" },
+ description:
+ "A self-hosted archive of one person's code — browse and clone everything, write nothing.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en" className={`${inter.variable} ${mono.variable} ${serif.variable}`}>
+ <body className="relative min-h-screen">
+ <Starfield />
+ <header className="mx-auto flex max-w-5xl items-baseline justify-between px-6 pb-4 pt-6">
+ <Link href="/" className="font-display text-2xl tracking-wide">
+ <span className="text-frost">❄</span> frieren
+ </Link>
+ <span className="font-mono text-xs text-dim">a personal git archive</span>
+ </header>
+ <main className="mx-auto max-w-5xl px-6 pb-24 pt-6">{children}</main>
+ <footer className="mx-auto max-w-5xl border-t border-line px-6 py-6 font-mono text-xs text-dim">
+ one writer · world readers — served from the owner's own machine by{" "}
+ <a
+ href="https://github.com/justin06lee/frieren"
+ className="text-fog hover:text-frost"
+ >
+ frieren
+ </a>
+ </footer>
+ </body>
+ </html>
+ );
+}
diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx
new file mode 100644
index 0000000..6740b12
--- /dev/null
+++ b/web/app/not-found.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+ <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10 text-center">
+ <p className="font-display text-4xl">404</p>
+ <p className="mt-3 text-sm text-fog">
+ Whatever was here, time has taken it.{" "}
+ <Link href="/" className="text-frost hover:underline">
+ Back to the archive.
+ </Link>
+ </p>
+ </div>
+ );
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..dd86680
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { getRepos, BackendOffline, type RepoInfo } from "@/lib/api";
+import { timeAgo } from "@/lib/format";
+import Offline from "@/components/Offline";
+
+// Always render against the live backend — never bake an offline state in at build time.
+export const dynamic = "force-dynamic";
+
+export default async function Home() {
+ let repos: RepoInfo[];
+ try {
+ repos = (await getRepos()) ?? [];
+ } catch (e) {
+ if (e instanceof BackendOffline) return <Offline />;
+ throw e;
+ }
+
+ return (
+ <>
+ <section className="mb-14 mt-6">
+ <h1 className="font-display text-5xl leading-tight">
+ Code that <em className="text-frost">outlives</em> the platforms.
+ </h1>
+ <p className="mt-4 max-w-2xl text-fog">
+ Every repository here lives on hardware its owner controls. Browse anything,
+ clone everything — writing is reserved for one person.
+ </p>
+ </section>
+
+ {repos.length === 0 ? (
+ <p className="border border-line bg-panel px-6 py-10 text-sm text-fog">
+ The archive is empty — the first <code className="text-snow">git push</code> will
+ fill it.
+ </p>
+ ) : (
+ <div className="grid gap-4 sm:grid-cols-2">
+ {repos.map((r) => (
+ <Link
+ key={r.name}
+ href={`/${r.name}`}
+ className="group border border-line bg-panel p-5 transition-colors hover:border-frost/60"
+ >
+ <div className="flex items-baseline justify-between gap-3">
+ <h2 className="font-mono text-base text-snow group-hover:text-frost">
+ {r.name}
+ </h2>
+ <span className="shrink-0 font-mono text-xs text-dim">
+ {r.empty ? "empty" : timeAgo(r.lastCommit)}
+ </span>
+ </div>
+ <p className="mt-2 line-clamp-2 min-h-10 text-sm text-fog">
+ {r.description || "no description"}
+ </p>
+ <p className="mt-3 font-mono text-xs text-dim">
+ <span className="text-gold/70">▪</span> {r.default}
+ </p>
+ </Link>
+ ))}
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/web/bun.lock b/web/bun.lock
new file mode 100644
index 0000000..01b37c2
--- /dev/null
+++ b/web/bun.lock
@@ -0,0 +1,497 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "web",
+ "dependencies": {
+ "next": "16.3.1",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
+ "shiki": "^4.4.3",
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5",
+ },
+ },
+ },
+ "trustedDependencies": [
+ "sharp",
+ ],
+ "packages": {
+ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
+
+ "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
+
+ "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="],
+
+ "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="],
+
+ "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="],
+
+ "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="],
+
+ "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="],
+
+ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="],
+
+ "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="],
+
+ "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="],
+
+ "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="],
+
+ "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="],
+
+ "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="],
+
+ "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="],
+
+ "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="],
+
+ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="],
+
+ "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="],
+
+ "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="],
+
+ "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="],
+
+ "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="],
+
+ "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="],
+
+ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="],
+
+ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="],
+
+ "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="],
+
+ "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="],
+
+ "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="],
+
+ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@next/env": ["@next/env@16.3.1", "", {}, "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ=="],
+
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw=="],
+
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw=="],
+
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w=="],
+
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw=="],
+
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg=="],
+
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA=="],
+
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog=="],
+
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw=="],
+
+ "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="],
+
+ "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="],
+
+ "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="],
+
+ "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="],
+
+ "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="],
+
+ "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="],
+
+ "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="],
+
+ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
+
+ "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
+
+ "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
+
+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
+
+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
+
+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
+
+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
+
+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
+
+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
+
+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
+
+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
+
+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
+
+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
+
+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
+
+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
+
+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
+
+ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
+
+ "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+
+ "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
+
+ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
+
+ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
+
+ "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
+
+ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
+
+ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+
+ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="],
+
+ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
+
+ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+
+ "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
+
+ "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
+
+ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
+
+ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+
+ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+
+ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+
+ "enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
+
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
+ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
+
+ "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
+
+ "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
+
+ "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
+
+ "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
+
+ "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
+
+ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
+
+ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+
+ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
+
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
+ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+
+ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+
+ "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
+
+ "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+
+ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+
+ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
+
+ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+
+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
+ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
+ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+
+ "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
+
+ "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
+
+ "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
+
+ "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
+
+ "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
+
+ "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
+
+ "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
+
+ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
+
+ "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
+
+ "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
+
+ "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
+
+ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
+
+ "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
+
+ "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
+
+ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+
+ "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
+
+ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
+
+ "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
+
+ "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
+
+ "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
+
+ "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
+
+ "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
+
+ "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
+
+ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
+
+ "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
+
+ "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
+
+ "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
+
+ "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
+
+ "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
+
+ "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
+
+ "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
+
+ "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
+
+ "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
+
+ "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
+
+ "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
+
+ "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
+
+ "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
+
+ "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
+
+ "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
+
+ "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
+
+ "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
+
+ "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
+
+ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
+
+ "next": ["next@16.3.1", "", { "dependencies": { "@next/env": "16.3.1", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.1", "@next/swc-darwin-x64": "16.3.1", "@next/swc-linux-arm64-gnu": "16.3.1", "@next/swc-linux-arm64-musl": "16.3.1", "@next/swc-linux-x64-gnu": "16.3.1", "@next/swc-linux-x64-musl": "16.3.1", "@next/swc-win32-arm64-msvc": "16.3.1", "@next/swc-win32-x64-msvc": "16.3.1", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA=="],
+
+ "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
+
+ "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
+
+ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
+
+ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
+
+ "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
+
+ "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
+
+ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
+ "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
+
+ "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
+
+ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+
+ "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
+
+ "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
+
+ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
+
+ "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
+
+ "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
+
+ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
+ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
+
+ "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
+
+ "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+
+ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
+
+ "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
+
+ "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+
+ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
+
+ "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
+
+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
+
+ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
+
+ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+ "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
+
+ "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
+
+ "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
+
+ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
+
+ "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
+
+ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+
+ "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+
+ "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
+
+ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+
+ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+
+ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+ }
+}
diff --git a/web/components/CloneBox.tsx b/web/components/CloneBox.tsx
new file mode 100644
index 0000000..9ded322
--- /dev/null
+++ b/web/components/CloneBox.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useState } from "react";
+
+export default function CloneBox({ url }: { url: string }) {
+ const [copied, setCopied] = useState(false);
+ const cmd = `git clone ${url}`;
+ return (
+ <button
+ type="button"
+ onClick={() => {
+ navigator.clipboard.writeText(cmd).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ });
+ }}
+ title="copy clone command"
+ className="group flex max-w-full items-center gap-3 border border-line bg-panel px-3 py-1.5 text-left font-mono text-xs text-fog transition-colors hover:border-frost/60"
+ >
+ <span className="truncate">{cmd}</span>
+ <span className={copied ? "text-frost" : "text-dim group-hover:text-frost"}>
+ {copied ? "copied" : "copy"}
+ </span>
+ </button>
+ );
+}
diff --git a/web/components/Crumbs.tsx b/web/components/Crumbs.tsx
new file mode 100644
index 0000000..feb8ec8
--- /dev/null
+++ b/web/components/Crumbs.tsx
@@ -0,0 +1,41 @@
+import Link from "next/link";
+
+// Breadcrumb path for tree/blob pages: repo / dir / dir / name
+export default function Crumbs({
+ repo,
+ refName,
+ path,
+ leafIsLink,
+}: {
+ repo: string;
+ refName: string;
+ path: string;
+ leafIsLink: boolean;
+}) {
+ const ref = encodeURIComponent(refName);
+ const parts = path === "" ? [] : path.split("/");
+ return (
+ <p className="font-mono text-sm text-fog">
+ <Link href={`/${repo}/tree/${ref}`} className="text-snow hover:text-frost">
+ {repo}
+ </Link>
+ {parts.map((part, i) => {
+ const sub = parts.slice(0, i + 1).join("/");
+ const last = i === parts.length - 1;
+ return (
+ <span key={sub}>
+ {" / "}
+ {last && !leafIsLink ? (
+ <span className="text-snow">{part}</span>
+ ) : (
+ <Link href={`/${repo}/tree/${ref}/${sub}`} className="hover:text-frost">
+ {part}
+ </Link>
+ )}
+ </span>
+ );
+ })}
+ <span className="ml-3 border border-line px-1.5 py-0.5 text-xs text-frost">{refName}</span>
+ </p>
+ );
+}
diff --git a/web/components/Diff.tsx b/web/components/Diff.tsx
new file mode 100644
index 0000000..dfe9aa3
--- /dev/null
+++ b/web/components/Diff.tsx
@@ -0,0 +1,56 @@
+import { parsePatch } from "@/lib/diff";
+
+const lineStyles = {
+ add: "bg-add-bg text-add",
+ del: "bg-del-bg text-del",
+ ctx: "text-fog",
+ hunk: "bg-raise text-frost/80",
+} as const;
+
+export default function Diff({ patch }: { patch: string }) {
+ const { stat, files } = parsePatch(patch);
+ return (
+ <div className="space-y-6">
+ {stat && (
+ <pre className="overflow-x-auto border border-line bg-panel px-4 py-3 font-mono text-xs leading-relaxed text-fog">
+ {stat}
+ </pre>
+ )}
+ {files.map((f) => (
+ <section key={f.path} className="border border-line bg-panel">
+ <header className="flex items-center gap-3 border-b border-line bg-raise px-4 py-2 font-mono text-xs">
+ <span className="truncate text-snow">{f.path}</span>
+ <span className="ml-auto shrink-0 text-add">+{f.adds}</span>
+ <span className="shrink-0 text-del">−{f.dels}</span>
+ </header>
+ {f.binary ? (
+ <p className="px-4 py-4 font-mono text-xs text-fog">binary file changed</p>
+ ) : (
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse font-mono text-xs leading-relaxed">
+ <tbody>
+ {f.lines.map((l, i) => (
+ <tr key={i} className={lineStyles[l.kind]}>
+ <td className="w-10 select-none pr-2 text-right align-top text-dim">
+ {l.old ?? ""}
+ </td>
+ <td className="w-10 select-none pr-3 text-right align-top text-dim">
+ {l.new ?? ""}
+ </td>
+ <td className="w-4 select-none text-center align-top opacity-70">
+ {l.kind === "add" ? "+" : l.kind === "del" ? "−" : ""}
+ </td>
+ <td className="whitespace-pre pr-4 align-top">
+ {l.kind === "hunk" ? l.text : l.text || " "}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </section>
+ ))}
+ </div>
+ );
+}
diff --git a/web/components/Markdown.tsx b/web/components/Markdown.tsx
new file mode 100644
index 0000000..03a7c12
--- /dev/null
+++ b/web/components/Markdown.tsx
@@ -0,0 +1,43 @@
+import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
+import remarkGfm from "remark-gfm";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+import { rawUrl } from "@/lib/api";
+
+// READMEs often embed HTML (centered headers, <img>, <br>). Render it, but
+// sanitized — repository content must never script against the site.
+const schema = {
+ ...defaultSchema,
+ attributes: {
+ ...defaultSchema.attributes,
+ "*": [...(defaultSchema.attributes?.["*"] ?? []), "align", "width", "height"],
+ },
+};
+
+// Renders a repository README. Relative image/link targets are rewritten to
+// the backend's raw endpoint so screenshots in READMEs just work.
+export default function Markdown({
+ repo,
+ refName,
+ source,
+}: {
+ repo: string;
+ refName: string;
+ source: string;
+}) {
+ const transform = (url: string) => {
+ if (/^(https?:|mailto:|#|data:)/i.test(url)) return defaultUrlTransform(url);
+ return rawUrl(repo, refName, url.replace(/^\.\//, ""));
+ };
+ return (
+ <div className="prose-frost">
+ <ReactMarkdown
+ remarkPlugins={[remarkGfm]}
+ rehypePlugins={[rehypeRaw, [rehypeSanitize, schema]]}
+ urlTransform={transform}
+ >
+ {source}
+ </ReactMarkdown>
+ </div>
+ );
+}
diff --git a/web/components/Offline.tsx b/web/components/Offline.tsx
new file mode 100644
index 0000000..6acecce
--- /dev/null
+++ b/web/components/Offline.tsx
@@ -0,0 +1,31 @@
+import { apiBase } from "@/lib/api";
+
+// Shown while the backend git server isn't reachable yet.
+export default function Offline() {
+ const base = apiBase();
+ return (
+ <div className="mx-auto max-w-xl border border-line bg-panel px-8 py-10">
+ <p className="font-mono text-sm text-gold">❄ the archive is unreachable</p>
+ <p className="mt-4 text-sm leading-relaxed text-fog">
+ {base ? (
+ <>
+ This site is configured to read from <code className="text-snow">{base}</code>,
+ but that server didn't answer. If you run this frieren, check that the
+ backend is up and reachable from the internet.
+ </>
+ ) : (
+ <>
+ No backend is configured yet. Set the <code className="text-snow">FRIEREN_API_URL</code>{" "}
+ environment variable on this deployment to the public URL of your frieren git
+ server (for example <code className="text-snow">https://git.example.com</code>),
+ then redeploy.
+ </>
+ )}
+ </p>
+ <p className="mt-4 text-sm leading-relaxed text-fog">
+ Everything here is read-only — the repositories live on the owner's own
+ machine, and this page is just the window into them.
+ </p>
+ </div>
+ );
+}
diff --git a/web/components/RepoNav.tsx b/web/components/RepoNav.tsx
new file mode 100644
index 0000000..22784a8
--- /dev/null
+++ b/web/components/RepoNav.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+export default function RepoNav({
+ repo,
+ defaultBranch,
+}: {
+ repo: string;
+ defaultBranch: string;
+}) {
+ const pathname = usePathname();
+ const base = `/${repo}`;
+ const tabs = [
+ { label: "overview", href: base, active: pathname === base },
+ {
+ label: "files",
+ href: `${base}/tree/${encodeURIComponent(defaultBranch)}`,
+ active: pathname.startsWith(`${base}/tree/`) || pathname.startsWith(`${base}/blob/`),
+ },
+ {
+ label: "commits",
+ href: `${base}/commits`,
+ active: pathname.startsWith(`${base}/commit`),
+ },
+ { label: "refs", href: `${base}/refs`, active: pathname === `${base}/refs` },
+ ];
+ return (
+ <nav className="flex gap-6 border-b border-line font-mono text-sm">
+ {tabs.map((t) => (
+ <Link
+ key={t.label}
+ href={t.href}
+ className={
+ t.active
+ ? "-mb-px border-b border-frost pb-2 text-snow"
+ : "pb-2 text-fog transition-colors hover:text-snow"
+ }
+ >
+ {t.label}
+ </Link>
+ ))}
+ </nav>
+ );
+}
diff --git a/web/components/Starfield.tsx b/web/components/Starfield.tsx
new file mode 100644
index 0000000..276e7f1
--- /dev/null
+++ b/web/components/Starfield.tsx
@@ -0,0 +1,34 @@
+// The banner's commit-graph constellation, faint, behind the page header.
+export default function Starfield() {
+ return (
+ <svg
+ aria-hidden
+ className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 w-full"
+ viewBox="0 0 1200 288"
+ preserveAspectRatio="xMidYMin slice"
+ >
+ <g stroke="#1d2430" strokeWidth="1.5">
+ <line x1="705" y1="165" x2="785" y2="145" />
+ <line x1="785" y1="145" x2="865" y2="125" />
+ <line x1="865" y1="125" x2="945" y2="105" />
+ <line x1="785" y1="145" x2="845" y2="195" />
+ <line x1="845" y1="195" x2="925" y2="175" />
+ <line x1="925" y1="175" x2="945" y2="105" />
+ </g>
+ <g shapeRendering="crispEdges">
+ <rect x="702" y="162" width="7" height="7" fill="#e8c170" opacity="0.55" />
+ <rect x="782" y="142" width="7" height="7" fill="#e8c170" opacity="0.55" />
+ <rect x="862" y="122" width="7" height="7" fill="#e8c170" opacity="0.55" />
+ <rect x="942" y="102" width="7" height="7" fill="#e8c170" opacity="0.55" />
+ <rect x="842" y="192" width="7" height="7" fill="#5eead4" opacity="0.55" />
+ <rect x="922" y="172" width="7" height="7" fill="#5eead4" opacity="0.55" />
+ {[
+ [80, 60], [170, 130], [260, 40], [340, 100], [440, 60], [520, 150],
+ [600, 50], [1020, 70], [1100, 150], [1160, 60], [90, 200], [420, 210],
+ ].map(([x, y]) => (
+ <rect key={`${x}-${y}`} x={x} y={y} width="3" height="3" fill="#2b3342" />
+ ))}
+ </g>
+ </svg>
+ );
+}
diff --git a/web/components/TreeTable.tsx b/web/components/TreeTable.tsx
new file mode 100644
index 0000000..f54f32b
--- /dev/null
+++ b/web/components/TreeTable.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import type { TreeEntry } from "@/lib/api";
+import { byteSize } from "@/lib/format";
+
+function DirIcon() {
+ return (
+ <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-gold/80" aria-hidden shapeRendering="crispEdges">
+ <path d="M1 3h5l1 2h8v8H1z" />
+ </svg>
+ );
+}
+
+function FileIcon() {
+ return (
+ <svg viewBox="0 0 16 16" className="h-3.5 w-3.5 fill-dim" aria-hidden shapeRendering="crispEdges">
+ <path d="M3 1h7l3 3v11H3z" />
+ </svg>
+ );
+}
+
+export default function TreeTable({
+ repo,
+ refName,
+ dir,
+ entries,
+}: {
+ repo: string;
+ refName: string;
+ dir: string;
+ entries: TreeEntry[];
+}) {
+ const ref = encodeURIComponent(refName);
+ const base = dir ? `${dir}/` : "";
+ return (
+ <div className="border border-line bg-panel">
+ {entries.map((e) => {
+ const href = `/${repo}/${e.type === "tree" ? "tree" : "blob"}/${ref}/${base}${e.name}`;
+ return (
+ <div
+ key={e.name}
+ className="flex items-center gap-3 border-b border-line px-4 py-2 text-sm last:border-b-0 hover:bg-raise"
+ >
+ {e.type === "tree" ? <DirIcon /> : <FileIcon />}
+ {e.type === "commit" ? (
+ <span className="font-mono text-fog">{e.name} @ {e.hash.slice(0, 7)}</span>
+ ) : (
+ <Link href={href} className="font-mono text-snow hover:text-frost">
+ {e.name}
+ {e.type === "tree" ? "/" : ""}
+ </Link>
+ )}
+ <span className="ml-auto font-mono text-xs text-dim">
+ {e.type === "blob" ? byteSize(e.size) : ""}
+ </span>
+ </div>
+ );
+ })}
+ {entries.length === 0 && (
+ <p className="px-4 py-6 text-sm text-fog">empty directory</p>
+ )}
+ </div>
+ );
+}
diff --git a/web/lib/api.ts b/web/lib/api.ts
new file mode 100644
index 0000000..ba6fb4e
--- /dev/null
+++ b/web/lib/api.ts
@@ -0,0 +1,96 @@
+// Server-side client for the frieren backend's read-only JSON API.
+
+export type RepoInfo = {
+ name: string;
+ description: string;
+ default: string;
+ lastCommit: string;
+ empty: boolean;
+};
+
+export type TreeEntry = {
+ mode: string;
+ type: "blob" | "tree" | "commit";
+ hash: string;
+ size: number;
+ name: string;
+};
+
+export type Blob = {
+ path: string;
+ size: number;
+ binary: boolean;
+ truncated: boolean;
+ content: string;
+};
+
+export type Readme = { name: string; content: string };
+
+export type Commit = {
+ hash: string;
+ short: string;
+ author: string;
+ when: string;
+ subject: string;
+};
+
+export type CommitDetail = Commit & { patch: string; truncated: boolean };
+
+export type Refs = {
+ branches: { name: string; short: string; when: string; subject: string }[];
+ tags: { name: string; short: string; when: string; subject: string }[];
+};
+
+export function apiBase(): string | null {
+ const base = process.env.FRIEREN_API_URL;
+ return base ? base.replace(/\/+$/, "") : null;
+}
+
+// Where git users point their clients — defaults to the API host.
+export function cloneUrl(repo: string): string {
+ const base = process.env.FRIEREN_CLONE_URL?.replace(/\/+$/, "") ?? apiBase();
+ return `${base ?? "https://your-frieren-server"}/${repo}.git`;
+}
+
+export function rawUrl(repo: string, ref: string, path: string): string {
+ const segs = path.split("/").map(encodeURIComponent).join("/");
+ return `${apiBase()}/${repo}/raw/${encodeURIComponent(ref)}/${segs}`;
+}
+
+export class BackendOffline extends Error {
+ constructor() {
+ super("frieren backend unreachable");
+ }
+}
+
+// api fetches a path, returning null on 404/400 and throwing BackendOffline
+// when the backend is missing or unreachable.
+async function api<T>(path: string): Promise<T | null> {
+ const base = apiBase();
+ if (!base) throw new BackendOffline();
+ let res: Response;
+ try {
+ res = await fetch(`${base}/api${path}`, { next: { revalidate: 30 } });
+ } catch {
+ throw new BackendOffline();
+ }
+ if (res.status === 404 || res.status === 400) return null;
+ if (!res.ok) throw new BackendOffline();
+ return (await res.json()) as T;
+}
+
+const q = encodeURIComponent;
+
+export const getRepos = () => api<RepoInfo[]>("/repos");
+export const getRepo = (repo: string) => api<RepoInfo>(`/repos/${q(repo)}`);
+export const getTree = (repo: string, ref: string, path: string) =>
+ api<TreeEntry[]>(`/repos/${q(repo)}/tree?ref=${q(ref)}&path=${q(path)}`);
+export const getBlob = (repo: string, ref: string, path: string) =>
+ api<Blob>(`/repos/${q(repo)}/blob?ref=${q(ref)}&path=${q(path)}`);
+export const getReadme = (repo: string, ref: string) =>
+ api<Readme>(`/repos/${q(repo)}/readme?ref=${q(ref)}`);
+export const getCommits = (repo: string, ref: string, n = 100) =>
+ api<Commit[]>(`/repos/${q(repo)}/commits?ref=${q(ref)}&n=${n}`);
+export const getCommit = (repo: string, hash: string) =>
+ api<CommitDetail>(`/repos/${q(repo)}/commit/${q(hash)}`);
+export const getRefs = (repo: string) => api<Refs>(`/repos/${q(repo)}/refs`);
diff --git a/web/lib/diff.ts b/web/lib/diff.ts
new file mode 100644
index 0000000..25acbed
--- /dev/null
+++ b/web/lib/diff.ts
@@ -0,0 +1,71 @@
+// Parses the `git show --stat --patch` text the backend returns into a
+// structure the diff view can render with per-side line numbers.
+
+export type DiffLine = {
+ kind: "add" | "del" | "ctx" | "hunk";
+ old: number | null;
+ new: number | null;
+ text: string;
+};
+
+export type DiffFile = {
+ path: string;
+ adds: number;
+ dels: number;
+ binary: boolean;
+ lines: DiffLine[];
+};
+
+export type ParsedPatch = { stat: string; files: DiffFile[] };
+
+export function parsePatch(patch: string): ParsedPatch {
+ const idx = patch.indexOf("diff --git ");
+ const stat = (idx === -1 ? patch : patch.slice(0, idx)).trim();
+ const body = idx === -1 ? "" : patch.slice(idx);
+
+ const files: DiffFile[] = [];
+ let file: DiffFile | null = null;
+ let oldN = 0;
+ let newN = 0;
+
+ for (const line of body.split("\n")) {
+ if (line.startsWith("diff --git ")) {
+ // `diff --git a/path b/path` — take the b/ side.
+ const m = line.match(/ b\/(.*)$/);
+ file = { path: m ? m[1] : line, adds: 0, dels: 0, binary: false, lines: [] };
+ files.push(file);
+ continue;
+ }
+ if (!file) continue;
+ if (line.startsWith("Binary files ")) {
+ file.binary = true;
+ continue;
+ }
+ const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+ if (hunk) {
+ oldN = parseInt(hunk[1], 10);
+ newN = parseInt(hunk[2], 10);
+ file.lines.push({ kind: "hunk", old: null, new: null, text: line });
+ continue;
+ }
+ if (
+ line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") ||
+ line.startsWith("new file") || line.startsWith("deleted file") ||
+ line.startsWith("old mode") || line.startsWith("new mode") ||
+ line.startsWith("similarity") || line.startsWith("rename ") ||
+ line.startsWith("\\ No newline")
+ ) {
+ continue;
+ }
+ if (line.startsWith("+")) {
+ file.adds++;
+ file.lines.push({ kind: "add", old: null, new: newN++, text: line.slice(1) });
+ } else if (line.startsWith("-")) {
+ file.dels++;
+ file.lines.push({ kind: "del", old: oldN++, new: null, text: line.slice(1) });
+ } else {
+ file.lines.push({ kind: "ctx", old: oldN++, new: newN++, text: line.slice(1) });
+ }
+ }
+ return { stat, files };
+}
diff --git a/web/lib/format.ts b/web/lib/format.ts
new file mode 100644
index 0000000..8df0a7d
--- /dev/null
+++ b/web/lib/format.ts
@@ -0,0 +1,31 @@
+export function timeAgo(iso: string): string {
+ const t = new Date(iso).getTime();
+ if (!t || t <= 0) return "";
+ const s = (Date.now() - t) / 1000;
+ if (s < 60) return "just now";
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ if (s < 30 * 86400) return `${Math.floor(s / 86400)}d ago`;
+ return new Date(iso).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export function byteSize(n: number): string {
+ if (n < 0) return "";
+ if (n < 1024) return `${n} B`;
+ if (n < 1 << 20) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / (1 << 20)).toFixed(1)} MB`;
+}
+
+export function fullDate(iso: string): string {
+ return new Date(iso).toLocaleString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
diff --git a/web/lib/params.ts b/web/lib/params.ts
new file mode 100644
index 0000000..8241399
--- /dev/null
+++ b/web/lib/params.ts
@@ -0,0 +1,13 @@
+// Next.js delivers dynamic segments percent-encoded in some cases; decode
+// defensively so refs like "feat%2Fauth" become "feat/auth".
+export function dec(s: string): string {
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+}
+
+export function decPath(segs: string[] | undefined): string {
+ return (segs ?? []).map(dec).join("/");
+}
diff --git a/web/lib/shiki.ts b/web/lib/shiki.ts
new file mode 100644
index 0000000..11a75e3
--- /dev/null
+++ b/web/lib/shiki.ts
@@ -0,0 +1,46 @@
+import { createHighlighter, type Highlighter } from "shiki";
+
+const LANGS = [
+ "typescript", "tsx", "javascript", "jsx", "json", "go", "rust", "python",
+ "c", "cpp", "css", "html", "yaml", "toml", "markdown", "bash", "sql",
+ "java", "swift", "kotlin", "ruby", "php", "docker", "make", "diff",
+];
+
+const EXT_TO_LANG: Record<string, string> = {
+ ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
+ js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
+ json: "json", go: "go", rs: "rust", py: "python",
+ c: "c", h: "c", cpp: "cpp", cc: "cpp", hpp: "cpp",
+ css: "css", html: "html", htm: "html",
+ yml: "yaml", yaml: "yaml", toml: "toml", md: "markdown",
+ sh: "bash", bash: "bash", zsh: "bash", sql: "sql",
+ java: "java", swift: "swift", kt: "kotlin", rb: "ruby", php: "php",
+ dockerfile: "docker", patch: "diff", diff: "diff",
+};
+
+let highlighter: Promise<Highlighter> | null = null;
+
+function getHighlighter(): Promise<Highlighter> {
+ highlighter ??= createHighlighter({ themes: ["vitesse-dark"], langs: LANGS });
+ return highlighter;
+}
+
+export function langForFile(name: string): string | null {
+ const base = name.toLowerCase().split("/").pop() ?? "";
+ if (base === "makefile") return "make";
+ if (base === "dockerfile") return "docker";
+ const ext = base.includes(".") ? base.split(".").pop()! : "";
+ return EXT_TO_LANG[ext] ?? null;
+}
+
+// highlight returns shiki HTML for known languages, null otherwise
+// (the caller renders a plain <pre> instead).
+export async function highlight(code: string, lang: string | null): Promise<string | null> {
+ if (!lang) return null;
+ try {
+ const hl = await getHighlighter();
+ return hl.codeToHtml(code, { lang, theme: "vitesse-dark" });
+ } catch {
+ return null;
+ }
+}
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..4c96536
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "web",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "16.3.1",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0",
+ "remark-gfm": "^4.0.1",
+ "shiki": "^4.4.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ },
+ "packageManager": "bun@1.3.14",
+ "ignoreScripts": [
+ "sharp",
+ "unrs-resolver"
+ ],
+ "trustedDependencies": [
+ "sharp",
+ "unrs-resolver"
+ ]
+}
diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/web/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..3a13f90
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}