Fix cross-browser favicons

This commit is contained in:
adilallo
2026-05-24 17:06:07 -06:00
parent daa5e533d6
commit e7c17b8651
11 changed files with 185 additions and 7 deletions
+6 -1
View File
@@ -28,7 +28,7 @@ npm-cache/
/lhci-results/ /lhci-results/
/.lighthouseci/ /.lighthouseci/
# Ignore other image files (but not visual regression snapshots) # Ignore other image files (but not visual regression snapshots or favicons)
*.png *.png
*.jpg *.jpg
*.jpeg *.jpeg
@@ -39,6 +39,11 @@ npm-cache/
*.avi *.avi
*.mkv *.mkv
# Root favicons (generated via `npm run generate:favicons`)
!public/favicon-16x16.png
!public/favicon-32x32.png
!public/apple-touch-icon.png
# Visual regression snapshots (allow these) # Visual regression snapshots (allow these)
!tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/
!tests/e2e/visual-regression.spec.ts-snapshots/*.png !tests/e2e/visual-regression.spec.ts-snapshots/*.png
+21 -1
View File
@@ -60,7 +60,27 @@ export const metadata: Metadata = {
}, },
metadataBase: new URL("https://communityrule.com"), metadataBase: new URL("https://communityrule.com"),
icons: { icons: {
icon: [{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }], icon: [
{ url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "any" },
{
url: "/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
},
{
url: "/favicon-16x16.png",
sizes: "16x16",
type: "image/png",
},
],
apple: [
{
url: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
}, },
alternates: { alternates: {
canonical: "/", canonical: "/",
+5 -2
View File
@@ -41,8 +41,11 @@ Do not duplicate the same glyph in both places unless migrating between systems.
- **Blog art** stays under `public/content/blog/` with - **Blog art** stays under `public/content/blog/` with
`{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`. `{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`.
- **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in - **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in
`app/layout.tsx` metadata). Do not place `favicon.ico` or other static `app/layout.tsx` metadata) plus generated root binaries for Safari/iOS:
binaries under `app/` — keep `app/` for routes, layouts, and styles only `public/favicon.ico`, `favicon-16x16.png`, `favicon-32x32.png`, and
`apple-touch-icon.png`. Regenerate after logo changes with
`npm run generate:favicons`. Do not place other static binaries under
`app/` — keep `app/` for routes, layouts, and styles only
(`globals.css`, `tailwind.css`). (`globals.css`, `tailwind.css`).
## PNG files and `.gitignore` ## PNG files and `.gitignore`
+47
View File
@@ -8,6 +8,7 @@
"name": "community-rule", "name": "community-rule",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
@@ -53,6 +54,7 @@
"knip": "^5.50.0", "knip": "^5.50.0",
"msw": "^2.10.5", "msw": "^2.10.5",
"playwright": "^1.55.0", "playwright": "^1.55.0",
"png-to-ico": "^3.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prisma": "^6.19.0", "prisma": "^6.19.0",
@@ -19175,6 +19177,51 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/png-to-ico": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.1.tgz",
"integrity": "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^22.10.3",
"minimist": "^1.2.8",
"pngjs": "^7.0.0"
},
"bin": {
"png-to-ico": "bin/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/png-to-ico/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/png-to-ico/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/po-parser": { "node_modules/po-parser": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
+4 -2
View File
@@ -45,7 +45,8 @@
"bundle:analyze": "node scripts/bundle-analyzer.js", "bundle:analyze": "node scripts/bundle-analyzer.js",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"migrate:smoke": "./scripts/migrate-smoke-local.sh", "migrate:smoke": "./scripts/migrate-smoke-local.sh",
"docker:release": "./scripts/docker-release.sh" "docker:release": "./scripts/docker-release.sh",
"generate:favicons": "node scripts/generate-favicons.mjs"
}, },
"dependencies": { "dependencies": {
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
@@ -89,9 +90,10 @@
"globals": "^17.1.0", "globals": "^17.1.0",
"jest-axe": "^10.0.0", "jest-axe": "^10.0.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"msw": "^2.10.5",
"knip": "^5.50.0", "knip": "^5.50.0",
"msw": "^2.10.5",
"playwright": "^1.55.0", "playwright": "^1.55.0",
"png-to-ico": "^3.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prisma": "^6.19.0", "prisma": "^6.19.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Regenerate root favicon binaries from `public/assets/logos/community-rule.svg`.
* Safari and iOS need PNG/ICO fallbacks; SVG alone shows a letter fallback in Safari.
*
* Run: npm run generate:favicons
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import pngToIco from "png-to-ico";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUBLIC = path.join(ROOT, "public");
const SVG_PATH = path.join(PUBLIC, "assets/logos/community-rule.svg");
const LOGO_FILL = "#FFFDD2";
const MARK_ON_LIGHT = "#000000";
async function readLogoSvg() {
return fs.readFile(SVG_PATH, "utf8");
}
async function markPng(svg, size, fill) {
const tinted = svg.replaceAll(LOGO_FILL, fill);
return sharp(Buffer.from(tinted))
.resize(size, size, { fit: "contain" })
.png()
.toBuffer();
}
async function creamMarkOnBlack(svg, size) {
const logoSize = Math.round(size * 0.75);
const logo = await markPng(svg, logoSize, LOGO_FILL);
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
})
.composite([{ input: logo, gravity: "center" }])
.png()
.toBuffer();
}
async function main() {
const svg = await readLogoSvg();
const png16 = await markPng(svg, 16, MARK_ON_LIGHT);
const png32 = await markPng(svg, 32, MARK_ON_LIGHT);
const appleTouch = await creamMarkOnBlack(svg, 180);
const faviconIco = await pngToIco([png16, png32]);
await Promise.all([
fs.writeFile(path.join(PUBLIC, "favicon-16x16.png"), png16),
fs.writeFile(path.join(PUBLIC, "favicon-32x32.png"), png32),
fs.writeFile(path.join(PUBLIC, "apple-touch-icon.png"), appleTouch),
fs.writeFile(path.join(PUBLIC, "favicon.ico"), faviconIco),
]);
console.log("Wrote public/favicon.ico");
console.log("Wrote public/favicon-16x16.png");
console.log("Wrote public/favicon-32x32.png");
console.log("Wrote public/apple-touch-icon.png");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+31 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, vi } from "vitest"; import { describe, test, expect, vi } from "vitest";
import RootLayout from "../../app/layout"; import RootLayout, { metadata } from "../../app/layout";
import MarketingLayout from "../../app/(marketing)/layout"; import MarketingLayout from "../../app/(marketing)/layout";
import AppLayout from "../../app/(app)/layout"; import AppLayout from "../../app/(app)/layout";
@@ -84,6 +84,36 @@ describe("RootLayout", () => {
); );
expect(childText).toBeTruthy(); expect(childText).toBeTruthy();
}); });
test("declares svg, ico, png, and apple-touch icons for cross-browser support", () => {
const icons = metadata.icons;
expect(icons?.icon).toEqual(
expect.arrayContaining([
expect.objectContaining({
url: "/assets/logos/community-rule.svg",
type: "image/svg+xml",
}),
expect.objectContaining({ url: "/favicon.ico", sizes: "any" }),
expect.objectContaining({
url: "/favicon-32x32.png",
sizes: "32x32",
type: "image/png",
}),
expect.objectContaining({
url: "/favicon-16x16.png",
sizes: "16x16",
type: "image/png",
}),
]),
);
expect(icons?.apple).toEqual([
{
url: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
]);
});
}); });
describe("Group layouts (chrome composition)", () => { describe("Group layouts (chrome composition)", () => {