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.