commit 2b66fa9cb8628880119caefc82fcc6dc1d1139a0
parent cf7ecdd6d6bca17ddcc7601a3ba9f49b53510d5f
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Sat, 29 Aug 2026 12:44:43 +0530
Merge pull request #110 from notamitgamer/feature/offline-snapshots
Add offline Snapshots page (browser-local, independent of main site cache)
Diffstat:
6 files changed, 435 insertions(+), 1 deletion(-)
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
@@ -74,6 +74,7 @@ const vitePressConfig = {
]
},
{ text: 'Tags', link: '/tags' },
+ { text: 'Snapshots', link: '/snapshots' },
{ text: 'License', link: 'https://github.com/notamitgamer/bsc/blob/main/LICENSE'},
{ text: 'Changelog', link: '/changelog' },
],
@@ -190,7 +191,7 @@ const sidebarConfig = {
useTitleFromFileHeading: true,
useFolderTitleFromIndexFile: true,
useFolderLinkFromIndexFile: true,
- excludeFiles: ['tags.md'],
+ excludeFiles: ['tags.md', 'snapshots.md'],
excludeFolders: ['stylesheets', 'overrides', 'assets', '.vitepress'],
sortMenusByName: true,
sortMenusOrderNumerically: true,
diff --git a/docs/.vitepress/theme/components/SnapshotManager.vue b/docs/.vitepress/theme/components/SnapshotManager.vue
@@ -0,0 +1,241 @@
+<script setup>
+import { ref, onMounted } from 'vue'
+import { listSnapshots, putSnapshot, deleteSnapshotMeta } from '../lib/snapshotDb'
+
+const snapshots = ref([])
+const isCapturing = ref(false)
+const progress = ref({ done: 0, total: 0, label: '' })
+const error = ref('')
+
+onMounted(refresh)
+
+async function refresh() {
+ snapshots.value = await listSnapshots()
+}
+
+function fmtBytes(n) {
+ if (!n) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB']
+ let i = 0
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
+ return `${n.toFixed(1)} ${units[i]}`
+}
+
+function fmtDate(ts) {
+ return new Date(ts).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+}
+
+async function getPagePaths() {
+ const res = await fetch('/sitemap.xml')
+ if (!res.ok) throw new Error('Could not read /sitemap.xml')
+ const xml = await res.text()
+ const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])
+ return locs.map((loc) => {
+ try {
+ return new URL(loc).pathname
+ } catch {
+ return null
+ }
+ }).filter(Boolean)
+}
+
+function extractAssetUrls(html) {
+ const urls = new Set()
+ const re = /(?:src|href)="([^"]+)"/g
+ let m
+ while ((m = re.exec(html))) {
+ const u = m[1]
+ if (u.startsWith('/') && !u.startsWith('//')) urls.add(u)
+ }
+ return urls
+}
+
+async function takeSnapshot() {
+ error.value = ''
+ isCapturing.value = true
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+ const cacheName = `snapshot-${id}`
+ let sizeBytes = 0
+ let assetUrls = new Set()
+
+ try {
+ const pagePaths = await getPagePaths()
+ progress.value = { done: 0, total: pagePaths.length, label: 'Fetching pages…' }
+
+ const cache = await caches.open(cacheName)
+
+ for (const path of pagePaths) {
+ try {
+ const res = await fetch(path, { cache: 'no-store' })
+ if (res && res.ok) {
+ const clone = res.clone()
+ const html = await clone.text()
+ sizeBytes += html.length
+ extractAssetUrls(html).forEach((u) => assetUrls.add(u))
+ await cache.put(path, res)
+ }
+ } catch (_) {
+ /* skip page that failed, continue capturing the rest */
+ }
+ progress.value.done++
+ }
+
+ const assetList = [...assetUrls]
+ progress.value = { done: 0, total: assetList.length, label: 'Fetching assets…' }
+ for (const url of assetList) {
+ try {
+ const res = await fetch(url, { cache: 'no-store' })
+ if (res && res.ok) {
+ const buf = await res.clone().arrayBuffer()
+ sizeBytes += buf.byteLength
+ await cache.put(url, res)
+ }
+ } catch (_) {
+ /* skip */
+ }
+ progress.value.done++
+ }
+
+ await putSnapshot({
+ id,
+ name: `Snapshot – ${new Date().toLocaleDateString()}`,
+ createdAt: Date.now(),
+ pageCount: pagePaths.length,
+ assetCount: assetList.length,
+ sizeBytes,
+ })
+
+ await refresh()
+ } catch (e) {
+ error.value = e?.message || 'Snapshot failed.'
+ // best-effort cleanup of a partial cache
+ try { await caches.delete(cacheName) } catch (_) {}
+ } finally {
+ isCapturing.value = false
+ progress.value = { done: 0, total: 0, label: '' }
+ }
+}
+
+function viewSnapshot(id) {
+ window.open(`/snapshots/view/${id}/`, '_blank', 'noopener')
+}
+
+async function removeSnapshot(id) {
+ if (!confirm('Delete this snapshot? This cannot be undone.')) return
+ await caches.delete(`snapshot-${id}`)
+ await deleteSnapshotMeta(id)
+ await refresh()
+}
+
+async function renameSnapshot(snap) {
+ const name = prompt('Snapshot name', snap.name)
+ if (!name) return
+ await putSnapshot({ ...snap, name })
+ await refresh()
+}
+</script>
+
+<template>
+ <div class="snapshot-manager">
+ <p class="intro">
+ Snapshots save a fully offline, point-in-time copy of the site into your browser
+ (separate from normal caching — the live site keeps auto-updating as usual).
+ Take one before you go offline, and come back to it any time from this page.
+ </p>
+
+ <button class="capture-btn" :disabled="isCapturing" @click="takeSnapshot">
+ <span v-if="!isCapturing">📸 Take snapshot now</span>
+ <span v-else>Capturing… {{ progress.label }} ({{ progress.done }}/{{ progress.total }})</span>
+ </button>
+
+ <p v-if="error" class="error">{{ error }}</p>
+
+ <div v-if="snapshots.length" class="snapshot-list">
+ <div v-for="s in snapshots" :key="s.id" class="snapshot-item">
+ <div class="snapshot-info">
+ <div class="snapshot-name">{{ s.name }}</div>
+ <div class="snapshot-meta">
+ {{ fmtDate(s.createdAt) }} · {{ s.pageCount }} pages · {{ s.assetCount }} assets · {{ fmtBytes(s.sizeBytes) }}
+ </div>
+ </div>
+ <div class="snapshot-actions">
+ <button @click="viewSnapshot(s.id)">View</button>
+ <button @click="renameSnapshot(s)">Rename</button>
+ <button class="danger" @click="removeSnapshot(s.id)">Delete</button>
+ </div>
+ </div>
+ </div>
+ <p v-else class="empty">No snapshots yet.</p>
+ </div>
+</template>
+
+<style scoped>
+.snapshot-manager {
+ max-width: 720px;
+}
+.intro {
+ color: var(--vp-c-text-2);
+ font-size: 14px;
+ margin-bottom: 16px;
+}
+.capture-btn {
+ background: var(--vp-c-brand-1);
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ padding: 10px 18px;
+ font-weight: 600;
+ cursor: pointer;
+ margin-bottom: 20px;
+}
+.capture-btn:disabled {
+ opacity: 0.7;
+ cursor: default;
+}
+.error {
+ color: var(--vp-c-danger-1, #d33);
+ font-size: 13px;
+}
+.snapshot-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.snapshot-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--vp-c-divider);
+ border-radius: 8px;
+}
+.snapshot-name {
+ font-weight: 600;
+}
+.snapshot-meta {
+ font-size: 12.5px;
+ color: var(--vp-c-text-2);
+}
+.snapshot-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+.snapshot-actions button {
+ font-size: 12.5px;
+ padding: 5px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--vp-c-divider);
+ background: var(--vp-c-bg-soft);
+ cursor: pointer;
+}
+.snapshot-actions button.danger {
+ color: #d33;
+ border-color: #d33;
+}
+.empty {
+ color: var(--vp-c-text-2);
+ font-size: 14px;
+}
+</style>
diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts
@@ -37,6 +37,13 @@ export default {
setup() {
onMounted(() => {
+ if ('serviceWorker' in navigator) {
+ // Registered on every page, independent of the main site's own
+ // caching. Only handles /snapshots and /snapshots/view/* — see
+ // public/sw-snapshot.js.
+ navigator.serviceWorker.register('/sw-snapshot.js', { scope: '/snapshots' }).catch(() => {})
+ }
+
setTimeout(() => {
const searchBtn = document.querySelector('.VPNavBarSearchButton');
const askAiBtn = document.querySelector('.VPNavBarAskAiButton');
diff --git a/docs/.vitepress/theme/lib/snapshotDb.ts b/docs/.vitepress/theme/lib/snapshotDb.ts
@@ -0,0 +1,60 @@
+// Lightweight IndexedDB wrapper for snapshot metadata.
+// Actual page/asset bytes live in the Cache Storage API (see public/sw-snapshot.js);
+// this only stores the small list you see on the /snapshots page.
+
+const DB_NAME = 'bsc-snapshots-db'
+const STORE = 'snapshots'
+const DB_VERSION = 1
+
+export interface SnapshotMeta {
+ id: string
+ name: string
+ createdAt: number
+ pageCount: number
+ assetCount: number
+ sizeBytes: number
+}
+
+function openDb(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
+ req.onupgradeneeded = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains(STORE)) {
+ db.createObjectStore(STORE, { keyPath: 'id' })
+ }
+ }
+ req.onsuccess = () => resolve(req.result)
+ req.onerror = () => reject(req.error)
+ })
+}
+
+export async function listSnapshots(): Promise<SnapshotMeta[]> {
+ const db = await openDb()
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readonly')
+ const req = tx.objectStore(STORE).getAll()
+ req.onsuccess = () => resolve((req.result as SnapshotMeta[]).sort((a, b) => b.createdAt - a.createdAt))
+ req.onerror = () => reject(req.error)
+ })
+}
+
+export async function putSnapshot(meta: SnapshotMeta): Promise<void> {
+ const db = await openDb()
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite')
+ tx.objectStore(STORE).put(meta)
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+}
+
+export async function deleteSnapshotMeta(id: string): Promise<void> {
+ const db = await openDb()
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite')
+ tx.objectStore(STORE).delete(id)
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+}
diff --git a/docs/public/sw-snapshot.js b/docs/public/sw-snapshot.js
@@ -0,0 +1,114 @@
+// sw-snapshot.js
+//
+// Registered on every page load (see theme/index.ts) regardless of which page
+// the user landed on. It does two things ONLY:
+//
+// 1. Keeps a small "shell" cache of the /snapshots page itself, so that page
+// is reachable offline even if the visitor never had the main site cached.
+// 2. Serves pages/assets for /snapshots/view/<id>/... from that snapshot's
+// own dedicated cache (created by SnapshotManager.vue when a snapshot is taken).
+//
+// It never intercepts any other request. The main site's own caching/update
+// behavior (e.g. the deploy-id banner) is completely untouched.
+
+const SHELL_CACHE = 'bsc-snapshot-shell-v1'
+const SNAPSHOT_PREFIX = 'snapshot-'
+const VIEW_PATH_RE = /^\/snapshots\/view\/([^/]+)\/?(.*)$/
+
+self.addEventListener('install', (event) => {
+ self.skipWaiting()
+ event.waitUntil(cacheSnapshotsShell())
+})
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim())
+})
+
+async function cacheSnapshotsShell() {
+ try {
+ const cache = await caches.open(SHELL_CACHE)
+ const shellUrl = '/snapshots'
+ const res = await fetch(shellUrl, { cache: 'no-store' })
+ if (!res || !res.ok) return
+ const html = await res.clone().text()
+ await cache.put(shellUrl, res)
+
+ // Pull same-origin script/style/link assets referenced by the page so the
+ // whole shell (not just the bare HTML) works offline.
+ const urls = new Set()
+ const attrRe = /(?:src|href)="([^"]+)"/g
+ let m
+ while ((m = attrRe.exec(html))) {
+ const u = m[1]
+ if (u.startsWith('/') && !u.startsWith('//')) urls.add(u)
+ }
+ await Promise.all(
+ [...urls].map(async (u) => {
+ try {
+ const r = await fetch(u, { cache: 'no-store' })
+ if (r && r.ok) await cache.put(u, r)
+ } catch (_) {
+ /* best-effort */
+ }
+ })
+ )
+ } catch (_) {
+ /* best-effort, never block install */
+ }
+}
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url)
+ if (url.origin !== self.location.origin) return
+
+ const viewMatch = url.pathname.match(VIEW_PATH_RE)
+ if (viewMatch) {
+ event.respondWith(handleSnapshotView(viewMatch[1], '/' + viewMatch[2]))
+ return
+ }
+
+ if (url.pathname === '/snapshots' || url.pathname === '/snapshots/') {
+ event.respondWith(handleShellRequest(event.request))
+ return
+ }
+
+ // Everything else: don't touch it, let it behave exactly as it does today.
+})
+
+async function handleShellRequest(request) {
+ try {
+ return await fetch(request)
+ } catch (_) {
+ const cache = await caches.open(SHELL_CACHE)
+ const cached = await cache.match('/snapshots')
+ return cached || new Response('Offline and no cached shell available.', { status: 503 })
+ }
+}
+
+async function handleSnapshotView(id, path) {
+ const cacheName = SNAPSHOT_PREFIX + id
+ const cache = await caches.open(cacheName)
+
+ // Normalize: "" or "/" -> "/", strip trailing slash otherwise for lookup,
+ // but try a couple of variants since snapshots are keyed by clean-URL paths.
+ const candidates = new Set([path])
+ if (path === '/' ) candidates.add('/')
+ if (path.endsWith('/')) candidates.add(path.slice(0, -1) || '/')
+ else candidates.add(path + '/')
+ candidates.add('/')
+
+ for (const p of candidates) {
+ const hit = await cache.match(p)
+ if (hit) return hit
+ }
+
+ // Fall back to any same-name asset (covers hashed asset paths stored verbatim).
+ const assetHit = await cache.match(path)
+ if (assetHit) return assetHit
+
+ return new Response(
+ `<!doctype html><meta charset="utf-8"><title>Not in snapshot</title>
+ <p>This page wasn't captured in this snapshot. <a href="/snapshots">Back to snapshots</a>.</p>`,
+ { status: 404, headers: { 'Content-Type': 'text/html' } }
+ )
+}
diff --git a/docs/snapshots.md b/docs/snapshots.md
@@ -0,0 +1,11 @@
+---
+title: 'Snapshots'
+---
+
+<script setup>
+import SnapshotManager from './.vitepress/theme/components/SnapshotManager.vue'
+</script>
+
+# Offline Snapshots
+
+<SnapshotManager />