Skip to content

Commit 1787adf

Browse files
authored
feat(web): generate manifest.json images (#3797)
- closes #1356
1 parent 1a0dcd1 commit 1787adf

32 files changed

Lines changed: 234 additions & 12 deletions

‎docs/tutorials/03-styling.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ Every component reads the theme through CSS variables that start with `--hb-`, s
3434

3535
The header shows `packages/web/public/images/logo.svg`, made for light backgrounds. Add `logo-dark.svg` next to it for the dark scheme. Without it, the header shows `shortName` from `site.json` as text in the dark scheme.
3636

37+
## App icon
38+
39+
The icon of the installed app, and of the site on phone home screens, is `packages/web/public/images/icon.png`. The build resizes it to every size the web app manifest and browsers need, from 16 to 512 pixels, including the favicon. Replace it with a square PNG of at least 512×512 pixels, or point `icon` in `site.json` at a square SVG in `packages/web/public`. An icon that isn't square sits centered on a transparent square.
40+
3741
## Colors
3842

3943
Override any theme color in `theme.colors`, as a hex color. Dark colors are in `theme.darkColors`:

‎packages/web/build/build.smoke.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,19 @@ describe('a production build of a minimal site', () => {
115115
});
116116
});
117117

118+
it("makes every icon in the manifest and the page's favicons from the site's icon", () => {
119+
const { icons } = JSON.parse(build.read('manifest.json')) as { icons: { src: string }[] };
120+
const favicons = [
121+
...build.read('index.html').matchAll(/<link href="([^"]+)" rel="icon" sizes="(\d+)x\2"/g),
122+
].map(([, href]) => href ?? '');
123+
124+
expect(icons.length).toBeGreaterThan(0);
125+
expect(favicons).toEqual(['images/manifest/icon-16.png', 'images/manifest/icon-32.png']);
126+
for (const src of [...icons.map(({ src }) => src), ...favicons]) {
127+
expect(build.read(src).slice(1, 4), src).toBe('PNG');
128+
}
129+
});
130+
118131
it('lists the home page in the sitemap, and links to it from robots.txt', () => {
119132
const locs = (file: string) =>
120133
[...build.read(file).matchAll(/<loc>([^<]*)<\/loc>/g)].map(([, loc]) => loc);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Types for `virtual:hoverboard/icon`, which vite-plugin-site.ts serves. tsconfig.json maps it here.
2+
3+
/** The absolute path of `icon` from site.json. */
4+
export declare const iconFile: string;

‎packages/web/build/icons.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import sharp from 'sharp';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import { iconErrors, pngSize } from './icons';
7+
8+
const png = (width: number, height = width) =>
9+
sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
10+
.png()
11+
.toBuffer();
12+
13+
describe('pngSize', () => {
14+
it("reads a PNG's width and height", async () => {
15+
expect(pngSize(await png(640, 480))).toEqual({ width: 640, height: 480 });
16+
});
17+
18+
it('is undefined for other files', () => {
19+
expect(pngSize(Buffer.from('<svg></svg>'))).toBeUndefined();
20+
expect(pngSize(Buffer.from('\u0089PNG'))).toBeUndefined();
21+
});
22+
});
23+
24+
describe('iconErrors', () => {
25+
let publicDir: string;
26+
27+
beforeEach(() => {
28+
publicDir = mkdtempSync(join(tmpdir(), 'hoverboard-icon-'));
29+
});
30+
31+
afterEach(() => rmSync(publicDir, { recursive: true, force: true }));
32+
33+
it('accepts a PNG of at least 512×512 pixels, and any SVG', async () => {
34+
writeFileSync(join(publicDir, 'icon.png'), await png(1024));
35+
writeFileSync(join(publicDir, 'icon.svg'), '<svg viewBox="0 0 24 24"></svg>');
36+
37+
expect(iconErrors('icon.png', publicDir)).toEqual([]);
38+
expect(iconErrors('icon.svg', publicDir)).toEqual([]);
39+
});
40+
41+
it('rejects an icon that is not in packages/web/public', () => {
42+
expect(iconErrors('images/missing.png', publicDir)).toEqual([
43+
'site.json/icon: "images/missing.png" is not in packages/web/public',
44+
]);
45+
});
46+
47+
it('rejects a PNG that is too small for the largest icon', async () => {
48+
writeFileSync(join(publicDir, 'icon.png'), await png(512, 256));
49+
50+
expect(iconErrors('icon.png', publicDir)).toEqual([
51+
'site.json/icon: "icon.png" is 512×256 pixels, and needs to be at least 512×512',
52+
]);
53+
});
54+
55+
it('rejects a .png file that is not a PNG', () => {
56+
writeFileSync(join(publicDir, 'icon.png'), 'not a png');
57+
58+
expect(iconErrors('icon.png', publicDir)).toEqual([
59+
'site.json/icon: "icon.png" is not a PNG file',
60+
]);
61+
});
62+
63+
it("accepts the repository's icon", () => {
64+
expect(iconErrors('images/icon.png', join(import.meta.dirname, '../public'))).toEqual([]);
65+
});
66+
});

‎packages/web/build/icons.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import fs from 'node:fs';
2+
import { extname, join } from 'node:path';
3+
import { ICON_SIZES } from '../src/data/icons';
4+
5+
const MIN_SIZE = Math.max(...ICON_SIZES);
6+
7+
/** A PNG's size, from its header. */
8+
export const pngSize = (data: Buffer): { width: number; height: number } | undefined =>
9+
data.toString('latin1', 1, 4) === 'PNG' && data.length >= 24
10+
? { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
11+
: undefined;
12+
13+
/** The site icon must be in packages/web/public, and a PNG big enough for the largest icon. */
14+
export const iconErrors = (icon: string, publicDir: string): string[] => {
15+
const file = join(publicDir, icon);
16+
if (!fs.existsSync(file)) return [`site.json/icon: "${icon}" is not in packages/web/public`];
17+
if (extname(icon).toLowerCase() !== '.png') return [];
18+
const size = pngSize(fs.readFileSync(file));
19+
if (!size) return [`site.json/icon: "${icon}" is not a PNG file`];
20+
return size.width < MIN_SIZE || size.height < MIN_SIZE
21+
? [
22+
`site.json/icon: "${icon}" is ${size.width}×${size.height} pixels, and needs to be at least ${MIN_SIZE}×${MIN_SIZE}`,
23+
]
24+
: [];
25+
};

‎packages/web/build/resolve-config.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,18 @@ describe('config validation', () => {
241241
]);
242242
});
243243

244+
it('rejects an icon that is not a PNG or SVG in packages/web/public', () => {
245+
expect(errorsFor({ site: { icon: 'images/missing.png' } })).toEqual([
246+
'site.json/icon: "images/missing.png" is not in packages/web/public',
247+
]);
248+
expect(errorsFor({ site: { icon: 'https://example.com/icon.png' } })).toEqual([
249+
expect.stringMatching(/^site\.json\/icon: must match pattern/),
250+
]);
251+
expect(errorsFor({ site: { icon: 'images/social-share.jpg' } })).toEqual([
252+
expect.stringMatching(/^site\.json\/icon: must match pattern/),
253+
]);
254+
});
255+
244256
it('rejects a hero illustration that is not an SVG in packages/web/public', () => {
245257
expect(
246258
errorsFor({ site: { heroSettings: { home: { illustration: '/images/missing.svg' } } } }),

‎packages/web/build/resolve-config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '../src/config/features';
1010
import { deepMerge, isPlainObject } from '../src/config/merge';
1111
import { fontProblems } from './fonts';
12+
import { iconErrors } from './icons';
1213
import { sanitizeSvg } from './svg';
1314
import {
1415
type ResolvedTheme,
@@ -205,6 +206,7 @@ const crossFileErrors = (site: Site, resources: Resources, paths: ConfigPaths):
205206
errors.push(`${path}: "${image}" is not in packages/web/public`);
206207
}
207208
}
209+
errors.push(...iconErrors(site.icon, paths.public));
208210
// The page inlines it, so it can be drawn in the theme's colors.
209211
const illustration = site.heroSettings?.home?.illustration;
210212
if (illustration && (isUrl(illustration) || !fs.existsSync(join(paths.public, illustration)))) {

‎packages/web/build/vite-plugin-site.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHash } from 'node:crypto';
22
import fs from 'node:fs';
3-
import { join } from 'node:path';
3+
import { join, resolve } from 'node:path';
44
import n from 'nunjucks';
55
import type { Plugin, PluginOption } from 'vite';
66
import copy from 'rollup-plugin-copy';
@@ -29,6 +29,9 @@ const RESOLVED_FONTS_MODULE = `\0${FONTS_MODULE}`;
2929
// The fonts, colors and logo of the share images. Only server code imports it.
3030
export const SOCIAL_IMAGES_MODULE = 'virtual:hoverboard/social-images';
3131
const RESOLVED_SOCIAL_IMAGES_MODULE = `\0${SOCIAL_IMAGES_MODULE}`;
32+
// The app icon's file, which the icon pages resize. Only server code imports it.
33+
export const ICON_MODULE = 'virtual:hoverboard/icon';
34+
const RESOLVED_ICON_MODULE = `\0${ICON_MODULE}`;
3235
// One module per locale, `virtual:hoverboard/content/<locale>`, so each is its own chunk.
3336
export const CONTENT_MODULE = 'virtual:hoverboard/content/';
3437
const RESOLVED_CONTENT_MODULE = `\0${CONTENT_MODULE}`;
@@ -68,6 +71,7 @@ export const siteModule = (
6871
if (id === LAYOUT_MODULE) return RESOLVED_LAYOUT_MODULE;
6972
if (id === FONTS_MODULE) return RESOLVED_FONTS_MODULE;
7073
if (id === SOCIAL_IMAGES_MODULE) return RESOLVED_SOCIAL_IMAGES_MODULE;
74+
if (id === ICON_MODULE) return RESOLVED_ICON_MODULE;
7175
const locale = id.startsWith(CONTENT_MODULE) ? id.slice(CONTENT_MODULE.length) : undefined;
7276
return locale && Object.hasOwn(config.contentTranslations, locale)
7377
? `${RESOLVED_CONTENT_MODULE}${locale}`
@@ -111,6 +115,9 @@ export const siteModule = (
111115
.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)};\n`)
112116
.join('');
113117
}
118+
if (id === RESOLVED_ICON_MODULE) {
119+
return `export const iconFile = ${JSON.stringify(resolve(publicDir, site.icon))};\n`;
120+
}
114121
if (id.startsWith(RESOLVED_CONTENT_MODULE)) {
115122
const locale = id.slice(RESOLVED_CONTENT_MODULE.length);
116123
return `export default ${JSON.stringify(contentTranslations[locale])};\n`;

‎packages/web/defaults/site.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"basepath": "/",
33
"image": "images/social-share.jpg",
4+
"icon": "images/icon.png",
45
"schedule": {
56
"published": true
67
},
-3.55 KB
Binary file not shown.

0 commit comments

Comments
 (0)