@rmdes/indiekit-frontend 1.0.0-beta.32 → 1.0.0-beta.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { IndiekitError } from "@indiekit/error";
2
2
 
3
+ import { openMediaBrowser } from "../../lib/media-browser.js";
3
4
  import { wrapElement } from "../../lib/utils/wrap-element.js";
4
5
 
5
6
  export const FileInputFieldController = class extends HTMLElement {
@@ -59,6 +60,46 @@ export const FileInputFieldController = class extends HTMLElement {
59
60
  const $fileInputFile =
60
61
  this.$fileInputPicker.querySelector(`.file-input__file`);
61
62
  $fileInputFile.addEventListener("change", (event) => this.fetch(event));
63
+
64
+ // Add "Browse media" button next to the upload button
65
+ if (this.endpoint) {
66
+ const $inputButtonGroup = this.querySelector(".input-button-group");
67
+ if ($inputButtonGroup) {
68
+ const $browseBtn = document.createElement("button");
69
+ $browseBtn.type = "button";
70
+ $browseBtn.className = "file-input__browse button button--secondary";
71
+ $browseBtn.textContent = "Browse media";
72
+ $browseBtn.addEventListener("click", () => this.browseMedia());
73
+ $inputButtonGroup.append($browseBtn);
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Open media browser to select an existing media file
80
+ */
81
+ browseMedia() {
82
+ if (this._mediaBrowserOpen) return;
83
+ this._mediaBrowserOpen = true;
84
+
85
+ // Determine filter type from the file input's accept attribute
86
+ const $fileInputFile = this.querySelector(".file-input__file");
87
+ const accept = $fileInputFile ? $fileInputFile.getAttribute("accept") : "";
88
+ let filterType = "all";
89
+ if (accept && accept.startsWith("image/")) filterType = "photo";
90
+ else if (accept && accept.startsWith("audio/")) filterType = "audio";
91
+ else if (accept && accept.startsWith("video/")) filterType = "video";
92
+
93
+ openMediaBrowser({
94
+ endpoint: this.endpoint,
95
+ filterType,
96
+ onSelect: (url) => {
97
+ this.$fileInputPath.value = url;
98
+ },
99
+ onClose: () => {
100
+ this._mediaBrowserOpen = false;
101
+ },
102
+ });
62
103
  }
63
104
 
64
105
  /**
@@ -1,5 +1,7 @@
1
1
  import EasyMDE from "easymde";
2
2
 
3
+ import { openMediaBrowser } from "../../lib/media-browser.js";
4
+
3
5
  const paths = {
4
6
  bold: "M17 30c6.1 0 10-3 10-8 0-3.5-2.7-6.3-6.5-6.5V15c3-.4 5-3 5-6 0-4.5-3.5-7-9-7H5v28h12ZM12 7h2c2.5 0 4 1 4 3 0 1.5-1.5 3-4 3h-2V7Zm0 18v-7h2.3c3.1 0 4.7 1.1 4.7 3.4 0 2.5-1.4 3.6-4.8 3.6H12Z",
5
7
  code: "m13.5 8.5-3-3L2 14C.5 15.5.5 16.5 2 18l8.5 8.5 3-3L6 16l7.5-7.5Zm5 0 3-3L30 14c1.5 1.5 1.5 2.5 0 4l-8.5 8.5-3-3L26 16l-7.5-7.5Z",
@@ -291,224 +293,19 @@ export const TextareaFieldComponent = class extends HTMLElement {
291
293
  if (this._mediaBrowserOpen) return;
292
294
  this._mediaBrowserOpen = true;
293
295
 
294
- const endpoint = this.editorEndpoint;
295
- let allItems = [];
296
- let afterCursor = null;
297
- let activeFilter = "all";
298
-
299
- // Create modal overlay
300
- const overlay = document.createElement("div");
301
- overlay.className = "media-browser";
302
- overlay.setAttribute("role", "dialog");
303
- overlay.setAttribute("aria-modal", "true");
304
- overlay.setAttribute("aria-label", "Browse media");
305
-
306
- const modal = document.createElement("div");
307
- modal.className = "media-browser__modal";
308
-
309
- // Header
310
- const header = document.createElement("div");
311
- header.className = "media-browser__header";
312
-
313
- const title = document.createElement("h2");
314
- title.className = "media-browser__title";
315
- title.textContent = "Browse media";
316
- header.append(title);
317
-
318
- const closeBtn = document.createElement("button");
319
- closeBtn.type = "button";
320
- closeBtn.className = "media-browser__close";
321
- closeBtn.setAttribute("aria-label", "Close");
322
- closeBtn.textContent = "\u00D7";
323
- closeBtn.addEventListener("click", close);
324
- header.append(closeBtn);
325
-
326
- // Filters
327
- const filters = document.createElement("div");
328
- filters.className = "media-browser__filters";
329
-
330
- for (const filter of ["all", "photo", "audio", "video"]) {
331
- const btn = document.createElement("button");
332
- btn.type = "button";
333
- btn.className = "media-browser__filter";
334
- if (filter === "all") btn.classList.add("is-active");
335
- btn.textContent = filter.charAt(0).toUpperCase() + filter.slice(1);
336
- btn.dataset.filter = filter;
337
- btn.addEventListener("click", () => {
338
- activeFilter = filter;
339
- for (const f of filters.querySelectorAll(".media-browser__filter")) {
340
- f.classList.toggle("is-active", f.dataset.filter === filter);
341
- }
342
- renderGrid();
343
- });
344
- filters.append(btn);
345
- }
346
-
347
- // Grid
348
- const grid = document.createElement("div");
349
- grid.className = "media-browser__grid";
350
-
351
- // Loading
352
- const loading = document.createElement("div");
353
- loading.className = "media-browser__loading";
354
- loading.textContent = "Loading\u2026";
355
-
356
- // Empty
357
- const empty = document.createElement("p");
358
- empty.className = "media-browser__empty";
359
- empty.textContent = "No media files found.";
360
- empty.hidden = true;
361
-
362
- // Load more
363
- const loadMoreBtn = document.createElement("button");
364
- loadMoreBtn.type = "button";
365
- loadMoreBtn.className = "media-browser__load-more";
366
- loadMoreBtn.textContent = "Load more";
367
- loadMoreBtn.hidden = true;
368
- loadMoreBtn.addEventListener("click", () => fetchMedia());
369
-
370
- modal.append(header, filters, grid, loading, empty, loadMoreBtn);
371
- overlay.append(modal);
372
- document.body.append(overlay);
373
-
374
- // Lock body scroll
375
- document.body.style.overflow = "hidden";
376
-
377
- // Close handlers
378
- overlay.addEventListener("click", (event) => {
379
- if (event.target === overlay) close();
296
+ openMediaBrowser({
297
+ endpoint: this.editorEndpoint,
298
+ onSelect: (url, filename, isImage) => {
299
+ const cm = editor.codemirror;
300
+ const cursor = cm.getCursor();
301
+ const text = isImage ? `![](${url})` : `[${filename}](${url})`;
302
+ cm.replaceRange(text, cursor);
303
+ },
304
+ onClose: () => {
305
+ this._mediaBrowserOpen = false;
306
+ editor.codemirror.focus();
307
+ },
380
308
  });
381
-
382
- const onKeyDown = (event) => {
383
- if (event.key === "Escape") close();
384
- };
385
- document.addEventListener("keydown", onKeyDown);
386
-
387
- function close() {
388
- overlay.remove();
389
- document.body.style.overflow = "";
390
- document.removeEventListener("keydown", onKeyDown);
391
- // Use arrow-free reference to component
392
- editor.codemirror.focus();
393
- // Reset flag on the component instance via closure
394
- closeBrowser();
395
- }
396
-
397
- const closeBrowser = () => {
398
- this._mediaBrowserOpen = false;
399
- };
400
-
401
- function getFilteredItems() {
402
- if (activeFilter === "all") return allItems;
403
- return allItems.filter((item) => {
404
- const type = item["media-type"] || "";
405
- return type === activeFilter;
406
- });
407
- }
408
-
409
- function isImageType(mediaType) {
410
- return mediaType === "photo";
411
- }
412
-
413
- function getMediaIcon(mediaType) {
414
- if (!mediaType) return "\uD83D\uDCC4";
415
- if (mediaType === "audio") return "\uD83C\uDFB5";
416
- if (mediaType === "video") return "\uD83C\uDFAC";
417
- return "\uD83D\uDCC4";
418
- }
419
-
420
- function getFilename(url) {
421
- try {
422
- return decodeURIComponent(url.split("/").pop());
423
- } catch {
424
- return url.split("/").pop();
425
- }
426
- }
427
-
428
- function renderGrid() {
429
- grid.replaceChildren();
430
- const filtered = getFilteredItems();
431
- empty.hidden = filtered.length > 0;
432
-
433
- for (const item of filtered) {
434
- const url = item.url;
435
- const mediaType = item["media-type"] || "";
436
- const isImage = isImageType(mediaType);
437
- const filename = getFilename(url);
438
-
439
- const tile = document.createElement("button");
440
- tile.type = "button";
441
- tile.className = "media-browser__item";
442
- tile.title = filename;
443
- tile.addEventListener("click", () => {
444
- insertMedia(url, filename, isImage);
445
- close();
446
- });
447
-
448
- if (isImage) {
449
- const img = document.createElement("img");
450
- img.src = `/image/s_240x240/${encodeURIComponent(url)}`;
451
- img.alt = filename;
452
- img.loading = "lazy";
453
- img.className = "media-browser__thumbnail";
454
- tile.append(img);
455
- } else {
456
- const icon = document.createElement("span");
457
- icon.className = "media-browser__icon";
458
- icon.textContent = getMediaIcon(mediaType);
459
- tile.append(icon);
460
-
461
- const name = document.createElement("span");
462
- name.className = "media-browser__filename";
463
- name.textContent = filename;
464
- tile.append(name);
465
- }
466
-
467
- grid.append(tile);
468
- }
469
- }
470
-
471
- function insertMedia(url, filename, isImage) {
472
- const cm = editor.codemirror;
473
- const cursor = cm.getCursor();
474
- const text = isImage ? `![](${url})` : `[${filename}](${url})`;
475
- cm.replaceRange(text, cursor);
476
- }
477
-
478
- async function fetchMedia() {
479
- loading.hidden = false;
480
- loadMoreBtn.hidden = true;
481
-
482
- try {
483
- const url = new URL(endpoint, globalThis.location.origin);
484
- url.searchParams.set("q", "source");
485
- url.searchParams.set("limit", "20");
486
- if (afterCursor) url.searchParams.set("after", afterCursor);
487
-
488
- const response = await fetch(url.href, { credentials: "same-origin" });
489
- if (!response.ok) throw new Error(response.statusText);
490
-
491
- const data = await response.json();
492
- const items = data.items || [];
493
- allItems = allItems.concat(items);
494
-
495
- afterCursor =
496
- data.paging && data.paging.after ? data.paging.after : null;
497
- loadMoreBtn.hidden = !afterCursor;
498
-
499
- renderGrid();
500
- } catch (error) {
501
- const errorMsg = document.createElement("p");
502
- errorMsg.className = "media-browser__error";
503
- errorMsg.textContent = `Error loading media: ${error.message}`;
504
- grid.replaceChildren(errorMsg);
505
- } finally {
506
- loading.hidden = true;
507
- }
508
- }
509
-
510
- // Initial fetch
511
- fetchMedia();
512
309
  }
513
310
 
514
311
  /**
@@ -122,6 +122,12 @@
122
122
  {% endblock %}
123
123
  <script type="module">
124
124
  if (navigator.serviceWorker) {
125
+ // Reload page when a new service worker activates (new deploy)
126
+ navigator.serviceWorker.addEventListener("message", (event) => {
127
+ if (event.data && event.data.command === "SW_UPDATED") {
128
+ window.location.reload();
129
+ }
130
+ });
125
131
  window.addEventListener("load", () => {
126
132
  navigator.serviceWorker.register("/serviceworker.js", {
127
133
  scope: '/'
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Shared media browser modal for browsing and selecting existing media files.
3
+ * Used by both the textarea (EasyMDE) and file-input components.
4
+ *
5
+ * @param {object} options
6
+ * @param {string} options.endpoint - Media endpoint URL (e.g., "/media")
7
+ * @param {Function} options.onSelect - Callback when a media item is selected: (url, filename, isImage) => void
8
+ * @param {Function} [options.onClose] - Optional callback when modal is closed without selection
9
+ * @param {string} [options.filterType] - Optional initial filter type ("all", "photo", "audio", "video")
10
+ */
11
+ export function openMediaBrowser({ endpoint, onSelect, onClose, filterType }) {
12
+ let allItems = [];
13
+ let afterCursor = null;
14
+ let activeFilter = filterType || "all";
15
+
16
+ // Create modal overlay
17
+ const overlay = document.createElement("div");
18
+ overlay.className = "media-browser";
19
+ overlay.setAttribute("role", "dialog");
20
+ overlay.setAttribute("aria-modal", "true");
21
+ overlay.setAttribute("aria-label", "Browse media");
22
+
23
+ const modal = document.createElement("div");
24
+ modal.className = "media-browser__modal";
25
+
26
+ // Header
27
+ const header = document.createElement("div");
28
+ header.className = "media-browser__header";
29
+
30
+ const title = document.createElement("h2");
31
+ title.className = "media-browser__title";
32
+ title.textContent = "Browse media";
33
+ header.append(title);
34
+
35
+ const closeBtn = document.createElement("button");
36
+ closeBtn.type = "button";
37
+ closeBtn.className = "media-browser__close";
38
+ closeBtn.setAttribute("aria-label", "Close");
39
+ closeBtn.textContent = "\u00D7";
40
+ closeBtn.addEventListener("click", close);
41
+ header.append(closeBtn);
42
+
43
+ // Filters
44
+ const filters = document.createElement("div");
45
+ filters.className = "media-browser__filters";
46
+
47
+ for (const filter of ["all", "photo", "audio", "video"]) {
48
+ const btn = document.createElement("button");
49
+ btn.type = "button";
50
+ btn.className = "media-browser__filter";
51
+ if (filter === activeFilter) btn.classList.add("is-active");
52
+ btn.textContent = filter.charAt(0).toUpperCase() + filter.slice(1);
53
+ btn.dataset.filter = filter;
54
+ btn.addEventListener("click", () => {
55
+ activeFilter = filter;
56
+ for (const f of filters.querySelectorAll(".media-browser__filter")) {
57
+ f.classList.toggle("is-active", f.dataset.filter === filter);
58
+ }
59
+ renderGrid();
60
+ });
61
+ filters.append(btn);
62
+ }
63
+
64
+ // Grid
65
+ const grid = document.createElement("div");
66
+ grid.className = "media-browser__grid";
67
+
68
+ // Loading
69
+ const loading = document.createElement("div");
70
+ loading.className = "media-browser__loading";
71
+ loading.textContent = "Loading\u2026";
72
+
73
+ // Empty
74
+ const empty = document.createElement("p");
75
+ empty.className = "media-browser__empty";
76
+ empty.textContent = "No media files found.";
77
+ empty.hidden = true;
78
+
79
+ // Load more
80
+ const loadMoreBtn = document.createElement("button");
81
+ loadMoreBtn.type = "button";
82
+ loadMoreBtn.className = "media-browser__load-more";
83
+ loadMoreBtn.textContent = "Load more";
84
+ loadMoreBtn.hidden = true;
85
+ loadMoreBtn.addEventListener("click", () => fetchMedia());
86
+
87
+ modal.append(header, filters, grid, loading, empty, loadMoreBtn);
88
+ overlay.append(modal);
89
+ document.body.append(overlay);
90
+
91
+ // Lock body scroll
92
+ document.body.style.overflow = "hidden";
93
+
94
+ // Close handlers
95
+ overlay.addEventListener("click", (event) => {
96
+ if (event.target === overlay) close();
97
+ });
98
+
99
+ const onKeyDown = (event) => {
100
+ if (event.key === "Escape") close();
101
+ };
102
+ document.addEventListener("keydown", onKeyDown);
103
+
104
+ function close() {
105
+ overlay.remove();
106
+ document.body.style.overflow = "";
107
+ document.removeEventListener("keydown", onKeyDown);
108
+ if (onClose) onClose();
109
+ }
110
+
111
+ function getFilteredItems() {
112
+ if (activeFilter === "all") return allItems;
113
+ return allItems.filter((item) => {
114
+ const type = item["media-type"] || "";
115
+ return type === activeFilter;
116
+ });
117
+ }
118
+
119
+ function isImageType(mediaType) {
120
+ return mediaType === "photo";
121
+ }
122
+
123
+ function getMediaIcon(mediaType) {
124
+ if (!mediaType) return "\uD83D\uDCC4";
125
+ if (mediaType === "audio") return "\uD83C\uDFB5";
126
+ if (mediaType === "video") return "\uD83C\uDFAC";
127
+ return "\uD83D\uDCC4";
128
+ }
129
+
130
+ function getFilename(url) {
131
+ try {
132
+ return decodeURIComponent(url.split("/").pop());
133
+ } catch {
134
+ return url.split("/").pop();
135
+ }
136
+ }
137
+
138
+ function renderGrid() {
139
+ grid.replaceChildren();
140
+ const filtered = getFilteredItems();
141
+ empty.hidden = filtered.length > 0;
142
+
143
+ for (const item of filtered) {
144
+ const url = item.url;
145
+ const mediaType = item["media-type"] || "";
146
+ const isImage = isImageType(mediaType);
147
+ const filename = getFilename(url);
148
+
149
+ const tile = document.createElement("button");
150
+ tile.type = "button";
151
+ tile.className = "media-browser__item";
152
+ tile.title = filename;
153
+ tile.addEventListener("click", () => {
154
+ onSelect(url, filename, isImage);
155
+ close();
156
+ });
157
+
158
+ if (isImage) {
159
+ const img = document.createElement("img");
160
+ img.src = `/image/s_240x240/${encodeURIComponent(url)}`;
161
+ img.alt = filename;
162
+ img.loading = "lazy";
163
+ img.className = "media-browser__thumbnail";
164
+ tile.append(img);
165
+ } else {
166
+ const icon = document.createElement("span");
167
+ icon.className = "media-browser__icon";
168
+ icon.textContent = getMediaIcon(mediaType);
169
+ tile.append(icon);
170
+
171
+ const name = document.createElement("span");
172
+ name.className = "media-browser__filename";
173
+ name.textContent = filename;
174
+ tile.append(name);
175
+ }
176
+
177
+ grid.append(tile);
178
+ }
179
+ }
180
+
181
+ async function fetchMedia() {
182
+ loading.hidden = false;
183
+ loadMoreBtn.hidden = true;
184
+
185
+ try {
186
+ const url = new URL(endpoint, globalThis.location.origin);
187
+ url.searchParams.set("q", "source");
188
+ url.searchParams.set("limit", "20");
189
+ if (afterCursor) url.searchParams.set("after", afterCursor);
190
+
191
+ const response = await fetch(url.href, { credentials: "same-origin" });
192
+ if (!response.ok) throw new Error(response.statusText);
193
+
194
+ const data = await response.json();
195
+ const items = data.items || [];
196
+ allItems = allItems.concat(items);
197
+
198
+ afterCursor =
199
+ data.paging && data.paging.after ? data.paging.after : null;
200
+ loadMoreBtn.hidden = !afterCursor;
201
+
202
+ renderGrid();
203
+ } catch (error) {
204
+ const errorMsg = document.createElement("p");
205
+ errorMsg.className = "media-browser__error";
206
+ errorMsg.textContent = `Error loading media: ${error.message}`;
207
+ grid.replaceChildren(errorMsg);
208
+ } finally {
209
+ loading.hidden = true;
210
+ }
211
+ }
212
+
213
+ // Initial fetch
214
+ fetchMedia();
215
+ }
@@ -8,6 +8,15 @@ const cacheList = new Set([assetCacheName, pagesCacheName, imageCacheName]);
8
8
  const placeholderImage = `<svg xmlns="http://www.w3.org/2000/svg"><defs><path id="icon" fill="#AAA" d="M24 32a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm-6.9-11.9 4.1 4.1a17 17 0 0 0-9.7 5.3L8 26a22 22 0 0 1 9-6Zm22.5 5.4L36 29l-.8-.8L26 19a22 22 0 0 1 13.5 6.4ZM8.2 11.2l3.7 3.7a24.7 24.7 0 0 0-8.4 6.6l-3.6-3.6c2.4-2.7 5.2-5 8.3-6.7ZM24 7a32 32 0 0 1 23.4 10.2l-3.5 3.6a27 27 0 0 0-24.5-8.4l-4.2-4.2A32 32 0 0 1 24 7ZM2 5l3-3 41 41-3 3L2 5Z" opacity=".7"/>
9
9
  </defs><rect fill="#000" width="100%" height="100%" opacity="0.075"/><use href="#icon" x="50%" y="50%" transform="translate(-24 -24)"/></svg>`;
10
10
 
11
+ /**
12
+ * Check if a URL is a hashed asset (content-addressable, immutable)
13
+ * @param {string} url - Request URL
14
+ * @returns {boolean}
15
+ */
16
+ function isHashedAsset(url) {
17
+ return /\/assets\/app-[a-f0-9]+\.(js|css)$/.test(url);
18
+ }
19
+
11
20
  /**
12
21
  * Update asset cache
13
22
  * @returns {Promise<Cache>} - Updated asset cache
@@ -16,7 +25,7 @@ async function updateAssetCache() {
16
25
  try {
17
26
  const assetCache = await caches.open(assetCacheName);
18
27
 
19
- // These items wont block the installation of the service worker
28
+ // These items won't block the installation of the service worker
20
29
  assetCache.addAll(["/app.webmanifest"]);
21
30
 
22
31
  // These items must be cached for service worker to complete installation
@@ -67,6 +76,27 @@ async function clearOldCaches() {
67
76
  }
68
77
  }
69
78
 
79
+ /**
80
+ * Clear the pages cache so stale HTML (with old asset references) is not served
81
+ */
82
+ async function clearPagesCache() {
83
+ try {
84
+ await caches.delete(pagesCacheName);
85
+ } catch (error) {
86
+ console.error("Error clearing pages cache", error);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Notify all clients that the service worker has been updated
92
+ */
93
+ async function notifyClients() {
94
+ const allClients = await clients.matchAll({ includeUncontrolled: true });
95
+ for (const client of allClients) {
96
+ client.postMessage({ command: "SW_UPDATED", version: "APP_VERSION" });
97
+ }
98
+ }
99
+
70
100
  /**
71
101
  * Trim cache
72
102
  * @param {string} cacheName - Name of cache
@@ -100,7 +130,9 @@ self.addEventListener("activate", async (event) => {
100
130
  event.waitUntil(
101
131
  (async () => {
102
132
  await clearOldCaches();
133
+ await clearPagesCache();
103
134
  await clients.claim();
135
+ await notifyClients();
104
136
  })(),
105
137
  );
106
138
  });
@@ -126,47 +158,40 @@ self.addEventListener("fetch", (event) => {
126
158
  return;
127
159
  }
128
160
 
129
- const retrieveFromCache = caches.match(request);
130
-
131
- // For HTML requests, try network, fall back to cache, else show offline page
161
+ // For HTML requests, try network with timeout, fall back to cache
132
162
  if (
133
163
  request.mode === "navigate" ||
134
164
  request.headers.get("Accept").includes("text/html")
135
165
  ) {
136
166
  event.respondWith(
137
167
  (async () => {
138
- // CHECK CACHE
139
- const timer = setTimeout(async () => {
140
- const responseFromCache = await retrieveFromCache;
141
- if (responseFromCache) {
142
- return responseFromCache;
143
- }
144
- }, timeout);
145
-
146
168
  try {
147
- const preloadResponse = await Promise.resolve(event.preloadResponse);
148
- const responseFromPreloadOrFetch =
149
- preloadResponse || (await fetch(request));
169
+ // Race network against a timeout for faster fallback on slow connections
170
+ const responseFromPreloadOrFetch = await Promise.race([
171
+ (async () => {
172
+ const preloadResponse = await Promise.resolve(
173
+ event.preloadResponse,
174
+ );
175
+ return preloadResponse || (await fetch(request));
176
+ })(),
177
+ new Promise((_, reject) =>
178
+ setTimeout(() => reject(new Error("Network timeout")), timeout),
179
+ ),
180
+ ]);
150
181
 
151
- // NETWORK
152
- // Save a copy of page to pages cache
153
- clearTimeout(timer);
182
+ // NETWORK succeeded — cache and serve
154
183
  try {
155
184
  const copy = responseFromPreloadOrFetch.clone();
156
185
  const pagesCache = await caches.open(pagesCacheName);
157
186
  await pagesCache.put(request, copy);
158
187
  } catch (cacheError) {
159
- // Cache put failed (e.g., network error response), but continue serving the response
160
188
  console.error("Failed to cache page:", cacheError);
161
189
  }
162
190
 
163
191
  return responseFromPreloadOrFetch;
164
- } catch (error) {
165
- console.error("Network fetch failed:", error, request.url);
166
-
167
- // CACHE or OFFLINE PAGE
168
- clearTimeout(timer);
169
- const responseFromCache = await retrieveFromCache;
192
+ } catch {
193
+ // NETWORK failed or timed out — fall back to cache
194
+ const responseFromCache = await caches.match(request);
170
195
  const offlineResponse = await caches.match("/offline");
171
196
  return (
172
197
  responseFromCache ||
@@ -184,33 +209,93 @@ self.addEventListener("fetch", (event) => {
184
209
  return;
185
210
  }
186
211
 
187
- // For non-HTML requests, look in cache first, fall back to network
212
+ // For hashed assets (e.g. /assets/app-abc123.js): cache-first
213
+ // These URLs are content-addressable — the content never changes for a given hash
214
+ if (isHashedAsset(request.url)) {
215
+ event.respondWith(
216
+ (async () => {
217
+ const responseFromCache = await caches.match(request);
218
+ if (responseFromCache) {
219
+ return responseFromCache;
220
+ }
221
+
222
+ try {
223
+ const responseFromFetch = await fetch(request);
224
+ const copy = responseFromFetch.clone();
225
+ const assetCache = await caches.open(assetCacheName);
226
+ await assetCache.put(request, copy);
227
+ return responseFromFetch;
228
+ } catch (error) {
229
+ console.error("Fetch failed for hashed asset:", error, request.url);
230
+ return new Response("Network error", {
231
+ status: 503,
232
+ statusText: "Service Unavailable",
233
+ headers: { "Content-Type": "text/plain" },
234
+ });
235
+ }
236
+ })(),
237
+ );
238
+
239
+ return;
240
+ }
241
+
242
+ // For other non-HTML requests: stale-while-revalidate
243
+ // Serve from cache immediately, update cache in background
188
244
  event.respondWith(
189
245
  (async () => {
190
246
  try {
191
- const responseFromCache = await retrieveFromCache;
247
+ const responseFromCache = await caches.match(request);
248
+
249
+ // Start network fetch regardless (to update cache)
250
+ const fetchPromise = fetch(request)
251
+ .then(async (responseFromFetch) => {
252
+ // Update cache with fresh response
253
+ if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
254
+ try {
255
+ const copy = responseFromFetch.clone();
256
+ const imagesCache = await caches.open(imageCacheName);
257
+ await imagesCache.put(request, copy);
258
+ } catch (cacheError) {
259
+ console.error("Failed to cache image:", cacheError);
260
+ }
261
+ }
262
+ return responseFromFetch;
263
+ })
264
+ .catch((error) => {
265
+ console.error(
266
+ "Background fetch failed:",
267
+ error,
268
+ request.url,
269
+ );
270
+ return null;
271
+ });
192
272
 
193
273
  if (responseFromCache) {
194
- // CACHE
274
+ // CACHE HIT — serve cached, update in background
195
275
  return responseFromCache;
196
- } else {
197
- const responseFromFetch = await fetch(request);
198
-
199
- // NETWORK
200
- // If request is for an image, save a copy to images cache
201
- if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
202
- try {
203
- const copy = responseFromFetch.clone();
204
- const imagesCache = await caches.open(imageCacheName);
205
- await imagesCache.put(request, copy);
206
- } catch (cacheError) {
207
- // Cache put failed (e.g., network error response), but continue serving the response
208
- console.error("Failed to cache image:", cacheError);
209
- }
210
- }
276
+ }
211
277
 
278
+ // CACHE MISS — wait for network
279
+ const responseFromFetch = await fetchPromise;
280
+ if (responseFromFetch) {
212
281
  return responseFromFetch;
213
282
  }
283
+
284
+ // OFFLINE IMAGE
285
+ if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
286
+ return new Response(placeholderImage, {
287
+ headers: {
288
+ "Content-Type": "image/svg+xml",
289
+ "Cache-Control": "no-store",
290
+ },
291
+ });
292
+ }
293
+
294
+ return new Response("Network error", {
295
+ status: 503,
296
+ statusText: "Service Unavailable",
297
+ headers: { "Content-Type": "text/plain" },
298
+ });
214
299
  } catch (error) {
215
300
  console.error(
216
301
  "Fetch failed for non-HTML resource:",
@@ -218,7 +303,6 @@ self.addEventListener("fetch", (event) => {
218
303
  request.url,
219
304
  );
220
305
 
221
- // OFFLINE IMAGE
222
306
  if (/\.(jpe?g|png|gif|svg|webp)/.test(request.url)) {
223
307
  return new Response(placeholderImage, {
224
308
  headers: {
@@ -228,7 +312,6 @@ self.addEventListener("fetch", (event) => {
228
312
  });
229
313
  }
230
314
 
231
- // For other resources, return a network error response
232
315
  return new Response("Network error", {
233
316
  status: 503,
234
317
  statusText: "Service Unavailable",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-frontend",
3
- "version": "1.0.0-beta.32",
3
+ "version": "1.0.0-beta.34",
4
4
  "description": "Frontend components for Indiekit (fork with floating toolbar)",
5
5
  "keywords": [
6
6
  "express",