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 ( + + ); +} + +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 ( + <>
+ {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){}})();`} -
- {/* 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",