Serve the MealQuest favicon instead of the browser default (Maestro 7c778111) (#213)
CI / ci (push) Successful in 10m15s
CI / ci (push) Successful in 10m15s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #213.
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#5a358c" />
|
<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" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>MealQuest</title>
|
<title>MealQuest</title>
|
||||||
</head>
|
</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
|
// Command generate-icons writes the PWA icons and favicons in web/public from
|
||||||
// MealQuest logo. Run it from the repository root:
|
// the MealQuest logo. Run it from the repository root:
|
||||||
//
|
//
|
||||||
// go run ./web/scripts/generate-icons
|
// go run ./web/scripts/generate-icons
|
||||||
//
|
//
|
||||||
@@ -9,10 +9,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -27,6 +30,9 @@ const (
|
|||||||
maskableScale = 0.66
|
maskableScale = 0.66
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// faviconSizes are the images packed into favicon.ico.
|
||||||
|
var faviconSizes = []int{16, 32, 48}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "generate-icons:", err)
|
fmt.Fprintln(os.Stderr, "generate-icons:", err)
|
||||||
@@ -49,6 +55,8 @@ func run() error {
|
|||||||
{"icons/icon-192.png", resized(logo, 192)},
|
{"icons/icon-192.png", resized(logo, 192)},
|
||||||
{"icons/icon-512.png", resized(logo, 512)},
|
{"icons/icon-512.png", resized(logo, 512)},
|
||||||
{"icons/icon-maskable-512.png", maskable(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)},
|
{"apple-touch-icon.png", resized(logo, 180)},
|
||||||
}
|
}
|
||||||
for _, out := range outputs {
|
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())
|
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
|
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
|
// load decodes the logo. The file is a PNG despite its .jpg name, so the
|
||||||
// format is sniffed rather than taken from the extension.
|
// format is sniffed rather than taken from the extension.
|
||||||
func load(path string) (image.Image, error) {
|
func load(path string) (image.Image, error) {
|
||||||
@@ -84,14 +136,18 @@ func save(path string, img image.Image) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
if err := encode(f, img); err != nil {
|
||||||
if err := enc.Encode(f, img); err != nil {
|
|
||||||
f.Close()
|
f.Close()
|
||||||
return fmt.Errorf("encode %s: %w", path, err)
|
return fmt.Errorf("encode %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return f.Close()
|
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 {
|
func resized(logo image.Image, size int) *image.RGBA {
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
|
draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil)
|
||||||
|
|||||||
+43
-3
@@ -1,7 +1,8 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
// Builds the app with the production config and serves the result, so the
|
// 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,
|
// 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 { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
||||||
import tokens from './styles/tokens.css?raw'
|
import tokens from './styles/tokens.css?raw'
|
||||||
@@ -12,9 +13,13 @@ const icons = [
|
|||||||
{ path: '/icons/icon-192.png', size: 192 },
|
{ path: '/icons/icon-192.png', size: 192 },
|
||||||
{ path: '/icons/icon-512.png', size: 512 },
|
{ path: '/icons/icon-512.png', size: 512 },
|
||||||
{ path: '/icons/icon-maskable-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 },
|
{ path: '/apple-touch-icon.png', size: 180 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const faviconSizes = [16, 32, 48]
|
||||||
|
|
||||||
function token(name: string): string {
|
function token(name: string): string {
|
||||||
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
||||||
if (!value) throw new Error(`${name} is not defined in tokens.css`)
|
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) }
|
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', () => {
|
describe('production build', () => {
|
||||||
let server: PreviewServer
|
let server: PreviewServer
|
||||||
let origin: string
|
let origin: string
|
||||||
@@ -94,13 +111,25 @@ describe('production build', () => {
|
|||||||
expect(pngSize(bytes)).toEqual({ width: size, height: size })
|
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 () => {
|
it('emits sw.js with the precache list injected', async () => {
|
||||||
const sw = await (await get('/sw.js')).text()
|
const sw = await (await get('/sw.js')).text()
|
||||||
expect(sw).not.toContain('self.__WB_MANIFEST')
|
expect(sw).not.toContain('self.__WB_MANIFEST')
|
||||||
expect(sw).toContain('"url":"index.html"')
|
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 () => {
|
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" />')
|
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 () => {
|
it('registers /sw.js at scope / from index.html', async () => {
|
||||||
const html = await (await get('/')).text()
|
const html = await (await get('/')).text()
|
||||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
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',
|
srcDir: 'src',
|
||||||
filename: 'sw.ts',
|
filename: 'sw.ts',
|
||||||
injectRegister: 'script',
|
injectRegister: 'script',
|
||||||
// Precache the shell, JS, CSS and the self-hosted fonts.
|
// Precache the shell, JS, CSS, the self-hosted fonts and every icon
|
||||||
injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] },
|
// 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
|
// The web manifest lives in manifest.json: its theme and background
|
||||||
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
||||||
// and hex colours may not be written in TypeScript.
|
// and hex colours may not be written in TypeScript.
|
||||||
|
|||||||
Reference in New Issue
Block a user