- commit
- 0da5b0c12fecd85e9d51198db15fc8d9e94bddd6
- parent
- 7d27a9813c3ae3bad0c83d8c4de2b11ea0eec7c9
- author
- Dami <dami.etoile@gmail.com>
| M | assets/ | 18 | +++++- |
| A | islands/ | 23 | +++++++ |
| A | lib/ | 3 | + |
| A | lib/ | 14 | ++++ |
| M | routes/ | 9 | +++ |
| M | routes/ | 2 | + |
| A | static/ | 10 | +++ |
| A | static/ | 100 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 8 | ++ |
| A | tests/ | 23 | +++++++ |
@@ -99,7 +99,7 @@ select {
9999 .shikaku-home__masthead {100100 display: flex;101101 align-items: center;102- justify-content: flex-start;102+ justify-content: space-between;103103 gap: 1rem;104104 padding: 0.75rem 0.9rem;105105 }@@ -111,6 +111,22 @@ select {
111111 text-transform: uppercase;112112 }113113 114+.shikaku-home__offline-badge {115+ display: inline-flex;116+ align-items: center;117+ justify-content: center;118+ min-height: 2rem;119+ padding: 0.35rem 0.6rem;120+ border: 2px solid #0b1209;121+ background: #f2cb55;122+ color: #11160d;123+ font-size: 0.72rem;124+ font-weight: 900;125+ letter-spacing: 0.08em;126+ text-transform: uppercase;127+ box-shadow: 3px 3px 0 #0b1209;128+}129+114130 .shikaku-home__hero {115131 position: relative;116132 overflow: hidden;@@ -0,0 +1,23 @@
1+import { useEffect, useState } from "preact/hooks";2+3+import { getOfflineBadgeLabel } from "@/lib/site/network.ts";4+5+export default function OfflineBadge() {6+ const [isOnline, setIsOnline] = useState(true);7+8+ useEffect(() => {9+ const sync = () => setIsOnline(navigator.onLine);10+ sync();11+ addEventListener("online", sync);12+ addEventListener("offline", sync);13+ return () => {14+ removeEventListener("online", sync);15+ removeEventListener("offline", sync);16+ };17+ }, []);18+19+ const label = getOfflineBadgeLabel(isOnline);20+ if (!label) return null;21+22+ return <span class="shikaku-home__offline-badge">{label}</span>;23+}@@ -0,0 +1,14 @@
1+export const SERVICE_WORKER_PATH = "/sw.js";2+export const SERVICE_WORKER_REGISTER_PATH = "/register-sw.js";3+4+export const PWA_CORE_PATHS: readonly string[] = [5+ "/",6+ "/d",7+ "/site.webmanifest",8+ "/icon.svg",9+ "/icon-192.png",10+ "/icon-512.png",11+ "/apple-touch-icon.png",12+ "/register-sw.js",13+ "/sw.js",14+];@@ -1,4 +1,8 @@
11 import { isDailyPathname } from "@/lib/site/paths.ts";2+import {3+ SERVICE_WORKER_PATH,4+ SERVICE_WORKER_REGISTER_PATH,5+} from "@/lib/site/pwa.ts";26 import { define } from "../utils.ts";37 import {48 buildPageTitle,@@ -37,6 +41,11 @@ export default define.page(function App({ Component, url }) {
3741 <link rel="manifest" href="/site.webmanifest" />3842 <link rel="icon" href="/icon.svg" type="image/svg+xml" />3943 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />44+ <script45+ src={SERVICE_WORKER_REGISTER_PATH}46+ defer47+ data-sw={SERVICE_WORKER_PATH}48+ />4049 </head>4150 <body4251 style={{@@ -1,6 +1,7 @@
11 import { listAvailablePuzzleDates } from "@/lib/shikaku/catalog.ts";22 import { buildDailyPath } from "@/lib/site/paths.ts";33 import { define } from "../utils.ts";4+import OfflineBadge from "../islands/OfflineBadge.tsx";45 56 export default define.page(function Home() {67 const latest = listAvailablePuzzleDates().at(-1) ??@@ -11,6 +12,7 @@ export default define.page(function Home() {
1112 <div class="shikaku-home__frame">1213 <section class="shikaku-home__masthead">1314 <p class="shikaku-home__brand">FelineStateMachine</p>15+ <OfflineBadge />1416 </section>1517 1618 <section class="shikaku-home__hero">@@ -0,0 +1,10 @@
1+if ("serviceWorker" in navigator) {2+ const currentScript = document.currentScript;3+ const serviceWorkerPath = currentScript?.dataset.sw ?? "/sw.js";4+5+ addEventListener("load", () => {6+ navigator.serviceWorker.register(serviceWorkerPath).catch((error) => {7+ console.error("Failed to register service worker", error);8+ });9+ });10+}@@ -0,0 +1,100 @@
1+const CORE_CACHE = "rect-run-core-v2";2+const RUNTIME_CACHE = "rect-run-runtime-v2";3+const CORE_PATHS = [4+ "/",5+ "/d",6+ "/site.webmanifest",7+ "/icon.svg",8+ "/icon-192.png",9+ "/icon-512.png",10+ "/apple-touch-icon.png",11+ "/register-sw.js",12+ "/sw.js",13+];14+15+self.addEventListener("install", (event) => {16+ event.waitUntil(17+ caches.open(CORE_CACHE).then((cache) => cache.addAll(CORE_PATHS)).then(18+ () => {19+ self.skipWaiting();20+ },21+ ),22+ );23+});24+25+self.addEventListener("activate", (event) => {26+ event.waitUntil((async () => {27+ const names = await caches.keys();28+ await Promise.all(29+ names.filter((name) => name !== CORE_CACHE && name !== RUNTIME_CACHE).map(30+ (name) => caches.delete(name),31+ ),32+ );33+ await self.clients.claim();34+ })());35+});36+37+self.addEventListener("fetch", (event) => {38+ const { request } = event;39+ if (request.method !== "GET") return;40+41+ const url = new URL(request.url);42+ if (url.origin !== self.location.origin) return;43+44+ if (request.mode === "navigate") {45+ event.respondWith(handleNavigation(request));46+ return;47+ }48+49+ if (url.pathname.startsWith("/api/puzzles/")) {50+ event.respondWith(networkFirst(request));51+ return;52+ }53+54+ event.respondWith(staleWhileRevalidate(request));55+});56+57+async function handleNavigation(request) {58+ try {59+ const response = await fetch(request);60+ const cache = await caches.open(RUNTIME_CACHE);61+ cache.put(request, response.clone());62+ return response;63+ } catch {64+ const cached = await caches.match(request);65+ if (cached) return cached;66+ return caches.match("/");67+ }68+}69+70+async function networkFirst(request) {71+ const cache = await caches.open(RUNTIME_CACHE);72+ try {73+ const response = await fetch(request);74+ cache.put(request, response.clone());75+ return response;76+ } catch {77+ const cached = await cache.match(request);78+ if (cached) return cached;79+ throw errorResponse();80+ }81+}82+83+async function staleWhileRevalidate(request) {84+ const cache = await caches.open(RUNTIME_CACHE);85+ const cached = await cache.match(request);86+ const networkPromise = fetch(request).then((response) => {87+ cache.put(request, response.clone());88+ return response;89+ }).catch(() => null);90+91+ return cached ?? await networkPromise ?? errorResponse();92+}93+94+function errorResponse() {95+ return new Response("Offline", {96+ status: 503,97+ statusText: "Offline",98+ headers: { "content-type": "text/plain; charset=utf-8" },99+ });100+}@@ -0,0 +1,8 @@
1+import { assertEquals } from "@std/assert";2+3+import { getOfflineBadgeLabel } from "@/lib/site/network.ts";4+5+Deno.test("getOfflineBadgeLabel only shows a badge when offline", () => {6+ assertEquals(getOfflineBadgeLabel(true), null);7+ assertEquals(getOfflineBadgeLabel(false), "Offline");8+});@@ -0,0 +1,23 @@
1+import { assertEquals, assertStringIncludes } from "@std/assert";2+3+import {4+ PWA_CORE_PATHS,5+ SERVICE_WORKER_PATH,6+ SERVICE_WORKER_REGISTER_PATH,7+} from "@/lib/site/pwa.ts";8+9+Deno.test("pwa exports stable service worker paths", () => {10+ assertEquals(SERVICE_WORKER_PATH, "/sw.js");11+ assertEquals(SERVICE_WORKER_REGISTER_PATH, "/register-sw.js");12+});13+14+Deno.test("pwa core paths include the homepage, daily entry, and install assets", () => {15+ assertEquals(PWA_CORE_PATHS.includes("/"), true);16+ assertEquals(PWA_CORE_PATHS.includes("/d"), true);17+ assertEquals(PWA_CORE_PATHS.includes("/site.webmanifest"), true);18+ assertEquals(PWA_CORE_PATHS.includes("/icon-192.png"), true);19+ assertEquals(PWA_CORE_PATHS.includes("/apple-touch-icon.png"), true);20+ for (const path of PWA_CORE_PATHS) {21+ assertStringIncludes(path, "/");22+ }23+});