diff --git a/.gitignore b/.gitignore index fc752b1..bf17aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ npm-cache/ /lhci-results/ /.lighthouseci/ -# Ignore other image files (but not visual regression snapshots) +# Ignore other image files (but not visual regression snapshots or favicons) *.png *.jpg *.jpeg @@ -39,6 +39,11 @@ npm-cache/ *.avi *.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) !tests/e2e/visual-regression.spec.ts-snapshots/ !tests/e2e/visual-regression.spec.ts-snapshots/*.png diff --git a/app/layout.tsx b/app/layout.tsx index dacd659..9fa8c7f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -60,7 +60,27 @@ export const metadata: Metadata = { }, metadataBase: new URL("https://communityrule.com"), 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: { canonical: "/", diff --git a/docs/guides/static-assets.md b/docs/guides/static-assets.md index a3d611c..0fcea2d 100644 --- a/docs/guides/static-assets.md +++ b/docs/guides/static-assets.md @@ -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 `{slug}-vertical.svg`, `-horizontal.svg`, `-section.svg`, `-tag.svg`. - **Favicon** reuses `assets/logos/community-rule.svg` (`ASSETS.LOGO` in - `app/layout.tsx` metadata). Do not place `favicon.ico` or other static - binaries under `app/` — keep `app/` for routes, layouts, and styles only + `app/layout.tsx` metadata) plus generated root binaries for Safari/iOS: + `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`). ## PNG files and `.gitignore` diff --git a/package-lock.json b/package-lock.json index aec4d47..4089b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "community-rule", "version": "0.1.0", "hasInstallScript": true, + "license": "GPL-3.0-or-later", "dependencies": { "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", @@ -53,6 +54,7 @@ "knip": "^5.50.0", "msw": "^2.10.5", "playwright": "^1.55.0", + "png-to-ico": "^3.0.1", "postcss": "^8.5.6", "prettier": "^3.7.4", "prisma": "^6.19.0", @@ -19175,6 +19177,51 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", diff --git a/package.json b/package.json index 4a843b2..a31f15f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "bundle:analyze": "node scripts/bundle-analyzer.js", "db:deploy": "prisma migrate deploy", "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": { "@mdx-js/loader": "^3.1.1", @@ -89,9 +90,10 @@ "globals": "^17.1.0", "jest-axe": "^10.0.0", "jsdom": "^26.1.0", - "msw": "^2.10.5", "knip": "^5.50.0", + "msw": "^2.10.5", "playwright": "^1.55.0", + "png-to-ico": "^3.0.1", "postcss": "^8.5.6", "prettier": "^3.7.4", "prisma": "^6.19.0", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..7d50f63 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..b1dcb03 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..1cc4463 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..08f3640 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/generate-favicons.mjs b/scripts/generate-favicons.mjs new file mode 100644 index 0000000..40df89c --- /dev/null +++ b/scripts/generate-favicons.mjs @@ -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); +}); diff --git a/tests/unit/Layout.test.jsx b/tests/unit/Layout.test.jsx index 84b18c8..8a5f4a5 100644 --- a/tests/unit/Layout.test.jsx +++ b/tests/unit/Layout.test.jsx @@ -1,5 +1,5 @@ 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 AppLayout from "../../app/(app)/layout"; @@ -84,6 +84,36 @@ describe("RootLayout", () => { ); 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)", () => {