mykiwi/mykiwi.blogpublic Fork
⑂ b3a4f36
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Keep opened pages readable offline with a service worker

/sw.js is a Hugo template, one per language build, naming that build's
fingerprinted files: those are served cache-first, since a name carrying
its own hash cannot go stale. Everything else stays network-first, with
the last copy seen as the fallback and a translated /offline page for
what was never opened. Not registered under `hugo server` or ?ogcard.
Romain Gautier committed 2026-10-11T20:43:37+02:00 Browse files
b3a4f36 parent: 94576dd
added blog/content/offline.es.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Sin conexión'
3+description = 'Se muestra cuando una página no está disponible sin conexión.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Esta página no está guardada en este dispositivo y no hay conexión para ir a buscarla. Las que ya has abierto se pueden seguir leyendo.
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Sin conexión'
3+description = 'Se muestra cuando una página no está disponible sin conexión.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Esta página no está guardada en este dispositivo y no hay conexión para ir a buscarla. Las que ya has abierto se pueden seguir leyendo.
added blog/content/offline.fr.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Hors ligne'
3+description = 'Affichée quand une page n’est pas disponible sans connexion.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Cette page n’est pas enregistrée sur cet appareil, et il n’y a pas de connexion pour aller la chercher. Celles que vous avez déjà ouvertes restent lisibles.
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Hors ligne'
3+description = 'Affichée quand une page n’est pas disponible sans connexion.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Cette page n’est pas enregistrée sur cet appareil, et il n’y a pas de connexion pour aller la chercher. Celles que vous avez déjà ouvertes restent lisibles.
added blog/content/offline.ja.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'オフライン'
3+description = '接続がなく、ページを表示できないときに表示されます。'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+このページはこの端末に保存されておらず、取得するための接続もありません。すでに開いたページは引き続き読めます。
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'オフライン'
3+description = '接続がなく、ページを表示できないときに表示されます。'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+このページはこの端末に保存されておらず、取得するための接続もありません。すでに開いたページは引き続き読めます。
added blog/content/offline.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Offline'
3+description = 'Shown when a page is not available without a connection.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+This page isn’t saved on this device, and there is no connection to fetch it over. The pages you have already opened are still readable.
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Offline'
3+description = 'Shown when a page is not available without a connection.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+This page isn’t saved on this device, and there is no connection to fetch it over. The pages you have already opened are still readable.
added blog/content/offline.pt.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Sem conexão'
3+description = 'Exibida quando uma página não está disponível sem conexão.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Esta página não está salva neste dispositivo e não há conexão para buscá-la. As que você já abriu continuam disponíveis para leitura.
new file mode 100644
@@ -0,0 +1,17 @@
1++++
2+title = 'Sem conexão'
3+description = 'Exibida quando uma página não está disponível sem conexão.'
4+layout = 'offline'
5+
6+# Never linked to: sw.js serves it in place of a page it has no copy of.
7+# list = 'never' keeps it out of the home page, the post list, the feed and
8+# the sitemap; render = 'always' still builds it.
9+[build]
10+list = 'never'
11+render = 'always'
12+
13+[params]
14+noindex = true
15++++
16+
17+Esta página não está salva neste dispositivo e não há conexão para buscá-la. As que você já abriu continuam disponíveis para leitura.
modified blog/hugo.toml +10 -1
@@ -76,11 +76,20 @@ baseName = "feed"
7676 isPlainText = true
7777 notAlternative = true
7878
79+# The service worker, /sw.js (layouts/_default/home.serviceworker.js). A
80+# template for the same reason the two above are: one per language build, each
81+# naming that build's fingerprinted files.
82+[outputFormats.serviceworker]
83+mediaType = "text/javascript"
84+baseName = "sw"
85+isPlainText = true
86+notAlternative = true
87+
7988 [outputs]
8089 # html and rss are the defaults for the home page and have to be repeated -
8190 # naming the key at all replaces the list rather than adding to it. Other page
8291 # kinds are untouched, so sections keep their own index.xml.
83-home = ["html", "rss", "feedstyle", "feedscript"]
92+home = ["html", "rss", "feedstyle", "feedscript", "serviceworker"]
8493
8594 [permalinks]
8695 [permalinks.page]
@@ -76,11 +76,20 @@ baseName = "feed"
76 isPlainText = true76 isPlainText = true
77 notAlternative = true77 notAlternative = true
78 78
79+# The service worker, /sw.js (layouts/_default/home.serviceworker.js). A
80+# template for the same reason the two above are: one per language build, each
81+# naming that build's fingerprinted files.
82+[outputFormats.serviceworker]
83+mediaType = "text/javascript"
84+baseName = "sw"
85+isPlainText = true
86+notAlternative = true
87+
79 [outputs]88 [outputs]
80 # html and rss are the defaults for the home page and have to be repeated -89 # html and rss are the defaults for the home page and have to be repeated -
81 # naming the key at all replaces the list rather than adding to it. Other page90 # naming the key at all replaces the list rather than adding to it. Other page
82 # kinds are untouched, so sections keep their own index.xml.91 # kinds are untouched, so sections keep their own index.xml.
83-home = ["html", "rss", "feedstyle", "feedscript"]92+home = ["html", "rss", "feedstyle", "feedscript", "serviceworker"]
84 93
85 [permalinks]94 [permalinks]
86 [permalinks.page]95 [permalinks.page]
modified blog/layouts/_default/baseof.html +20 -0
@@ -370,6 +370,26 @@
370370 window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title});
371371 });
372372 </script>
373+ {{- /*
374+ /sw.js (_default/home.serviceworker.js) keeps a copy of what the reader
375+ opens, so it can be opened again without a connection. Registered once
376+ the page has loaded, so installing it - which fetches the stylesheet,
377+ the scripts and every font - never competes with the page for the
378+ network on the one visit where nothing is cached yet.
379+
380+ Not under `hugo server`: a worker answering for the stylesheet is the
381+ last thing wanted while editing it, and `make prod-server` is there to
382+ try this against the real artifact. Not under ?ogcard either (the root
383+ was marked by the boot script above): that is the build photographing
384+ its own pages, and it should photograph them as they were just built.
385+ */}}
386+ {{- if not hugo.IsServer }}
387+ <script>
388+ if ("serviceWorker" in navigator && !("ogcard" in document.documentElement.dataset)) {
389+ addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
390+ }
391+ </script>
392+ {{- end }}
373393 {{ block "head" . }}{{ end }}
374394 </head>
375395 {{- /*
@@ -370,6 +370,26 @@
370 window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title});370 window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title});
371 });371 });
372 </script>372 </script>
373+ {{- /*
374+ /sw.js (_default/home.serviceworker.js) keeps a copy of what the reader
375+ opens, so it can be opened again without a connection. Registered once
376+ the page has loaded, so installing it - which fetches the stylesheet,
377+ the scripts and every font - never competes with the page for the
378+ network on the one visit where nothing is cached yet.
379+
380+ Not under `hugo server`: a worker answering for the stylesheet is the
381+ last thing wanted while editing it, and `make prod-server` is there to
382+ try this against the real artifact. Not under ?ogcard either (the root
383+ was marked by the boot script above): that is the build photographing
384+ its own pages, and it should photograph them as they were just built.
385+ */}}
386+ {{- if not hugo.IsServer }}
387+ <script>
388+ if ("serviceWorker" in navigator && !("ogcard" in document.documentElement.dataset)) {
389+ addEventListener("load", () => navigator.serviceWorker.register("/sw.js"));
390+ }
391+ </script>
392+ {{- end }}
373 {{ block "head" . }}{{ end }}393 {{ block "head" . }}{{ end }}
374 </head>394 </head>
375 {{- /*395 {{- /*
added blog/layouts/_default/home.serviceworker.js +176 -0
new file mode 100644
@@ -0,0 +1,176 @@
1+{{- /*
2+ The service worker, published as /sw.js: what lets a page the reader has
3+ already opened be opened again with no connection, and what saves a round
4+ trip for every file whose name already says it cannot have changed.
5+
6+ A Hugo template for the same reason feed.js and feed.xsl are (see hugo.toml):
7+ each language is its own origin and so needs its own worker, and that worker
8+ has to name this build's fingerprinted files. It also has to sit at the root -
9+ a worker controls nothing above the directory it is served from - under a name
10+ that never changes, which rules out fingerprinting the worker itself.
11+
12+ $assets repeats the pipelines of _default/baseof.html, because a pipeline is
13+ what decides the URL: keep the two in step. Falling out of step breaks
14+ nothing while there is a connection - any fingerprinted file is kept the
15+ first time it is asked for - but a file missing from this list is not there
16+ for a reader who goes offline after a single visit.
17+*/ -}}
18+{{- $assets := slice }}
19+{{- with resources.Get "css/style.scss" }}
20+ {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }}
21+ {{- with . | toCSS $opts | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
22+{{- end }}
23+{{- range slice "js/htmx.min.js" "js/hx-preload.min.js" }}
24+ {{- with resources.Get . | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
25+{{- end }}
26+{{- with resources.Get "js/quote-share.js" | minify | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
27+{{- /* What a first visit has to leave behind beyond those: the home page, the
28+ page shown in place of one that was never opened, and the fonts - every one
29+ of them, since which of the five a page needs depends on the characters in
30+ it, and a page read offline is not the one that was on screen at install. */}}
31+{{- $offline := "" }}
32+{{- with site.GetPage "/offline" }}{{ $offline = .RelPermalink }}{{ end }}
33+{{- $seed := slice ("/" | relLangURL) }}
34+{{- with $offline }}{{ $seed = $seed | append . }}{{ end }}
35+{{- range os.ReadDir "static/fonts" }}
36+ {{- $seed = $seed | append (printf "/fonts/%s" .Name | relURL) }}
37+{{- end }}
38+// Two caches, told apart by whether a copy can go stale. IMMUTABLE holds the
39+// fingerprinted files: a name that carries the hash of its own content means
40+// the same bytes for good, so a copy is as good as the network and is never
41+// checked against it. SEEN holds the last copy of everything else this origin
42+// served - pages, fonts, the feed, images - which is only ever a fallback.
43+//
44+// Neither name carries a version. Nothing in them needs throwing away when a
45+// new build arrives: a page is replaced the next time it is fetched, and a
46+// fingerprinted file is dropped by sweep() once nothing names it any more.
47+const IMMUTABLE = "immutable", SEEN = "seen";
48+const ASSETS = {{ $assets | jsonify }};
49+const SEED = {{ $seed | jsonify }};
50+const OFFLINE = {{ $offline | jsonify }};
51+// Hugo's fingerprint: the sha256 of the file, in hex, ahead of the extension.
52+const FINGERPRINTED = /\.[0-9a-f]{64}\.\w+$/;
53+
54+// Everything is keyed by path alone. The site is static, so a query string
55+// (?ogcard, a tracking parameter someone's link picked up) changes nothing
56+// about what comes back, and keying on it would keep one copy per spelling.
57+const pathOf = (request) => new URL(request.url).pathname;
58+
59+// A copy is kept on the side while the response itself goes straight back to
60+// the page, rather than awaited: put() only settles once the whole body is
61+// stored, and waiting on it would hold the first byte back until the last.
62+//
63+// Only a plain 200. A redirected response cannot answer a navigation later
64+// (the browser refuses one it did not follow itself), and Pages answers
65+// /posts with a redirect to /posts/, so keeping what fetch() followed would
66+// store exactly that.
67+const keep = (event, name, response) => {
68+ if (response.status === 200 && !response.redirected) {
69+ const copy = response.clone();
70+ event.waitUntil(
71+ caches.open(name).then((cache) => cache.put(pathOf(event.request), copy)),
72+ );
73+ }
74+ return response;
75+};
76+
77+const cacheFirst = async (event) => {
78+ const cached = await (await caches.open(IMMUTABLE)).match(pathOf(event.request));
79+ return cached ?? keep(event, IMMUTABLE, await fetch(event.request));
80+};
81+
82+// The network's answer whenever there is one, so being online never means
83+// reading an old copy of a post. Without a connection: the copy kept from last
84+// time, and failing that - for a request that is going to put a page on
85+// screen - the offline page. hx-boost asks for a page with fetch() rather than
86+// by navigating, which is what the HX-Request header test is for; what it gets
87+// back is a whole document either way, so the two share their copies.
88+const networkFirst = async (event) => {
89+ const {request} = event;
90+ try {
91+ // preloadResponse is the request the browser already started while this
92+ // worker was booting (see "activate"); undefined for anything that is not
93+ // a navigation.
94+ return keep(event, SEEN, (await event.preloadResponse) ?? (await fetch(request)));
95+ } catch {
96+ const seen = await caches.open(SEEN);
97+ let cached = await seen.match(pathOf(request));
98+ if (!cached && OFFLINE && (request.mode === "navigate" || request.headers.has("HX-Request"))) {
99+ cached = await seen.match(OFFLINE);
100+ }
101+ // Rebuilt rather than handed back as stored: a response out of a cache
102+ // still carries the address it was fetched from, and htmx pushes that
103+ // address into history in preference to the one it asked for - so a
104+ // boosted link answered with the offline page left /offline/ in the
105+ // address bar, where reloading could never reach the page that was
106+ // wanted. A response with no address of its own leaves the request's.
107+ return cached ? new Response(cached.body, cached) : Response.error();
108+ }
109+};
110+
111+// A fingerprinted file outlives its build for as long as a kept page still
112+// names it. A reader who opened a post under an older build and has not been
113+// back to it holds that build's HTML, which links that build's stylesheet -
114+// delete the stylesheet because a newer one exists, and the post comes up
115+// unstyled the day it is opened without a connection. So the test is not
116+// "is it current" but "does anything still ask for it".
117+const sweep = async () => {
118+ const seen = await caches.open(SEEN), immutable = await caches.open(IMMUTABLE);
119+ let named = "";
120+ for (const key of await seen.keys()) {
121+ const response = await seen.match(key);
122+ // Only what can name a file: pages, the feed and its stylesheet, feed.js.
123+ if (/html|xml|xsl|javascript/.test(response?.headers.get("content-type") ?? "")) {
124+ named += await response.text();
125+ }
126+ }
127+ for (const key of await immutable.keys()) {
128+ const path = pathOf(key);
129+ if (!ASSETS.includes(path) && !named.includes(path)) await immutable.delete(key);
130+ }
131+};
132+
133+self.addEventListener("install", (event) => {
134+ event.waitUntil((async () => {
135+ await (await caches.open(IMMUTABLE)).addAll(ASSETS);
136+ await (await caches.open(SEEN)).addAll(SEED);
137+ // Nothing to wait for: the caches are shared and only ever added to, so
138+ // this worker and the one it replaces can serve the same open pages.
139+ await self.skipWaiting();
140+ })());
141+});
142+
143+self.addEventListener("activate", (event) => {
144+ event.waitUntil((async () => {
145+ // Network-first means a navigation always waits on the network, and
146+ // without this it would also wait on the worker starting up first.
147+ await self.registration.navigationPreload?.enable();
148+ // Take over the pages already open instead of from their next load.
149+ // hx-boost turns every later navigation into a fetch from the same
150+ // document, so there may be no next load for the rest of the visit.
151+ await self.clients.claim();
152+ // Those pages were themselves loaded before there was anything to keep
153+ // them - and the page someone arrived on is the likeliest one they will
154+ // want back. Best effort: failing here must not fail the activation.
155+ const seen = await caches.open(SEEN);
156+ const open = await self.clients.matchAll({type: "window"});
157+ await Promise.allSettled(open.map(async (client) => {
158+ const response = await fetch(client.url);
159+ if (response.status === 200 && !response.redirected) {
160+ await seen.put(new URL(client.url).pathname, response);
161+ }
162+ }));
163+ await sweep();
164+ })());
165+});
166+
167+self.addEventListener("fetch", (event) => {
168+ const {request} = event;
169+ // Left to the browser: anything that is not a read, other origins (the
170+ // visit counter), and range requests - a <video> asks for its file in
171+ // pieces, each a 206 that the Cache API will not store.
172+ if (request.method !== "GET" || request.headers.has("range")) return;
173+ const url = new URL(request.url);
174+ if (url.origin !== location.origin) return;
175+ event.respondWith(FINGERPRINTED.test(url.pathname) ? cacheFirst(event) : networkFirst(event));
176+});
new file mode 100644
@@ -0,0 +1,176 @@
1+{{- /*
2+ The service worker, published as /sw.js: what lets a page the reader has
3+ already opened be opened again with no connection, and what saves a round
4+ trip for every file whose name already says it cannot have changed.
5+
6+ A Hugo template for the same reason feed.js and feed.xsl are (see hugo.toml):
7+ each language is its own origin and so needs its own worker, and that worker
8+ has to name this build's fingerprinted files. It also has to sit at the root -
9+ a worker controls nothing above the directory it is served from - under a name
10+ that never changes, which rules out fingerprinting the worker itself.
11+
12+ $assets repeats the pipelines of _default/baseof.html, because a pipeline is
13+ what decides the URL: keep the two in step. Falling out of step breaks
14+ nothing while there is a connection - any fingerprinted file is kept the
15+ first time it is asked for - but a file missing from this list is not there
16+ for a reader who goes offline after a single visit.
17+*/ -}}
18+{{- $assets := slice }}
19+{{- with resources.Get "css/style.scss" }}
20+ {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }}
21+ {{- with . | toCSS $opts | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
22+{{- end }}
23+{{- range slice "js/htmx.min.js" "js/hx-preload.min.js" }}
24+ {{- with resources.Get . | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
25+{{- end }}
26+{{- with resources.Get "js/quote-share.js" | minify | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }}
27+{{- /* What a first visit has to leave behind beyond those: the home page, the
28+ page shown in place of one that was never opened, and the fonts - every one
29+ of them, since which of the five a page needs depends on the characters in
30+ it, and a page read offline is not the one that was on screen at install. */}}
31+{{- $offline := "" }}
32+{{- with site.GetPage "/offline" }}{{ $offline = .RelPermalink }}{{ end }}
33+{{- $seed := slice ("/" | relLangURL) }}
34+{{- with $offline }}{{ $seed = $seed | append . }}{{ end }}
35+{{- range os.ReadDir "static/fonts" }}
36+ {{- $seed = $seed | append (printf "/fonts/%s" .Name | relURL) }}
37+{{- end }}
38+// Two caches, told apart by whether a copy can go stale. IMMUTABLE holds the
39+// fingerprinted files: a name that carries the hash of its own content means
40+// the same bytes for good, so a copy is as good as the network and is never
41+// checked against it. SEEN holds the last copy of everything else this origin
42+// served - pages, fonts, the feed, images - which is only ever a fallback.
43+//
44+// Neither name carries a version. Nothing in them needs throwing away when a
45+// new build arrives: a page is replaced the next time it is fetched, and a
46+// fingerprinted file is dropped by sweep() once nothing names it any more.
47+const IMMUTABLE = "immutable", SEEN = "seen";
48+const ASSETS = {{ $assets | jsonify }};
49+const SEED = {{ $seed | jsonify }};
50+const OFFLINE = {{ $offline | jsonify }};
51+// Hugo's fingerprint: the sha256 of the file, in hex, ahead of the extension.
52+const FINGERPRINTED = /\.[0-9a-f]{64}\.\w+$/;
53+
54+// Everything is keyed by path alone. The site is static, so a query string
55+// (?ogcard, a tracking parameter someone's link picked up) changes nothing
56+// about what comes back, and keying on it would keep one copy per spelling.
57+const pathOf = (request) => new URL(request.url).pathname;
58+
59+// A copy is kept on the side while the response itself goes straight back to
60+// the page, rather than awaited: put() only settles once the whole body is
61+// stored, and waiting on it would hold the first byte back until the last.
62+//
63+// Only a plain 200. A redirected response cannot answer a navigation later
64+// (the browser refuses one it did not follow itself), and Pages answers
65+// /posts with a redirect to /posts/, so keeping what fetch() followed would
66+// store exactly that.
67+const keep = (event, name, response) => {
68+ if (response.status === 200 && !response.redirected) {
69+ const copy = response.clone();
70+ event.waitUntil(
71+ caches.open(name).then((cache) => cache.put(pathOf(event.request), copy)),
72+ );
73+ }
74+ return response;
75+};
76+
77+const cacheFirst = async (event) => {
78+ const cached = await (await caches.open(IMMUTABLE)).match(pathOf(event.request));
79+ return cached ?? keep(event, IMMUTABLE, await fetch(event.request));
80+};
81+
82+// The network's answer whenever there is one, so being online never means
83+// reading an old copy of a post. Without a connection: the copy kept from last
84+// time, and failing that - for a request that is going to put a page on
85+// screen - the offline page. hx-boost asks for a page with fetch() rather than
86+// by navigating, which is what the HX-Request header test is for; what it gets
87+// back is a whole document either way, so the two share their copies.
88+const networkFirst = async (event) => {
89+ const {request} = event;
90+ try {
91+ // preloadResponse is the request the browser already started while this
92+ // worker was booting (see "activate"); undefined for anything that is not
93+ // a navigation.
94+ return keep(event, SEEN, (await event.preloadResponse) ?? (await fetch(request)));
95+ } catch {
96+ const seen = await caches.open(SEEN);
97+ let cached = await seen.match(pathOf(request));
98+ if (!cached && OFFLINE && (request.mode === "navigate" || request.headers.has("HX-Request"))) {
99+ cached = await seen.match(OFFLINE);
100+ }
101+ // Rebuilt rather than handed back as stored: a response out of a cache
102+ // still carries the address it was fetched from, and htmx pushes that
103+ // address into history in preference to the one it asked for - so a
104+ // boosted link answered with the offline page left /offline/ in the
105+ // address bar, where reloading could never reach the page that was
106+ // wanted. A response with no address of its own leaves the request's.
107+ return cached ? new Response(cached.body, cached) : Response.error();
108+ }
109+};
110+
111+// A fingerprinted file outlives its build for as long as a kept page still
112+// names it. A reader who opened a post under an older build and has not been
113+// back to it holds that build's HTML, which links that build's stylesheet -
114+// delete the stylesheet because a newer one exists, and the post comes up
115+// unstyled the day it is opened without a connection. So the test is not
116+// "is it current" but "does anything still ask for it".
117+const sweep = async () => {
118+ const seen = await caches.open(SEEN), immutable = await caches.open(IMMUTABLE);
119+ let named = "";
120+ for (const key of await seen.keys()) {
121+ const response = await seen.match(key);
122+ // Only what can name a file: pages, the feed and its stylesheet, feed.js.
123+ if (/html|xml|xsl|javascript/.test(response?.headers.get("content-type") ?? "")) {
124+ named += await response.text();
125+ }
126+ }
127+ for (const key of await immutable.keys()) {
128+ const path = pathOf(key);
129+ if (!ASSETS.includes(path) && !named.includes(path)) await immutable.delete(key);
130+ }
131+};
132+
133+self.addEventListener("install", (event) => {
134+ event.waitUntil((async () => {
135+ await (await caches.open(IMMUTABLE)).addAll(ASSETS);
136+ await (await caches.open(SEEN)).addAll(SEED);
137+ // Nothing to wait for: the caches are shared and only ever added to, so
138+ // this worker and the one it replaces can serve the same open pages.
139+ await self.skipWaiting();
140+ })());
141+});
142+
143+self.addEventListener("activate", (event) => {
144+ event.waitUntil((async () => {
145+ // Network-first means a navigation always waits on the network, and
146+ // without this it would also wait on the worker starting up first.
147+ await self.registration.navigationPreload?.enable();
148+ // Take over the pages already open instead of from their next load.
149+ // hx-boost turns every later navigation into a fetch from the same
150+ // document, so there may be no next load for the rest of the visit.
151+ await self.clients.claim();
152+ // Those pages were themselves loaded before there was anything to keep
153+ // them - and the page someone arrived on is the likeliest one they will
154+ // want back. Best effort: failing here must not fail the activation.
155+ const seen = await caches.open(SEEN);
156+ const open = await self.clients.matchAll({type: "window"});
157+ await Promise.allSettled(open.map(async (client) => {
158+ const response = await fetch(client.url);
159+ if (response.status === 200 && !response.redirected) {
160+ await seen.put(new URL(client.url).pathname, response);
161+ }
162+ }));
163+ await sweep();
164+ })());
165+});
166+
167+self.addEventListener("fetch", (event) => {
168+ const {request} = event;
169+ // Left to the browser: anything that is not a read, other origins (the
170+ // visit counter), and range requests - a <video> asks for its file in
171+ // pieces, each a 206 that the Cache API will not store.
172+ if (request.method !== "GET" || request.headers.has("range")) return;
173+ const url = new URL(request.url);
174+ if (url.origin !== location.origin) return;
175+ event.respondWith(FINGERPRINTED.test(url.pathname) ? cacheFirst(event) : networkFirst(event));
176+});
added blog/layouts/_default/offline.html +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+{{- /*
2+ The page sw.js answers with when there is no connection and no kept copy of
3+ what was asked for (content/offline.md and its translations). It is served
4+ under the address of the page it stands in for, so nothing on it may depend
5+ on its own URL - and it is a post's header and body without the kicker: it
6+ has no date, and nothing worth timing or quoting.
7+*/ -}}
8+{{ define "main" }}
9+<article class="column">
10+ <header class="post-header rise">
11+ <h1 class="post-title">{{ .Title }}</h1>
12+ <hr class="post-rule">
13+ </header>
14+
15+ <div class="post-body prose">{{ .Content }}</div>
16+</article>
17+{{ end }}
new file mode 100644
@@ -0,0 +1,17 @@
1+{{- /*
2+ The page sw.js answers with when there is no connection and no kept copy of
3+ what was asked for (content/offline.md and its translations). It is served
4+ under the address of the page it stands in for, so nothing on it may depend
5+ on its own URL - and it is a post's header and body without the kicker: it
6+ has no date, and nothing worth timing or quoting.
7+*/ -}}
8+{{ define "main" }}
9+<article class="column">
10+ <header class="post-header rise">
11+ <h1 class="post-title">{{ .Title }}</h1>
12+ <hr class="post-rule">
13+ </header>
14+
15+ <div class="post-body prose">{{ .Content }}</div>
16+</article>
17+{{ end }}