diff --git a/web/index.html b/web/index.html index b6b78f5..958c97a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,9 @@ + + + MealQuest diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..08f273b Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/icons/favicon-16.png b/web/public/icons/favicon-16.png new file mode 100644 index 0000000..7e14a13 Binary files /dev/null and b/web/public/icons/favicon-16.png differ diff --git a/web/public/icons/favicon-32.png b/web/public/icons/favicon-32.png new file mode 100644 index 0000000..6e0b0d7 Binary files /dev/null and b/web/public/icons/favicon-32.png differ diff --git a/web/scripts/generate-icons/main.go b/web/scripts/generate-icons/main.go index 9734c33..3f2454e 100644 --- a/web/scripts/generate-icons/main.go +++ b/web/scripts/generate-icons/main.go @@ -1,5 +1,5 @@ -// Command generate-icons writes the PWA icons in web/public from the -// MealQuest logo. Run it from the repository root: +// Command generate-icons writes the PWA icons and favicons in web/public from +// the MealQuest logo. Run it from the repository root: // // go run ./web/scripts/generate-icons // @@ -9,10 +9,13 @@ package main import ( + "bytes" + "encoding/binary" "fmt" "image" "image/color" "image/png" + "io" "math" "os" "path/filepath" @@ -27,6 +30,9 @@ const ( maskableScale = 0.66 ) +// faviconSizes are the images packed into favicon.ico. +var faviconSizes = []int{16, 32, 48} + func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "generate-icons:", err) @@ -49,6 +55,8 @@ func run() error { {"icons/icon-192.png", resized(logo, 192)}, {"icons/icon-512.png", resized(logo, 512)}, {"icons/icon-maskable-512.png", maskable(logo, 512)}, + {"icons/favicon-16.png", resized(logo, 16)}, + {"icons/favicon-32.png", resized(logo, 32)}, {"apple-touch-icon.png", resized(logo, 180)}, } for _, out := range outputs { @@ -58,9 +66,53 @@ func run() error { } fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy()) } + ico, err := favicon(logo) + if err != nil { + return err + } + path := filepath.Join(public, "favicon.ico") + if err := os.WriteFile(path, ico, 0o644); err != nil { + return err + } + fmt.Printf("wrote %s (%v)\n", path, faviconSizes) return nil } +// favicon is an ICO file holding the logo at each of faviconSizes as an +// embedded PNG: a 6-byte header, a 16-byte directory entry per image, then +// the images. +func favicon(logo image.Image) ([]byte, error) { + var images [][]byte + for _, size := range faviconSizes { + var buf bytes.Buffer + if err := encode(&buf, resized(logo, size)); err != nil { + return nil, fmt.Errorf("encode favicon %dx%d: %w", size, size, err) + } + images = append(images, buf.Bytes()) + } + var out bytes.Buffer + le := binary.LittleEndian + out.Write(le.AppendUint16(nil, 0)) // reserved + out.Write(le.AppendUint16(nil, 1)) // type: icon + out.Write(le.AppendUint16(nil, uint16(len(images)))) // image count + offset := 6 + 16*len(images) + for i, size := range faviconSizes { + out.WriteByte(byte(size)) // width + out.WriteByte(byte(size)) // height + out.WriteByte(0) // palette size: none + out.WriteByte(0) // reserved + out.Write(le.AppendUint16(nil, 1)) // colour planes + out.Write(le.AppendUint16(nil, 32)) // bits per pixel + out.Write(le.AppendUint32(nil, uint32(len(images[i])))) + out.Write(le.AppendUint32(nil, uint32(offset))) + offset += len(images[i]) + } + for _, img := range images { + out.Write(img) + } + return out.Bytes(), nil +} + // load decodes the logo. The file is a PNG despite its .jpg name, so the // format is sniffed rather than taken from the extension. func load(path string) (image.Image, error) { @@ -84,14 +136,18 @@ func save(path string, img image.Image) error { if err != nil { return err } - enc := png.Encoder{CompressionLevel: png.BestCompression} - if err := enc.Encode(f, img); err != nil { + if err := encode(f, img); err != nil { f.Close() return fmt.Errorf("encode %s: %w", path, err) } return f.Close() } +func encode(w io.Writer, img image.Image) error { + enc := png.Encoder{CompressionLevel: png.BestCompression} + return enc.Encode(w, img) +} + func resized(logo image.Image, size int) *image.RGBA { dst := image.NewRGBA(image.Rect(0, 0, size, size)) draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil) diff --git a/web/src/pwa.test.ts b/web/src/pwa.test.ts index dda3d6a..07ac166 100644 --- a/web/src/pwa.test.ts +++ b/web/src/pwa.test.ts @@ -1,7 +1,8 @@ // @vitest-environment node // Builds the app with the production config and serves the result, so the // assertions cover what ships in dist: the web manifest, the service worker, -// every icon, and the service worker registration that only builds include. +// every icon and favicon, and the service worker registration that only builds +// include. import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite' import tokens from './styles/tokens.css?raw' @@ -12,9 +13,13 @@ const icons = [ { path: '/icons/icon-192.png', size: 192 }, { path: '/icons/icon-512.png', size: 512 }, { path: '/icons/icon-maskable-512.png', size: 512 }, + { path: '/icons/favicon-16.png', size: 16 }, + { path: '/icons/favicon-32.png', size: 32 }, { path: '/apple-touch-icon.png', size: 180 }, ] +const faviconSizes = [16, 32, 48] + function token(name: string): string { const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1] if (!value) throw new Error(`${name} is not defined in tokens.css`) @@ -38,6 +43,18 @@ function pngSize(bytes: Uint8Array): { width: number; height: number } { return { width: view.getUint32(16), height: view.getUint32(20) } } +// The images in an ICO file: a 6-byte header (reserved, type 1, count), then +// a 16-byte directory entry per image giving its byte length and offset. +function icoImages(bytes: Uint8Array): Uint8Array[] { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + expect([view.getUint16(0, true), view.getUint16(2, true)]).toEqual([0, 1]) + return Array.from({ length: view.getUint16(4, true) }, (_, i) => { + const entry = 6 + 16 * i + const offset = view.getUint32(entry + 12, true) + return bytes.subarray(offset, offset + view.getUint32(entry + 8, true)) + }) +} + describe('production build', () => { let server: PreviewServer let origin: string @@ -94,13 +111,25 @@ describe('production build', () => { expect(pngSize(bytes)).toEqual({ width: size, height: size }) }) + it('ships /favicon.ico holding the logo as PNGs at every favicon size', async () => { + const bytes = new Uint8Array(await (await get('/favicon.ico')).arrayBuffer()) + expect(icoImages(bytes).map((png) => pngSize(png))).toEqual( + faviconSizes.map((size) => ({ width: size, height: size })), + ) + }) + it('emits sw.js with the precache list injected', async () => { const sw = await (await get('/sw.js')).text() expect(sw).not.toContain('self.__WB_MANIFEST') expect(sw).toContain('"url":"index.html"') - for (const { path } of icons.slice(0, 3)) { - expect(sw).toContain(`"url":"${path.slice(1)}"`) + }) + + it('precaches every icon and favicon so the offline shell keeps them', async () => { + const urls = precacheEntries(await (await get('/sw.js')).text()).map((entry) => entry.url) + for (const path of [...icons.map((icon) => icon.path), '/favicon.ico']) { + expect(urls).toContain(path.slice(1)) } + expect(new Set(urls).size, 'precache URLs are unique').toBe(urls.length) }) it('precaches the shell, JS, CSS and every self-hosted font', async () => { @@ -129,6 +158,17 @@ describe('production build', () => { expect(html).toContain('') }) + it('links the favicons from index.html', async () => { + const html = await (await get('/')).text() + expect(html).toContain('') + expect(html).toContain( + '', + ) + expect(html).toContain( + '', + ) + }) + it('registers /sw.js at scope / from index.html', async () => { const html = await (await get('/')).text() expect(html).toContain('') diff --git a/web/vite.config.ts b/web/vite.config.ts index 48a76c4..8d11f62 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -17,8 +17,11 @@ export default defineConfig({ srcDir: 'src', filename: 'sw.ts', injectRegister: 'script', - // Precache the shell, JS, CSS and the self-hosted fonts. - injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] }, + // Precache the shell, JS, CSS, the self-hosted fonts and every icon + // and favicon. The glob already covers the manifest icons, so the + // plugin does not add them a second time. + injectManifest: { globPatterns: ['**/*.{html,js,css,woff2,png,ico}'] }, + includeManifestIcons: false, // The web manifest lives in manifest.json: its theme and background // colours are --color-accent and --color-bg from src/styles/tokens.css, // and hex colours may not be written in TypeScript.