feat(#84): Improve performance and efficiency of service worker
@@ -33,6 +33,18 @@ npm run build
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
## Sprites
|
||||
|
||||
The app uses WebP sprites from `static/sprites-small`. During builds we generate this folder from the
|
||||
full-resolution PNGs in `static/sprites`:
|
||||
|
||||
```bash
|
||||
npm run sprites:build
|
||||
```
|
||||
|
||||
If you want to serve sprites locally, set `PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER="true"` in `.env`.
|
||||
Otherwise the app defaults to GitHub raw for `static/sprites-small`.
|
||||
|
||||
## Hosting
|
||||
|
||||
The app is hosted on [Netlify](https://www.netlify.com/) at [pokedex.jcreek.co.uk](https://pokedex.jcreek.co.uk/).
|
||||
@@ -43,4 +55,6 @@ User authentication is handled by [Supabase Auth](https://supabase.com/auth).
|
||||
|
||||
## Dependencies
|
||||
|
||||
The living dex tracker's sprite collection is taken from [PokéAPI Sprites](https://github.com/PokeAPI/sprites), which is licensed under [the Creative Commons CC0 1.0 Universal license](https://github.com/PokeAPI/sprites/blob/master/LICENCE.txt).
|
||||
The living dex tracker's sprite collection is derived from [PokéAPI Sprites](https://github.com/PokeAPI/sprites)
|
||||
and converted to smaller WebP files in `static/sprites-small`. PokéAPI sprites are licensed under
|
||||
[the Creative Commons CC0 1.0 Universal license](https://github.com/PokeAPI/sprites/blob/master/LICENCE.txt).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dev": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||
"sprites:build": "node scripts/optimize-sprites.mjs",
|
||||
"build-generate-sw": "GENERATE_SW=true vite build",
|
||||
"build-generate-sw-node": "NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||
"build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build",
|
||||
@@ -54,6 +55,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"sharp": "^0.33.4",
|
||||
"supabase": "^2.70.5",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.2",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { mkdir, readdir, rename } from 'node:fs/promises';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const inputDir = process.env.SPRITE_INPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites');
|
||||
const outputDir =
|
||||
process.env.SPRITE_OUTPUT_DIR ?? path.join(process.cwd(), 'static', 'sprites-small');
|
||||
const format = (process.env.SPRITE_FORMAT ?? 'webp').toLowerCase();
|
||||
const quality = Number(process.env.SPRITE_QUALITY ?? 80);
|
||||
const maxSize = Number(process.env.SPRITE_MAX_SIZE ?? 0);
|
||||
|
||||
if (!['png', 'webp'].includes(format)) {
|
||||
console.error(`Unsupported SPRITE_FORMAT "${format}". Use "png" or "webp".`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function walk(dir, files = []) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath, files);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = await walk(inputDir);
|
||||
console.log(`Optimizing ${files.length} sprite images from ${inputDir}`);
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
const relative = path.relative(inputDir, file);
|
||||
const baseName = path.basename(file, path.extname(file));
|
||||
const targetDir = path.join(outputDir, path.dirname(relative));
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const targetExt = format === 'png' ? '.png' : '.webp';
|
||||
const targetPath = path.join(targetDir, `${baseName}${targetExt}`);
|
||||
const tempPath = format === 'png' && outputDir === inputDir ? `${targetPath}.tmp` : targetPath;
|
||||
|
||||
const pipeline = sharp(file);
|
||||
if (maxSize > 0) {
|
||||
pipeline.resize({
|
||||
width: maxSize,
|
||||
height: maxSize,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
});
|
||||
}
|
||||
|
||||
if (format === 'png') {
|
||||
await pipeline.png({ compressionLevel: 9, palette: true, quality }).toFile(tempPath);
|
||||
} else {
|
||||
await pipeline.webp({ quality }).toFile(tempPath);
|
||||
}
|
||||
|
||||
if (tempPath !== targetPath) {
|
||||
await rename(tempPath, targetPath);
|
||||
}
|
||||
|
||||
if ((index + 1) % 250 === 0) {
|
||||
console.log(`Processed ${index + 1}/${files.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Sprite optimization complete.');
|
||||
@@ -0,0 +1,106 @@
|
||||
type InViewParams = {
|
||||
rootMargin?: string;
|
||||
threshold?: number | number[];
|
||||
once?: boolean;
|
||||
enabled?: boolean;
|
||||
onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void;
|
||||
};
|
||||
|
||||
const observers = new Map<string, IntersectionObserver>();
|
||||
const callbacks = new WeakMap<Element, (entry: IntersectionObserverEntry) => void>();
|
||||
|
||||
function normalizeThreshold(threshold: number | number[] | undefined) {
|
||||
if (threshold === undefined) return [0];
|
||||
return Array.isArray(threshold) ? threshold : [threshold];
|
||||
}
|
||||
|
||||
function buildObserverKey(rootMargin: string, threshold: number | number[]) {
|
||||
return `${rootMargin}|${JSON.stringify(normalizeThreshold(threshold))}`;
|
||||
}
|
||||
|
||||
function getObserver(rootMargin: string, threshold: number | number[]) {
|
||||
const key = buildObserverKey(rootMargin, threshold);
|
||||
const existing = observers.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const callback = callbacks.get(entry.target);
|
||||
if (callback) {
|
||||
callback(entry);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin, threshold }
|
||||
);
|
||||
observers.set(key, observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
export function inView(node: Element, params: InViewParams = {}) {
|
||||
let options: Required<InViewParams> = {
|
||||
rootMargin: '200px 0px',
|
||||
threshold: 0,
|
||||
once: true,
|
||||
enabled: true,
|
||||
onChange: () => undefined,
|
||||
...params
|
||||
};
|
||||
|
||||
let observer = getObserver(options.rootMargin, options.threshold);
|
||||
|
||||
const handleEntry = (entry: IntersectionObserverEntry) => {
|
||||
const isIntersecting = entry.isIntersecting || entry.intersectionRatio > 0;
|
||||
options.onChange(isIntersecting, entry);
|
||||
if (isIntersecting && options.once) {
|
||||
observer.unobserve(node);
|
||||
callbacks.delete(node);
|
||||
}
|
||||
};
|
||||
|
||||
const startObserving = () => {
|
||||
callbacks.set(node, handleEntry);
|
||||
observer.observe(node);
|
||||
};
|
||||
|
||||
const stopObserving = () => {
|
||||
observer.unobserve(node);
|
||||
callbacks.delete(node);
|
||||
};
|
||||
|
||||
if (options.enabled) {
|
||||
startObserving();
|
||||
}
|
||||
|
||||
return {
|
||||
update(next: InViewParams = {}) {
|
||||
const nextOptions: Required<InViewParams> = {
|
||||
...options,
|
||||
...next,
|
||||
onChange: next.onChange ?? options.onChange
|
||||
};
|
||||
const observerKeyChanged =
|
||||
buildObserverKey(options.rootMargin, options.threshold) !==
|
||||
buildObserverKey(nextOptions.rootMargin, nextOptions.threshold);
|
||||
const enabledChanged = options.enabled !== nextOptions.enabled;
|
||||
|
||||
if (observerKeyChanged || enabledChanged) {
|
||||
stopObserving();
|
||||
}
|
||||
|
||||
options = nextOptions;
|
||||
|
||||
if (observerKeyChanged) {
|
||||
observer = getObserver(options.rootMargin, options.threshold);
|
||||
}
|
||||
|
||||
if (options.enabled) {
|
||||
startObserving();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
stopObserving();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||
import { inView } from '$lib/actions/inView';
|
||||
|
||||
export let pokemonName: string;
|
||||
export let pokedexNumber: string | number;
|
||||
export let form: string | undefined;
|
||||
export let spriteKey: string | undefined;
|
||||
export let shiny: boolean | undefined = false;
|
||||
export let loadingStrategy: 'eager' | 'lazy' | 'inView' = 'inView';
|
||||
|
||||
let imagePath = null as string | null;
|
||||
let isInView = false;
|
||||
|
||||
function isFemaleForm(value?: string) {
|
||||
return /^female\b/i.test((value ?? '').trim());
|
||||
@@ -19,7 +22,10 @@
|
||||
|
||||
let formValue = form.trim();
|
||||
formValue = formValue.replace(/^female[-\s]*/i, '');
|
||||
formValue = formValue.replace(/\s*\(.*?\)/g, '').replace(/\s*\[.*?\]/g, '').trim();
|
||||
formValue = formValue
|
||||
.replace(/\s*\(.*?\)/g, '')
|
||||
.replace(/\s*\[.*?\]/g, '')
|
||||
.trim();
|
||||
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
|
||||
|
||||
formValue = formValue
|
||||
@@ -40,20 +46,11 @@
|
||||
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
|
||||
}
|
||||
|
||||
function computeImagePath() {
|
||||
let rootFolder =
|
||||
$: {
|
||||
const rootFolderBase =
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
? '/sprites/home'
|
||||
: 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home';
|
||||
|
||||
if (shiny) {
|
||||
rootFolder += '/shiny';
|
||||
}
|
||||
|
||||
if (isFemaleForm(form)) {
|
||||
rootFolder += '/female';
|
||||
}
|
||||
|
||||
? '/sprites-small/home'
|
||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
|
||||
if (!spriteKey?.trim()) {
|
||||
console.warn('Missing sprite key for pokemon entry', {
|
||||
@@ -63,14 +60,47 @@
|
||||
});
|
||||
}
|
||||
|
||||
return `${rootFolder}/${resolvedSpriteKey}.png`;
|
||||
let rootFolder = rootFolderBase;
|
||||
if (shiny) {
|
||||
rootFolder += '/shiny';
|
||||
}
|
||||
if (isFemaleForm(form)) {
|
||||
rootFolder += '/female';
|
||||
}
|
||||
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
|
||||
}
|
||||
|
||||
$: imagePath = computeImagePath();
|
||||
$: if (loadingStrategy !== 'inView') {
|
||||
isInView = true;
|
||||
}
|
||||
|
||||
function handleInView(inView: boolean) {
|
||||
if (inView) {
|
||||
isInView = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if imagePath}
|
||||
<img src={imagePath} alt="sprite" />
|
||||
<span
|
||||
use:inView={{
|
||||
enabled: loadingStrategy === 'inView',
|
||||
once: true,
|
||||
rootMargin: '200px 0px',
|
||||
onChange: handleInView
|
||||
}}
|
||||
>
|
||||
{#if loadingStrategy === 'inView' && !isInView}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<img
|
||||
src={imagePath}
|
||||
alt="sprite"
|
||||
loading={loadingStrategy === 'lazy' ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
precacheAndRoute,
|
||||
precache
|
||||
} from 'workbox-precaching';
|
||||
// import { NavigationRoute, registerRoute } from 'workbox-routing';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst } from 'workbox-strategies';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
|
||||
@@ -29,6 +32,21 @@ if (Array.isArray(manifest)) {
|
||||
// clean old assets
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'image',
|
||||
new CacheFirst({
|
||||
cacheName: 'image-cache',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 3000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
purgeOnQuotaError: true
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// let allowlist: undefined | RegExp[];
|
||||
// if (import.meta.env.DEV) allowlist = [/^\/$/];
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |