v3: added customizable site emoji (logo), small visual tweaks
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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 {
|
||||
|
||||
23
api/db/migrations/0007_password_reset_tokens.ts
Normal file
23
api/db/migrations/0007_password_reset_tokens.ts
Normal file
@@ -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
|
||||
);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
161
api/lib/site-icons.ts
Normal file
161
api/lib/site-icons.ts
Normal file
@@ -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<unknown> | undefined;
|
||||
function ensureWasm(): Promise<unknown> {
|
||||
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<string, string>();
|
||||
|
||||
async function fetchGlyph(emoji: string): Promise<string> {
|
||||
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-<size>x<size>.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<void> {
|
||||
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<number, Uint8Array>();
|
||||
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));
|
||||
}
|
||||
@@ -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<Record<string, object>>,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
|
||||
27
api/scripts/generate-icons.ts
Normal file
27
api/scripts/generate-icons.ts
Normal file
@@ -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}`);
|
||||
Reference in New Issue
Block a user