diff --git a/app/api/ucb-libraries/route.ts b/app/api/ucb-libraries/route.ts
new file mode 100644
index 0000000..12cb970
--- /dev/null
+++ b/app/api/ucb-libraries/route.ts
@@ -0,0 +1,7 @@
+import { NextResponse } from "next/server";
+import { getUCBLibraryHours } from "@/lib/ucb-library-hours";
+
+export async function GET() {
+ const data = await getUCBLibraryHours();
+ return NextResponse.json(data);
+}
diff --git a/app/berkeley-libraries/page.tsx b/app/berkeley-libraries/page.tsx
new file mode 100644
index 0000000..f6385bc
--- /dev/null
+++ b/app/berkeley-libraries/page.tsx
@@ -0,0 +1,178 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getUCBLibraryHours } from "@/lib/ucb-library-hours";
+import type { UCBLibrary } from "@/lib/ucb-library-hours";
+
+export const metadata: Metadata = {
+ title: "UC Berkeley Library Hours",
+ description:
+ "Live open/closed status for UC Berkeley libraries, from lib.berkeley.edu (refreshed at most every 15 minutes).",
+ openGraph: {
+ title: "UC Berkeley Library Hours Β· Kai Chen",
+ description: "Library availability scraped from the official Berkeley hours page.",
+ url: "https://kaichen.dev/berkeley-libraries",
+ },
+};
+
+function groupByStatus(libraries: UCBLibrary[]) {
+ const open = libraries.filter((l) => l.status === "Open");
+ const closed = libraries.filter((l) => l.status === "Closed");
+ const unknown = libraries.filter((l) => l.status === "Unknown");
+ return { open, closed, unknown };
+}
+
+function LibraryList({
+ items,
+ emptyLabel,
+ tone,
+}: {
+ items: UCBLibrary[];
+ emptyLabel: string;
+ tone: "open" | "closed" | "unknown";
+}) {
+ const dot =
+ tone === "open"
+ ? "π’"
+ : tone === "closed"
+ ? "π΄"
+ : "βͺ";
+
+ if (items.length === 0) {
+ return (
+
+ {emptyLabel}
+
+ );
+ }
+
+ return (
+
+ {items.map((lib) => (
+
+
+ {dot}
+
+
+
+ ))}
+
+ );
+}
+
+export default async function BerkeleyLibrariesPage() {
+ const result = await getUCBLibraryHours();
+
+ return (
+
+
+
+
+ β
+ Home
+
+
+
+ UC Berkeley Library Hours
+
+
+ Parsed from the official{" "}
+
+ library hours
+ {" "}
+ page. Data is cached for up to 15 minutes to avoid hammering their servers. Overnight and
+ special hours may only appear on each library's detail page β check the official site
+ when in doubt.
+
+
+
+ {!result.ok ? (
+
+
Could not load
+
+ {result.error}
+
+
+ Last attempt: {result.fetchedAt} (Pacific)
+
+
+ ) : (
+ <>
+
+ Last updated (Pacific): {result.fetchedAt}
+
+
+ {(() => {
+ const { open, closed, unknown } = groupByStatus(result.libraries);
+ return (
+
+
+
Now open ({open.length})
+
+
+
+
+
Now closed ({closed.length})
+
+
+
+
+
Status unknown ({unknown.length})
+
+
+
+ );
+ })()}
+ >
+ )}
+
+ );
+}
diff --git a/app/components/nav-wave-overlay.tsx b/app/components/nav-wave-overlay.tsx
deleted file mode 100644
index 3c3f329..0000000
--- a/app/components/nav-wave-overlay.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-import { useEffect, useRef, useState, useSyncExternalStore } from "react";
-import { createPortal } from "react-dom";
-
-const WAVE_SVG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='4'%3E%3Cpath d='M0 3 Q5 0 10 3 Q15 6 20 3' stroke='%23C4894F' stroke-width='1.5' fill='none'/%3E%3C/svg%3E")`;
-
-const emptySubscribe = () => () => {};
-
-export default function NavWaveOverlay() {
- const [rect, setRect] = useState<{ left: number; width: number; top: number } | null>(null);
- const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
- const frameRef = useRef(0);
-
- useEffect(() => {
- if (!mounted) return;
-
- const handleEnter = (e: MouseEvent) => {
- const el = e.currentTarget as HTMLElement;
- const r = el.getBoundingClientRect();
- cancelAnimationFrame(frameRef.current);
- setRect({ left: r.left, width: r.width, top: r.bottom + 2 });
- };
- const handleLeave = () => {
- frameRef.current = requestAnimationFrame(() => setRect(null));
- };
-
- const links = document.querySelectorAll(".nav-wave");
- links.forEach((el) => {
- el.addEventListener("mouseenter", handleEnter);
- el.addEventListener("mouseleave", handleLeave);
- });
- return () => {
- links.forEach((el) => {
- el.removeEventListener("mouseenter", handleEnter);
- el.removeEventListener("mouseleave", handleLeave);
- });
- };
- }, [mounted]);
-
- if (!mounted) return null;
-
- return createPortal(
-
,
- document.body
- );
-}
diff --git a/app/components/nav.tsx b/app/components/nav.tsx
index e961d43..207731c 100644
--- a/app/components/nav.tsx
+++ b/app/components/nav.tsx
@@ -2,20 +2,41 @@
import Link from "next/link";
import ThemeToggle from "./theme-toggle";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { createPortal } from "react-dom";
+
+const WAVE_SVG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='4'%3E%3Cpath d='M0 3 Q5 0 10 3 Q15 6 20 3' stroke='%23C4894F' stroke-width='1.5' fill='none'/%3E%3C/svg%3E")`;
const NAV_LINKS = [
{ href: "/about", label: "About", external: false },
{ href: "/projects", label: "Projects", external: false },
{ href: "/notes", label: "Notes", external: false },
{ href: "https://news.kaichen.dev", label: "News", external: true },
- { href: "https://substack.com/@kaiiiichen", label: "Blog", external: true },
+ { href: "https://kaiiiichen.substack.com/", label: "Blog", external: true },
{ href: "/gallery", label: "Gallery", external: false },
];
export default function Nav() {
const [isOpen, setIsOpen] = useState(false);
const navRef = useRef(null);
+ const [waveRect, setWaveRect] = useState<{ left: number; width: number; top: number } | null>(null);
+ const waveLeaveFrame = useRef(0);
+
+ const onWaveEnter = useCallback((e: React.MouseEvent) => {
+ const el = e.currentTarget;
+ const r = el.getBoundingClientRect();
+ cancelAnimationFrame(waveLeaveFrame.current);
+ setWaveRect({ left: r.left, width: r.width, top: r.bottom + 2 });
+ }, []);
+
+ const onWaveLeave = useCallback(() => {
+ waveLeaveFrame.current = requestAnimationFrame(() => setWaveRect(null));
+ }, []);
+
+ const waveProps = {
+ onMouseEnter: onWaveEnter,
+ onMouseLeave: onWaveLeave,
+ };
useEffect(() => {
if (!isOpen) return;
@@ -29,6 +50,7 @@ export default function Nav() {
}, [isOpen]);
return (
+ <>
{label}
@@ -59,7 +82,8 @@ export default function Nav() {
key={label}
href={href}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
- className="nav-wave text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
+ className="text-sm text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors duration-150"
+ {...waveProps}
>
{label}
@@ -133,5 +157,29 @@ export default function Nav() {
+ {typeof document !== "undefined"
+ ? createPortal(
+
,
+ document.body
+ )
+ : null}
+ >
);
}
diff --git a/app/globals.css b/app/globals.css
index 0795a91..60e7e91 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -178,7 +178,7 @@ body {
}
-/* ββ Nav wave underline (rendered via portal, see nav-wave-overlay.tsx) ββ */
+/* ββ Nav wave underline (portal in nav.tsx) ββ */
@keyframes nav-wave-flow {
from { background-position-x: 0px; }
to { background-position-x: 20px; }
diff --git a/app/layout.tsx b/app/layout.tsx
index e55da27..7e8e0a9 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -11,7 +11,6 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "./globals.css";
import Nav from "./components/nav";
-import NavWaveOverlay from "./components/nav-wave-overlay";
import SubpageEnter from "./components/subpage-enter";
import Providers from "./components/providers";
import { Analytics } from "@vercel/analytics/react";
@@ -56,7 +55,6 @@ export default function RootLayout({
{`(function(){try{var d=document.documentElement;var t=localStorage.getItem('theme');var dark;if(t==='dark')dark=true;else if(t==='light')dark=false;else dark=false;d.classList.toggle('dark',dark);d.style.colorScheme=dark?'dark':'light';}catch(e){}})();`}
-
{children}
diff --git a/app/page.tsx b/app/page.tsx
index 73af7a9..4f50848 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -85,7 +85,6 @@ export default async function Home() {
Pledged to give 10% of my lifetime income β because some decisions are worth making early.
- {/* Divider */}
{/* Personality / side */}
@@ -100,7 +99,7 @@ export default async function Home() {
Want a daily digest from my Ginger Cat bot?
- Itβs on{" "}
+ It's on{" "}
.
+
Want to check the availability of all libraries at Berkeley?
+
+ See{" "}
+
+ here
+
+ .
+
{/* Social links β icon row, pinned to bottom */}
diff --git a/lib/ucb-library-hours.ts b/lib/ucb-library-hours.ts
new file mode 100644
index 0000000..9e1b4a3
--- /dev/null
+++ b/lib/ucb-library-hours.ts
@@ -0,0 +1,135 @@
+import { load } from "cheerio";
+
+export const UCB_HOURS_URL = "https://www.lib.berkeley.edu/hours";
+
+export type LibraryStatus = "Open" | "Closed" | "Unknown";
+
+export type UCBLibrary = {
+ name: string;
+ status: LibraryStatus;
+ hours: string | null;
+ url: string;
+};
+
+export type UCBLibraryHoursResult =
+ | {
+ ok: true;
+ libraries: UCBLibrary[];
+ fetchedAt: string;
+ sourceUrl: string;
+ }
+ | {
+ ok: false;
+ error: string;
+ fetchedAt: string;
+ sourceUrl: string;
+ };
+
+const HOURS_PATTERN =
+ /(\d{1,2}\s+(?:a\.m\.|p\.m\.)\s*-\s*\d{1,2}\s+(?:a\.m\.|p\.m\.))/i;
+const FLEX_HOURS =
+ /(\d{1,2}\s*(?:a\.m\.|p\.m\.)\s*[-ββ]\s*\d{1,2}\s*(?:a\.m\.|p\.m\.))/i;
+
+function parseItemText(itemText: string): Pick {
+ let status: LibraryStatus = "Unknown";
+ let hours: string | null = null;
+
+ if (/\bClosed\b/i.test(itemText)) {
+ status = "Closed";
+ } else if (HOURS_PATTERN.test(itemText)) {
+ const m = itemText.match(HOURS_PATTERN);
+ hours = m ? m[1] : null;
+ status = "Open";
+ } else if (/\d+\s*(?:a\.m\.|p\.m\.)/i.test(itemText)) {
+ status = "Open";
+ const fm = itemText.match(FLEX_HOURS);
+ hours = fm ? fm[1] : null;
+ }
+
+ return { status, hours };
+}
+
+export function parseLibrariesFromHtml(html: string): UCBLibrary[] {
+ const $ = load(html);
+ const libraries: UCBLibrary[] = [];
+ const seen = new Set();
+
+ $("li").each((_, li) => {
+ const $li = $(li);
+ const nameLink = $li.find('a[href*="/visit/"]').first();
+
+ if (!nameLink.length) return;
+
+ const name = nameLink.text().trim();
+ if (!name || name.includes("View") || name.includes("Map") || seen.has(name)) {
+ return;
+ }
+ seen.add(name);
+
+ const itemText = $li.text().replace(/\s+/g, " ").trim();
+ const { status, hours } = parseItemText(itemText);
+
+ let href = nameLink.attr("href") ?? "";
+ if (href && !href.startsWith("http")) {
+ href = `https://www.lib.berkeley.edu${href}`;
+ }
+
+ libraries.push({ name, status, hours, url: href });
+ });
+
+ return libraries;
+}
+
+function pacificTimestamp(): string {
+ return new Date().toLocaleString("en-US", {
+ timeZone: "America/Los_Angeles",
+ dateStyle: "medium",
+ timeStyle: "medium",
+ });
+}
+
+/** Fetches and parses UCB library hours. Cached 15 minutes via Next fetch cache. */
+export async function getUCBLibraryHours(): Promise {
+ const fetchedAt = pacificTimestamp();
+ try {
+ const res = await fetch(UCB_HOURS_URL, {
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (compatible; kaichen.dev/1.0; +https://kaichen.dev/berkeley-libraries)",
+ Accept: "text/html,application/xhtml+xml",
+ },
+ next: { revalidate: 900 },
+ });
+
+ if (!res.ok) {
+ return {
+ ok: false,
+ error: `HTTP ${res.status}`,
+ fetchedAt,
+ sourceUrl: UCB_HOURS_URL,
+ };
+ }
+
+ const html = await res.text();
+ const libraries = parseLibrariesFromHtml(html);
+
+ if (libraries.length === 0) {
+ return {
+ ok: false,
+ error: "Parsed 0 libraries (page structure may have changed).",
+ fetchedAt,
+ sourceUrl: UCB_HOURS_URL,
+ };
+ }
+
+ return { ok: true, libraries, fetchedAt, sourceUrl: UCB_HOURS_URL };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ return {
+ ok: false,
+ error: message,
+ fetchedAt,
+ sourceUrl: UCB_HOURS_URL,
+ };
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 28cc961..370385d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@supabase/supabase-js": "^2.100.1",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
+ "cheerio": "^1.2.0",
"geist": "^1.7.0",
"katex": "^0.16.45",
"next": "16.2.4",
@@ -5320,6 +5321,12 @@
"node": ">=6.0.0"
}
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -5568,6 +5575,48 @@
"node": ">= 16"
}
},
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/chrome-trace-event": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -5686,6 +5735,34 @@
"node": ">= 8"
}
},
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5881,6 +5958,73 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -5921,6 +6065,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -7524,6 +7681,37 @@
"node": ">=12.0.0"
}
},
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -7546,6 +7734,18 @@
"node": ">=20.0.0"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -10036,6 +10236,18 @@
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"license": "MIT"
},
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -10275,6 +10487,31 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -11033,6 +11270,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -12064,6 +12307,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12650,6 +12902,28 @@
"node": ">=4.0"
}
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
diff --git a/package.json b/package.json
index 466459b..05a80e1 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@supabase/supabase-js": "^2.100.1",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
+ "cheerio": "^1.2.0",
"geist": "^1.7.0",
"katex": "^0.16.45",
"next": "16.2.4",