Serve the MealQuest favicon instead of the browser default (Maestro 7c778111)
index.html linked only the apple-touch-icon, so browsers fell back to a default favicon. generate-icons now also writes favicon.ico (16/32/48 PNG-in-ICO) and 16/32 px PNG favicons from the v1 logo; index.html links them, and the service worker precaches every icon and favicon so the offline shell keeps them (the glob covers the manifest icons, so the plugin no longer injects them a second time). pwa.test.ts asserts the build ships the favicons at the right sizes, links them from index.html, and precaches each icon exactly once. Task: 7c778111-c615-4437-a3f6-0e36bcb97e63 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5a358c" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 785 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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)
|
||||
|
||||
+43
-3
@@ -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('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
|
||||
})
|
||||
|
||||
it('links the favicons from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />')
|
||||
expect(html).toContain(
|
||||
'<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />',
|
||||
)
|
||||
expect(html).toContain(
|
||||
'<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />',
|
||||
)
|
||||
})
|
||||
|
||||
it('registers /sw.js at scope / from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
||||
|
||||
+5
-2
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user