diff --git a/.env.example b/.env.example index e26148a..e148709 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ # Site name shown in the browser tab, app header, OG meta tags, and emails. GERBEUR_SITE_NAME=gerbeur +# Emoji the favicon set (SVG, .ico, PNG 16–512, Apple touch icon) and the app +# header logo are generated/rendered from. Defaults to 🚚. Applied at runtime +# (server startup), so changing it only needs a restart — no rebuild. +GERBEUR_SITE_EMOJI=🚚 + # Port the API server listens on (the container's internal port). GERBEUR_PORT=8000 diff --git a/.gitignore b/.gitignore index 2859ca0..51d0ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,13 @@ dist dist-ssr *.local +# Generated from GERBEUR_SITE_EMOJI (see api/scripts/generate-icons.ts) +/public/favicon.svg +/public/favicon.ico +/public/favicon-*.png +/public/apple-touch-icon.png +/public/manifest.webmanifest + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/api/config.ts b/api/config.ts index 9ab7ed8..cd7e405 100644 --- a/api/config.ts +++ b/api/config.ts @@ -93,6 +93,15 @@ export const VALIDATION = { // SEO/OG export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; +// Emoji the favicon set + app header logo are generated/rendered from. +// Applied at runtime (server startup + index.html injection), so changing it +// only needs a restart — no rebuild. +export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; + +// Background color for generated icons and the manifest. Mirrors the +// hard-coded theme-color in index.html. +export const THEME_COLOR = "#111827"; + const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; export const ALLOWED_ORIGINS: string[] = Array.from( new Set([ diff --git a/api/db/migrate.ts b/api/db/migrate.ts index b0a6164..9509303 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -5,6 +5,7 @@ import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts"; import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts"; import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; import { up as up0006Categories } from "./migrations/0006_categories.ts"; +import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts"; interface Migration { name: string; @@ -21,6 +22,7 @@ const MIGRATIONS: Migration[] = [ { name: "0004_user_roles", up: up0004UserRoles }, { name: "0005_drop_is_admin", up: up0005DropIsAdmin }, { name: "0006_categories", up: up0006Categories }, + { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0007_password_reset_tokens.ts b/api/db/migrations/0007_password_reset_tokens.ts new file mode 100644 index 0000000..343de0c --- /dev/null +++ b/api/db/migrations/0007_password_reset_tokens.ts @@ -0,0 +1,23 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Idempotent: safe to run against a fresh db (already created from schema.sql +// with password_reset_tokens) or an existing one that predates the table. +export function up(db: DatabaseSync): void { + const tables = new Set( + (db.prepare( + `SELECT name FROM sqlite_master WHERE type = 'table';`, + ).all() as { name: string }[]).map((r) => r.name), + ); + + if (!tables.has("password_reset_tokens")) { + db.exec( + `CREATE TABLE password_reset_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );`, + ); + } +} diff --git a/api/lib/site-icons.ts b/api/lib/site-icons.ts new file mode 100644 index 0000000..c4ffec8 --- /dev/null +++ b/api/lib/site-icons.ts @@ -0,0 +1,161 @@ +// Generates the full favicon set + web manifest from a single emoji. +// +// Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without +// any system emoji font (which matters in the minimal Alpine runtime image). +// @resvg/resvg-wasm does the rasterization; the .ico is assembled by hand. +// +// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in +// prod, public/ in dev) — see that script for how the output dir is chosen. +import { initWasm, Resvg } from "@resvg/resvg-wasm"; + +// Pinned Twemoji release. +const TWEMOJI_VERSION = "16.0.1"; +const PNG_SIZES = [16, 32, 48, 64, 96, 128, 192, 256, 512]; +const APPLE_SIZE = 180; +const ICO_SIZES = [16, 32, 48]; + +/** + * Mirror Twemoji's filename convention: strip variation selectors (FE0F) unless + * the emoji is a ZWJ sequence, then join codepoints with "-". Also doubles as a + * stable cache-busting version string for the icon URLs. + */ +export function emojiToCodepoint(emoji: string): string { + const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, ""); + return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-"); +} + +let wasmReady: Promise | undefined; +function ensureWasm(): Promise { + if (!wasmReady) { + const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm")); + wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes)); + } + return wasmReady; +} + +// Cache the fetched glyph per emoji so repeated runs don't re-hit the network. +const glyphCache = new Map(); + +async function fetchGlyph(emoji: string): Promise { + const cp = emojiToCodepoint(emoji); + const cached = glyphCache.get(cp); + if (cached) return cached; + const url = + `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg/${cp}.svg`; + let res: Response; + try { + res = await fetch(url); + } catch (cause) { + throw new Error( + `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}`, + { cause }, + ); + } + if (!res.ok) { + throw new Error( + `Failed to fetch Twemoji glyph for "${emoji}" (${cp}) from ${url}: ${res.status} ${res.statusText}`, + ); + } + const svg = await res.text(); + glyphCache.set(cp, svg); + return svg; +} + +function renderPng(svg: string, size: number, background?: string): Uint8Array { + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background }); + return resvg.render().asPng(); +} + +// Pack PNG payloads into a (PNG-compressed) .ico container. +function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array { + const HEADER = 6; + const ENTRY = 16; + const offsetBase = HEADER + ENTRY * images.length; + const total = offsetBase + images.reduce((sum, i) => sum + i.png.length, 0); + const buf = new Uint8Array(total); + const dv = new DataView(buf.buffer); + dv.setUint16(2, 1, true); // type: icon + dv.setUint16(4, images.length, true); + let offset = offsetBase; + images.forEach((img, idx) => { + const e = HEADER + idx * ENTRY; + buf[e] = img.size >= 256 ? 0 : img.size; // width (0 means 256) + buf[e + 1] = img.size >= 256 ? 0 : img.size; // height + dv.setUint16(e + 4, 1, true); // color planes + dv.setUint16(e + 6, 32, true); // bits per pixel + dv.setUint32(e + 8, img.png.length, true); // size of image data + dv.setUint32(e + 12, offset, true); // offset of image data + buf.set(img.png, offset); + offset += img.png.length; + }); + return buf; +} + +function buildManifest(siteName: string, bgColor: string): string { + const manifest = { + name: siteName, + short_name: siteName, + start_url: "/", + display: "standalone", + background_color: bgColor, + theme_color: bgColor, + icons: [ + { src: "/favicon.svg", type: "image/svg+xml", sizes: "any" }, + { src: "/favicon-192x192.png", type: "image/png", sizes: "192x192" }, + { + src: "/favicon-512x512.png", + type: "image/png", + sizes: "512x512", + purpose: "any maskable", + }, + ], + share_target: { + action: "/", + method: "GET", + params: { url: "share_url", title: "share_title", text: "share_text" }, + }, + }; + return JSON.stringify(manifest, null, 2) + "\n"; +} + +export interface GenerateSiteIconsOptions { + /** Emoji to render the icons from. */ + emoji: string; + /** Directory to write the assets into (created if missing). */ + outDir: string; + /** Site name written into the manifest. */ + siteName: string; + /** Background color for the Apple touch icon and manifest (iOS ignores transparency). */ + bgColor: string; +} + +/** + * Generate `favicon.svg`, `favicon.ico`, `favicon-x.png` (16–512), + * `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`. + * Throws if the emoji glyph can't be fetched. + */ +export async function generateSiteIcons( + opts: GenerateSiteIconsOptions, +): Promise { + const { emoji, outDir, siteName, bgColor } = opts; + const svg = await fetchGlyph(emoji); + await ensureWasm(); + + await Deno.mkdir(outDir, { recursive: true }); + const out = (name: string) => `${outDir}/${name}`; + + await Deno.writeTextFile(out("favicon.svg"), svg); + + const pngs = new Map(); + for (const size of PNG_SIZES) pngs.set(size, renderPng(svg, size)); + for (const size of PNG_SIZES) { + await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!); + } + // Apple touch icon gets a solid background — iOS doesn't honor transparency. + await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor)); + await Deno.writeFile( + out("favicon.ico"), + buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))), + ); + await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor)); +} diff --git a/api/lib/static.ts b/api/lib/static.ts index 14c7e3d..1243a6e 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -1,5 +1,8 @@ import { Context, Next, send } from "@oak/oak"; -import { OG_SITE_NAME } from "../config.ts"; +import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { emojiToCodepoint } from "./site-icons.ts"; + +const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); async function serveIndexHtml( context: Context>, @@ -7,7 +10,10 @@ async function serveIndexHtml( ) { const filePath = `${root}/index.html`; const raw = await Deno.readTextFile(filePath); - const html = raw.replaceAll("__SITE_NAME__", OG_SITE_NAME); + const html = raw + .replaceAll("__SITE_NAME__", OG_SITE_NAME) + .replaceAll("__SITE_EMOJI__", SITE_EMOJI) + .replaceAll("__ICON_VERSION__", ICON_VERSION); context.response.type = "text/html"; context.response.body = html; } diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 4ecc8f5..69f6b26 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -2,7 +2,10 @@ import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; -import { OG_SITE_NAME } from "../config.ts"; +import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { emojiToCodepoint } from "../lib/site-icons.ts"; + +const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); interface OGMeta { title: string; @@ -59,10 +62,10 @@ async function loadIndexHtml(): Promise { const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`] ) { try { - cachedHtml = (await Deno.readTextFile(path)).replaceAll( - "__SITE_NAME__", - OG_SITE_NAME, - ); + cachedHtml = (await Deno.readTextFile(path)) + .replaceAll("__SITE_NAME__", OG_SITE_NAME) + .replaceAll("__SITE_EMOJI__", SITE_EMOJI) + .replaceAll("__ICON_VERSION__", ICON_VERSION); return cachedHtml; } catch { continue; diff --git a/api/scripts/generate-icons.ts b/api/scripts/generate-icons.ts new file mode 100644 index 0000000..8037a64 --- /dev/null +++ b/api/scripts/generate-icons.ts @@ -0,0 +1,27 @@ +// Generates the favicon set + manifest from the runtime GERBEUR_SITE_EMOJI. +// Runs before the server starts, so changing the emoji only requires a restart +// — no rebuild. +// +// The output directory is passed as the first argument (relative to cwd): +// - prod (`deno task start` / `serve`): `dist` — what the Oak server serves +// - dev (`deno task dev`): `public` — what the Vite server serves +// Defaults to `dist` when omitted. +import { OG_SITE_NAME, SITE_EMOJI, THEME_COLOR } from "../config.ts"; +import { generateSiteIcons } from "../lib/site-icons.ts"; + +const outDir = `${Deno.cwd()}/${Deno.args[0] ?? "dist"}`; + +try { + await Deno.stat(outDir); +} catch { + console.log(`[icons] ${outDir} not found — skipping favicon generation.`); + Deno.exit(0); +} + +await generateSiteIcons({ + emoji: SITE_EMOJI, + outDir, + siteName: OG_SITE_NAME, + bgColor: THEME_COLOR, +}); +console.log(`[icons] Generated favicons for "${SITE_EMOJI}" into ${outDir}`); diff --git a/deno.json b/deno.json index f883690..f164611 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,13 @@ { "tasks": { - "dev": "deno run --env-file -A npm:vite & deno task server:start", + "dev": "deno task icons public && deno run --env-file -A npm:vite & deno task server:start", "build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build", "server:start": "deno run --env-file -A --watch api/main.ts", - "serve": "deno task build && deno task server:start", + "icons": "deno run --env-file -A api/scripts/generate-icons.ts", + "serve": "deno task build && deno task icons dist && deno task server:start", "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", - "start": "deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts" + "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts" }, "nodeModulesDir": "auto", "compilerOptions": { @@ -29,7 +30,8 @@ "@panva/jose": "jsr:@panva/jose@^6.2.3", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", "nodemailer": "npm:nodemailer@^9.0.1", - "marked": "npm:marked@^18.0.5" + "marked": "npm:marked@^18.0.5", + "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2" }, "allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"] } diff --git a/deno.lock b/deno.lock index ed2ec3b..ef93f8e 100644 --- a/deno.lock +++ b/deno.lock @@ -29,6 +29,8 @@ "npm:@lingui/react@6.4.0": "6.4.0_react@19.2.7", "npm:@lingui/swc-plugin@6.5.0": "6.5.0_@lingui+core@6.4.0", "npm:@lingui/vite-plugin@6.4.0": "6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", + "npm:@resvg/resvg-wasm@2.6.2": "2.6.2", + "npm:@resvg/resvg-wasm@^2.6.2": "2.6.2", "npm:@types/node@^26.0.1": "26.0.1", "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17", "npm:@types/react@^19.2.17": "19.2.17", @@ -656,6 +658,9 @@ "@oxc-project/types@0.137.0": { "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==" }, + "@resvg/resvg-wasm@2.6.2": { + "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==" + }, "@rolldown/binding-android-arm64@1.1.3": { "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "os": ["android"], @@ -2650,6 +2655,7 @@ "jsr:@oak/oak@^17.2.0", "jsr:@panva/jose@^6.2.3", "jsr:@tajpouria/cors@^1.2.1", + "npm:@resvg/resvg-wasm@^2.6.2", "npm:marked@^18.0.5", "npm:nodemailer@^9.0.1" ], diff --git a/index.html b/index.html index 4229b37..06a5e43 100644 --- a/index.html +++ b/index.html @@ -2,15 +2,21 @@ - + + + + + + - - + + + __SITE_NAME__ diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..961f67e 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index d1818e3..5c2019f 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,6 +1,6 @@ { - "name": "gerbeur", - "short_name": "gerbeur", + "name": "coucou", + "short_name": "coucou", "start_url": "/", "display": "standalone", "background_color": "#111827", @@ -10,6 +10,17 @@ "src": "/favicon.svg", "type": "image/svg+xml", "sizes": "any" + }, + { + "src": "/favicon-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/favicon-512x512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any maskable" } ], "share_target": { diff --git a/src/App.css b/src/App.css index b1653ce..d33f942 100644 --- a/src/App.css +++ b/src/App.css @@ -2868,6 +2868,12 @@ body.has-fab .page-content { align-self: center; } + /* Slightly smaller 16:9 thumb on phones so the body keeps enough width. */ + .dump-card-preview { + width: 104px; + height: 58px; + } + .dump-card-meta { flex-direction: column; gap: 0.1rem; @@ -2886,21 +2892,35 @@ body.has-fab .page-content { .dump-card-preview, .playlist-card-preview { flex-shrink: 0; - width: 80px; - height: 80px; display: flex; align-items: center; justify-content: center; border-radius: 6px; overflow: hidden; background: var(--color-bg); + /* Subtle matte so any unfilled space (icons, off-ratio art) reads as + intentional framing rather than a broken bar. */ + box-shadow: inset 0 0 0 1px var(--color-border); transition: transform 0.18s ease, box-shadow 0.18s ease; } +/* Feed thumbnails are mostly 16:9 (YouTube, og:image) — match that ratio so + object-fit: cover crops almost nothing, and go wider for legibility. */ +.dump-card-preview { + width: 128px; + height: 72px; +} + +/* Playlist art is square (album covers) — keep it square. */ +.playlist-card-preview { + width: 80px; + height: 80px; +} + .dump-card-inner:hover .dump-card-preview, .playlist-card-inner:hover .playlist-card-preview { transform: scale(1.08); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + box-shadow: inset 0 0 0 1px var(--color-border), 0 4px 12px rgba(0, 0, 0, 0.25); } /* ── Shared card preview icon ── */ @@ -2920,15 +2940,15 @@ body.has-fab .page-content { } .dump-card-preview .rich-content-compact { - width: 80px; - height: 80px; + width: 100%; + height: 100%; justify-content: center; } .dump-card-preview .rich-content-compact-thumbnail { - width: 80px; - height: 80px; - object-fit: contain; + width: 100%; + height: 100%; + object-fit: cover; border-radius: 0; border: none; } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 34577d6..90f7222 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -7,6 +7,7 @@ import { useWS } from "../hooks/useWS.ts"; import { NotificationBell } from "./NotificationBell.tsx"; import { UserMenu } from "./UserMenu.tsx"; import { SearchBar } from "./SearchBar.tsx"; +import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts"; const DumpCreateModal = lazy(() => import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) @@ -43,10 +44,9 @@ export function AppHeader( <>
- 🚚 + {SITE_EMOJI} {" "} - {document.querySelector('meta[name="site-name"]') - ?.content ?? "gerbeur"} + {SITE_NAME} diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts index 3102716..dcd0627 100644 --- a/src/hooks/useDocumentTitle.ts +++ b/src/hooks/useDocumentTitle.ts @@ -12,6 +12,17 @@ function readSiteName(): string { export const SITE_NAME = readSiteName(); +// The server injects the configured emoji into a tag. +// In the Vite dev server it's substituted too; fall back to the default truck. +function readSiteEmoji(): string { + const meta = document.querySelector('meta[name="site-emoji"]') + ?.getAttribute("content")?.trim(); + if (meta && meta !== "__SITE_EMOJI__") return meta; + return "🚚"; +} + +export const SITE_EMOJI = readSiteEmoji(); + /** * Keeps document.title in sync with the current page during client-side * navigation. Without this the tab title only ever reflects the server-rendered diff --git a/vite.config.ts b/vite.config.ts index 6eb3096..007c9e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,56 +1,19 @@ -import { defineConfig, type Plugin } from "vite"; +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import { lingui } from "@lingui/vite-plugin"; -import fs from "node:fs"; -import path from "node:path"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; +const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; -function manifestPlugin(): Plugin { - const cssPath = path.resolve("src/index.css"); - const outPath = path.resolve("public/manifest.webmanifest"); - - function cssVar(rootBlock: string, name: string): string | undefined { - return rootBlock.match( - new RegExp(`${name.replace("-", "\\-")}:\\s*(#[0-9a-fA-F]{3,8})`), - )?.[1]; - } - - function generate() { - const css = fs.readFileSync(cssPath, "utf-8"); - // Only read the first :root block — dark-mode defaults, before any @media overrides - const rootBlock = css.match(/:root\s*\{([^}]+)\}/)?.[1] ?? ""; - const bgColor = cssVar(rootBlock, "--color-bg") ?? "#111827"; - - const manifest = { - name: SITE_NAME, - short_name: SITE_NAME, - start_url: "/", - display: "standalone", - background_color: bgColor, - theme_color: bgColor, - icons: [{ src: "/favicon.svg", type: "image/svg+xml", sizes: "any" }], - share_target: { - action: "/", - method: "GET", - params: { url: "share_url", title: "share_title", text: "share_text" }, - }, - }; - - fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n"); - } - - return { - name: "generate-manifest", - buildStart: generate, - configureServer(server) { - generate(); - server.watcher.on("change", (file) => { - if (path.resolve(file) === cssPath) generate(); - }); - }, - }; +// Cache-busting token for the favicon URLs (see index.html). Changes when the +// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors +// Twemoji's filename convention (kept trivial here to avoid the frontend build +// depending on backend code; the canonical version lives in api/lib/site-icons.ts). +function emojiToCodepoint(emoji: string): string { + const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, ""); + return [...cleaned].map((ch) => ch.codePointAt(0)!.toString(16)).join("-"); } +const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); export default defineConfig({ server: { @@ -74,16 +37,18 @@ export default defineConfig({ }, }, plugins: [ - manifestPlugin(), { - // In dev the API server isn't serving index.html, so replace the placeholder here. + // In dev the API server isn't serving index.html, so replace the placeholders here. // In production the Oak server does the replacement at runtime (see api/lib/static.ts). name: "inject-site-name", transformIndexHtml: { order: "pre", handler: (html, ctx) => ctx.server - ? html.replaceAll("__SITE_NAME__", SITE_NAME) + ? html + .replaceAll("__SITE_NAME__", SITE_NAME) + .replaceAll("__SITE_EMOJI__", SITE_EMOJI) + .replaceAll("__ICON_VERSION__", ICON_VERSION) : html, }, },