diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..836f4ed
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+PORT=1234
+DEBUG=false
+TELEGRAM_BOT_TOKEN=abcd
+TELEGRAM_CHAT_ID=efgh
diff --git a/.eslintrc.json b/.eslintrc.json
index 1f5c47b..bd0edbc 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,25 +1,25 @@
{
- "parser": "@babel/eslint-parser",
- "parserOptions": {
- "ecmaVersion": 2021,
- "sourceType": "module",
- "babelOptions": {
- "presets": [
- "@babel/preset-env",
- "@babel/preset-react"
- ]
- }
- },
- "env": {
- "browser": true,
- "node": true
- },
- "plugins": [
- "react"
- ],
- "extends": [
- "eslint:recommended",
- "plugin:react/recommended"
- ],
- "rules": {}
-}
\ No newline at end of file
+ "parser": "@babel/eslint-parser",
+ "parserOptions": {
+ "ecmaVersion": 2021,
+ "sourceType": "module",
+ "babelOptions": {
+ "presets": ["@babel/preset-env", "@babel/preset-react"]
+ }
+ },
+ "env": {
+ "browser": true,
+ "node": true
+ },
+ "plugins": ["react", "@typescript-eslint"],
+ "extends": [
+ "eslint:recommended",
+ "plugin:react/recommended",
+ "plugin:@typescript-eslint/recommended"
+ ],
+ "rules": {
+ "@typescript-eslint/explicit-module-boundary-types": "error",
+ "@typescript-eslint/explicit-function-return-type": "error"
+ }
+}
+
diff --git a/.gitignore b/.gitignore
index 5c005f9..5a566f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
+.env
.env.local
.env.development.local
.env.test.local
@@ -42,5 +43,7 @@ package-lock.json
**/*.bun
server
+worker
db.sqlite
+*.db
diff --git a/README.md b/README.md
index 6d5c10a..563f39a 100644
--- a/README.md
+++ b/README.md
@@ -1,34 +1,40 @@
# hyperwave
-hyperwave combines the benefits of traditional server-rendered applications with the flexibility of modern client-side frameworks.
+hyperwave combines the strengths of traditional server-rendered applications with the flexibility of modern client-side frameworks. It is designed to deliver fast, responsive applications while providing a streamlined developer experience.
-- **Performance:** Server-side rendering ensures fast, responsive applications, tailored to produce the smallest possible bundles.
-- **Developer experience:** HTMX and Tailwind provide a minimalistic and declarative approach to building user interfaces
-- **Deployment:** bun applications can be easily deployed on any platform as portable binaries
+- Performance: Only 200 lines of JS with zero client side dependencies. Works well in 2G network conditions.
+- Developer Experience: tailwind-compatible syntax and html over the wire offer a minimalistic and declarative approach to UI development.
+- Deployment: Bun applications can be deployed easily on any platform as portable binaries.
-## Getting started
+```
+$ git clone https://github.com/tireymorris/hyperwave.git
+$ cd hyperwave
+$ bun install
+$ bun dev
+```
-Follow these steps to start developing with hyperwave:
+Navigate to http://localhost:1234 in your browser and start editing server.tsx to observe your changes live.
-1. Clone the repository:
+remove any of the articles code from the example if you want, it's not many lines. see the server, model files, routes, and db.ts.
- ```sh
- git clone https://github.com/tireymorris/hyperwave.git
- cd hyperwave
- ```
-2. Install dependencies:
+## hyperwave.js
- ```sh
- bun install
- ```
+dynamically load content on user events, without requiring a page reload.
-3. Start the development server:
+attaches automatically to any element with an href attribute (besides an anchor/link tag, which is treated as normal)
- ```sh
- bun dev
- ```
+### Usage:
-4. Visit `http://localhost:1234` in your browser.
+```
+
+```
-5. Start editing `server.tsx` to see your changes live.
+- `trigger`: Event that triggers loading (e.g., click, scroll).
+- `method`: HTTP request method (e.g., GET, POST).
+- `debounce`: Delay in milliseconds to optimize performance.
+- `offset, limit, data-total`: Manage pagination settings.
+
+```
+
+```
diff --git a/bun.lockb b/bun.lockb
index ab03cfd..7df1bdb 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..41e08a8
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,18 @@
+module.exports = {
+ apps: [
+ {
+ name: "hyperwave-server",
+ script: "./dist/server",
+ env: {
+ NODE_ENV: "production",
+ },
+ },
+ {
+ name: "hyperwave-worker",
+ script: "./dist/worker",
+ env: {
+ NODE_ENV: "production",
+ },
+ },
+ ],
+};
diff --git a/package.json b/package.json
index a9981ef..ff5f709 100644
--- a/package.json
+++ b/package.json
@@ -2,27 +2,38 @@
"name": "hyperwave",
"version": "0.2.1",
"scripts": {
- "build": "bun build:css && bun build --compile ./src/server.tsx",
+ "build": "bun build:css && bun build:server && bun build:worker",
"build:css": "unocss \"src/**/*.tsx\" -o public/styles/uno.css",
- "css": "unocss --watch \"src/**/*.tsx\" -o public/styles/uno.css",
+ "build:server": "bun build --compile ./src/server.tsx --outfile ./dist/server",
+ "build:worker": "bun build --compile ./src/worker.ts --outfile ./dist/worker",
+ "css:watch": "unocss --watch \"src/**/*.tsx\" -o public/styles/uno.css",
"db": "bun run src/db.ts",
- "dev": "bun install && concurrently --restart-tries=3 \"bun css\" \"nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'\"",
+ "dev": "bun install && DEBUG=true concurrently --restart-tries=3 \"bun css:watch\" \"bun server:watch\" \"bun worker\"",
"prettier": "bunx prettier --write src/ test/ --plugin prettier-plugin-tailwindcss",
+ "server:watch": "nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'",
+ "start": "bun run build && pm2 start; pm2 reload all --update-env",
+ "start:debug": "DEBUG=true bun run build && pm2 start; pm2 reload all --update-env",
+ "stop": "pm2 kill",
"server": "bun run --hot src/server.tsx",
- "test": "bun run test",
- "update-deps": "bunx npm-check-updates -u && bun install"
+ "test": "NODE_ENV=test bun run test",
+ "update-deps": "bunx npm-check-updates -u && bun install",
+ "worker": "bun run src/worker.ts"
},
"dependencies": {
"@unocss/preset-web-fonts": "^0.61.3",
+ "cheerio": "^1.0.0-rc.12",
"hono": "^4.4.12",
"nodemon": "^3.1.4",
"unocss": "^0.61.3",
+ "uuid": "^10.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
+ "@types/bun": "^1.1.6",
"@unocss/cli": "^0.61.3",
"bun-types": "^1.1.18",
"concurrently": "^8.2.2",
+ "pm2": "^5.4.2",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5"
},
diff --git a/public/scripts/hyperwave.js b/public/scripts/hyperwave.js
new file mode 100644
index 0000000..572bffc
--- /dev/null
+++ b/public/scripts/hyperwave.js
@@ -0,0 +1,238 @@
+const DEBUG = true;
+
+/**
+ * Logs messages to the console if DEBUG is true.
+ * @param {string} level - The level of logging (e.g., 'log', 'warn', 'error').
+ * @param {...any} messages - The messages or data to log.
+ */
+const log = (level, ...messages) =>
+ DEBUG && console[level](`hyperwave:`, ...messages);
+
+/**
+ * Creates a debounced function that delays the execution of the provided function.
+ * Useful to limit the rate at which a function is invoked.
+ * @param {Function} func - The function to debounce.
+ * @param {number} delay - The number of milliseconds to delay.
+ * @returns {Function} - The debounced function.
+ */
+const createDebouncedFunction = (func, delay) => {
+ let timeoutId;
+ return (...args) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => func.apply(this, args), delay);
+ };
+};
+
+/**
+ * Fetches content from the specified URL using the provided options.
+ * @param {string} url - The URL to fetch content from.
+ * @param {RequestInit} fetchOptions - The options for the fetch request.
+ * @returns {Promise} - The fetched content as a string.
+ */
+const fetchContent = async (url, fetchOptions) => {
+ try {
+ log("log", `Fetching content from ${url}`);
+ const response = await fetch(url, fetchOptions);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const content = await response.text();
+ log("log", `Content fetched from ${url}`, content.length);
+ return content;
+ } catch (error) {
+ log("error", `Error fetching from ${url}:`, error);
+ return null;
+ }
+};
+
+/**
+ * Updates the target element with the provided content.
+ * @param {HTMLElement} targetElement - The element to update.
+ * @param {string} content - The new content to append to the target element.
+ */
+const updateTargetElement = (targetElement, content) => {
+ const tempDiv = document.createElement("div");
+ tempDiv.innerHTML = content;
+ while (tempDiv.firstChild) {
+ targetElement.appendChild(tempDiv.firstChild);
+ }
+ log("log", `Content appended to target element`);
+ attachHyperwaveHandlers(targetElement);
+};
+
+/**
+ * Builds the URL for the next page to load, used for pagination.
+ * @param {HTMLElement} triggerElement - The element that triggers pagination.
+ * @param {number} offset - The current offset.
+ * @param {number} limit - The number of items per page.
+ * @returns {string} - The URL for the next page.
+ */
+const buildPaginationUrl = (triggerElement, offset, limit) => {
+ const url = new URL(
+ triggerElement.getAttribute("href"),
+ window.location.origin,
+ );
+ url.searchParams.set("offset", offset);
+ url.searchParams.set("limit", limit);
+ return url.toString();
+};
+
+/**
+ * Handles pagination for loading additional content.
+ * @param {HTMLElement} triggerElement - The element that triggers pagination.
+ * @param {RequestInit} fetchOptions - The options for the fetch request.
+ * @returns {Function} - The function to load the next page of content.
+ */
+const handlePagination = (triggerElement, fetchOptions) => {
+ let offset = parseInt(triggerElement.getAttribute("offset") || "0", 10);
+ const limit = parseInt(triggerElement.getAttribute("limit") || "10", 10);
+ const totalItems = parseInt(
+ triggerElement.getAttribute("data-total") || "999999999",
+ 10,
+ );
+
+ return async () => {
+ if (offset >= totalItems) return;
+
+ const url = buildPaginationUrl(triggerElement, offset, limit);
+ const content = await fetchContent(url, fetchOptions);
+ if (content) {
+ updateTargetElement(
+ document.querySelector(triggerElement.getAttribute("target")),
+ content,
+ );
+ offset += limit;
+ triggerElement.setAttribute("offset", offset);
+ }
+ };
+};
+
+/**
+ * Sets up event listeners and handlers based on element attributes.
+ * Handles different triggers like click and DOMContentLoaded.
+ * @param {HTMLElement} triggerElement - The element to handle the request for.
+ */
+const setupEventHandlers = (triggerElement) => {
+ const method = triggerElement.getAttribute("method") || "GET";
+ const trigger = triggerElement.getAttribute("trigger") || "click";
+ const debounceDelay = parseInt(
+ triggerElement.getAttribute("debounce") || "50",
+ 10,
+ );
+
+ if (!triggerElement.getAttribute("href")) {
+ log("warn", `Missing href for element:`, triggerElement);
+ return;
+ }
+
+ const fetchOptions = {
+ method: method.toUpperCase(),
+ headers: { Accept: "text/html" },
+ };
+
+ const loadNextPage = handlePagination(triggerElement, fetchOptions);
+
+ if (trigger.includes("DOMContentLoaded")) {
+ loadNextPage();
+ } else {
+ // Remove any existing event listener before adding a new one
+ if (triggerElement._hyperwaveHandler) {
+ triggerElement.removeEventListener(
+ trigger,
+ triggerElement._hyperwaveHandler,
+ );
+ }
+
+ const eventHandler = createDebouncedFunction((event) => {
+ event.preventDefault();
+ loadNextPage();
+ }, debounceDelay);
+
+ triggerElement.addEventListener(trigger, eventHandler);
+ triggerElement._hyperwaveHandler = eventHandler;
+ }
+};
+
+/**
+ * Handles infinite scrolling to load more content as the user scrolls near the bottom of the page.
+ * @param {HTMLElement} triggerElement - The element that triggers infinite scroll.
+ * @param {Function} loadNextPage - The function to load the next page of content.
+ * @param {number} debounceDelay - The debounce time in milliseconds for the scroll event.
+ */
+const setupInfiniteScroll = (triggerElement, loadNextPage, debounceDelay) => {
+ let isLoading = false;
+ const threshold = 200; // Pixels from the bottom to trigger loading
+
+ const onScroll = createDebouncedFunction(async () => {
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
+ const clientHeight = window.innerHeight;
+ const scrollHeight = document.documentElement.scrollHeight;
+
+ if (scrollTop + clientHeight >= scrollHeight - threshold && !isLoading) {
+ isLoading = true;
+ await loadNextPage();
+ isLoading = false;
+ }
+ }, debounceDelay);
+
+ window.addEventListener("scroll", onScroll);
+ loadNextPage(); // Load initial content if needed
+
+ // Store the event handler to remove it later if needed
+ triggerElement._hyperwaveScrollHandler = onScroll;
+};
+
+/**
+ * Attaches Hyperwave functionality to elements within the specified root element.
+ * It scans for elements with `href` attribute and sets up the necessary handlers.
+ * @param {HTMLElement} rootElement - The root element to search for elements to attach Hyperwave to.
+ */
+const attachHyperwaveHandlers = (rootElement) => {
+ const elements = Array.from(rootElement.querySelectorAll("[href]")).filter(
+ (element) => !["A", "LINK"].includes(element.tagName),
+ );
+ elements.forEach((element) => {
+ setupEventHandlers(element);
+
+ const trigger = element.getAttribute("trigger") || "click";
+ if (trigger.includes("scroll")) {
+ const debounceDelay = parseInt(
+ element.getAttribute("debounce") || "50",
+ 10,
+ );
+ const loadNextPage = handlePagination(element, {
+ method: element.getAttribute("method") || "GET",
+ headers: { Accept: "text/html" },
+ });
+
+ // Remove any existing scroll event listener before adding a new one
+ if (element._hyperwaveScrollHandler) {
+ window.removeEventListener("scroll", element._hyperwaveScrollHandler);
+ }
+ setupInfiniteScroll(element, loadNextPage, debounceDelay);
+ }
+ });
+};
+
+/**
+ * Initializes Hyperwave on DOMContentLoaded and sets up a MutationObserver to attach Hyperwave to newly added elements.
+ * Ensures dynamic elements loaded after initial page load are also enhanced.
+ */
+document.addEventListener("DOMContentLoaded", () => {
+ attachHyperwaveHandlers(document);
+
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ attachHyperwaveHandlers(node);
+ }
+ });
+ });
+ });
+
+ observer.observe(document.body, {
+ childList: true, // Monitor for additions or removals of child elements
+ subtree: true, // Monitor the entire subtree, not just immediate children
+ });
+});
diff --git a/public/styles/uno.css b/public/styles/uno.css
index d7f315c..458d9ec 100644
--- a/public/styles/uno.css
+++ b/public/styles/uno.css
@@ -1,84 +1,72 @@
/* layer: preflights */
*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgb(0 0 0 / 0);--un-ring-shadow:0 0 rgb(0 0 0 / 0);--un-shadow-inset: ;--un-shadow:0 0 rgb(0 0 0 / 0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgb(147 197 253 / 0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
-/* latin-ext */
-@font-face {
- font-family: 'Lato';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src: url(https://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2');
- unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
-}
-/* latin */
-@font-face {
- font-family: 'Lato';
- font-style: normal;
- font-weight: 400;
- font-display: swap;
- src: url(https://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHjx4wXg.woff2) format('woff2');
- unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
-}
-
/* layer: default */
-.m-0{margin:0;}
-.m-auto{margin:auto;}
+.m-0,
+[m-0=""]{margin:0;}
+.mb-1{margin-bottom:0.25rem;}
+.mr-4{margin-right:1rem;}
+.mt-1{margin-top:0.25rem;}
+.h-12{height:3rem;}
.h-8{height:2rem;}
-.h-full{height:100%;}
-[h1=""]{height:0.25rem;}
+.min-h-screen{min-height:100vh;}
+.w-12{width:3rem;}
+.w-full{width:100%;}
.flex{display:flex;}
.flex-col{flex-direction:column;}
.cursor-pointer{cursor:pointer;}
+.list-none,
+[list-none=""]{list-style-type:none;}
.items-center{align-items:center;}
.justify-center{justify-content:center;}
+.gap-2{gap:0.5rem;}
.gap-3{gap:0.75rem;}
-.gap-8{gap:2rem;}
.border{border-width:1px;}
+.border-b,
+[border-b=""]{border-bottom-width:1px;}
.border-gray-2{--un-border-opacity:1;border-color:rgb(229 231 235 / var(--un-border-opacity));}
.focus\:border-blue-200:focus{--un-border-opacity:1;border-color:rgb(191 219 254 / var(--un-border-opacity));}
-.rounded{border-radius:0.25rem;}
.rounded-md{border-radius:0.375rem;}
+.rounded-sm{border-radius:0.125rem;}
.border-none{border-style:none;}
.border-solid{border-style:solid;}
-.bg-blue-200{--un-bg-opacity:1;background-color:rgb(191 219 254 / var(--un-bg-opacity)) /* #bfdbfe */;}
.bg-blue-300{--un-bg-opacity:1;background-color:rgb(147 197 253 / var(--un-bg-opacity)) /* #93c5fd */;}
-.bg-gray-300{--un-bg-opacity:1;background-color:rgb(209 213 219 / var(--un-bg-opacity)) /* #d1d5db */;}
-.bg-indigo-200{--un-bg-opacity:1;background-color:rgb(199 210 254 / var(--un-bg-opacity)) /* #c7d2fe */;}
.bg-transparent{background-color:transparent /* transparent */;}
.bg-white,
[bg-white=""]{--un-bg-opacity:1;background-color:rgb(255 255 255 / var(--un-bg-opacity)) /* #fff */;}
-.dark .dark\:bg-blue-600{--un-bg-opacity:1;background-color:rgb(37 99 235 / var(--un-bg-opacity)) /* #2563eb */;}
-.dark .dark\:bg-gray-700{--un-bg-opacity:1;background-color:rgb(55 65 81 / var(--un-bg-opacity)) /* #374151 */;}
.dark .dark\:bg-gray-900,
.dark [dark\:bg-gray-900=""]{--un-bg-opacity:1;background-color:rgb(17 24 39 / var(--un-bg-opacity)) /* #111827 */;}
-.dark .dark\:bg-indigo-600{--un-bg-opacity:1;background-color:rgb(79 70 229 / var(--un-bg-opacity)) /* #4f46e5 */;}
.hover\:bg-blue-400:hover{--un-bg-opacity:1;background-color:rgb(96 165 250 / var(--un-bg-opacity)) /* #60a5fa */;}
-.p-0{padding:0;}
-.px-10{padding-left:2.5rem;padding-right:2.5rem;}
+.from-blue-500{--un-gradient-from-position:0%;--un-gradient-from:rgb(59 130 246 / var(--un-from-opacity, 1)) var(--un-gradient-from-position);--un-gradient-to-position:100%;--un-gradient-to:rgb(59 130 246 / 0) var(--un-gradient-to-position);--un-gradient-stops:var(--un-gradient-from), var(--un-gradient-to);}
+.to-purple-500{--un-gradient-to-position:100%;--un-gradient-to:rgb(168 85 247 / var(--un-to-opacity, 1)) var(--un-gradient-to-position);}
+.bg-gradient-to-r{--un-gradient-shape:to right;--un-gradient:var(--un-gradient-shape), var(--un-gradient-stops);background-image:linear-gradient(var(--un-gradient));}
+.p-0,
+[p-0=""]{padding:0;}
+.p-4{padding:1rem;}
.px-2{padding-left:0.5rem;padding-right:0.5rem;}
.px-4{padding-left:1rem;padding-right:1rem;}
-.px-6{padding-left:1.5rem;padding-right:1.5rem;}
.py-1{padding-top:0.25rem;padding-bottom:0.25rem;}
.py-2{padding-top:0.5rem;padding-bottom:0.5rem;}
-.py-3{padding-top:0.75rem;padding-bottom:0.75rem;}
-.py-8{padding-top:2rem;padding-bottom:2rem;}
.pl-3{padding-left:0.75rem;}
.pr-10{padding-right:2.5rem;}
-.text-center{text-align:center;}
-.text-5xl{font-size:3rem;line-height:1;}
.text-base{font-size:1rem;line-height:1.5rem;}
.text-sm{font-size:0.875rem;line-height:1.25rem;}
+.text-xl{font-size:1.25rem;line-height:1.75rem;}
.dark .dark\:text-white,
-.dark [dark\:text-white=""]{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;}
+.dark [dark\:text-white=""],
+.text-white{--un-text-opacity:1;color:rgb(255 255 255 / var(--un-text-opacity)) /* #fff */;}
.text-black{--un-text-opacity:1;color:rgb(0 0 0 / var(--un-text-opacity)) /* #000 */;}
+.text-gray-500,
+[text-gray-500=""]{--un-text-opacity:1;color:rgb(107 114 128 / var(--un-text-opacity)) /* #6b7280 */;}
.text-neutral-500{--un-text-opacity:1;color:rgb(115 115 115 / var(--un-text-opacity)) /* #737373 */;}
.text-slate-900{--un-text-opacity:1;color:rgb(15 23 42 / var(--un-text-opacity)) /* #0f172a */;}
-.dark .dark\:hover\:text-yellow-300:hover{--un-text-opacity:1;color:rgb(253 224 71 / var(--un-text-opacity)) /* #fde047 */;}
-.hover\:text-yellow-600:hover{--un-text-opacity:1;color:rgb(202 138 4 / var(--un-text-opacity)) /* #ca8a04 */;}
+.text-teal-500,
+[text-teal-500=""]{--un-text-opacity:1;color:rgb(20 184 166 / var(--un-text-opacity)) /* #14b8a6 */;}
+.visited\:text-purple-600:visited{--un-text-opacity:1;color:rgb(147 51 234 / var(--un-text-opacity)) /* #9333ea */;}
.font-bold{font-weight:700;}
-.font-extrabold{font-weight:800;}
-.font-lato{font-family:"Lato";}
-.underline{text-decoration-line:underline;}
+.font-serif{font-family:ui-serif,Georgia,Cambria,"Times New Roman",Times,serif;}
+.italic{font-style:italic;}
+.hover\:underline:hover{text-decoration-line:underline;}
+[hover\:underline=""]:hover{text-decoration-line:underline;}
.shadow-lg{--un-shadow:var(--un-shadow-inset) 0 10px 15px -3px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 4px 6px -4px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
.shadow-md{--un-shadow:var(--un-shadow-inset) 0 4px 6px -1px var(--un-shadow-color, rgb(0 0 0 / 0.1)),var(--un-shadow-inset) 0 2px 4px -2px var(--un-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
-.outline{outline-style:solid;}
-.drop-shadow-lg{--un-drop-shadow:drop-shadow(0 10px 8px var(--un-drop-shadow-color, rgb(0 0 0 / 0.04))) drop-shadow(0 4px 3px var(--un-drop-shadow-color, rgb(0 0 0 / 0.1)));filter:var(--un-blur) var(--un-brightness) var(--un-contrast) var(--un-drop-shadow) var(--un-grayscale) var(--un-hue-rotate) var(--un-invert) var(--un-saturate) var(--un-sepia);}
\ No newline at end of file
+.outline{outline-style:solid;}
\ No newline at end of file
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 0000000..524d2c8
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,33 @@
+import { formatRelativeTime, getLastUpdatedTimestamp } from "util/time.ts";
+
+export default async function Header() {
+ const lastUpdatedDate = getLastUpdatedTimestamp();
+ const lastUpdated = lastUpdatedDate
+ ? formatRelativeTime(lastUpdatedDate)
+ : null;
+
+ return (
+
+
+