la-rose/scripts/fetch-favicon.ts
semianiakavy 4090aac0c5 feat: real /favicon.ico and an un-stretched brand lockup
Favicon: mirror the source site's /favicon.ico — a 32×32 PNG payload under an
`.ico` name — into public/favicon.ico. scripts/fetch-favicon.ts keeps it in place
(`pnpm fetch:favicon`, or `pnpm fetch:brand` for wordmark + icon) and the dev
server calls it at boot; the file handler sniffs the PNG signature for `.ico` and
serves it as image/png, so the default /favicon.ico request — and the new
<link rel="icon"> — resolve to the brand rose.

Brand lockup: the tagline "Цветы подарки Минск" is now red, un-stretched text
under the wordmark in the header and inside the footer's white plate (it used to
be letter-spaced 8px caps, which read as squeezed). The wordmark itself is sized
on one axis only (height, width:auto, object-fit:contain) — the artwork is tightly
cropped, so a width and a height at once would stretch it. BRAND.tagline /
favicon* record both facts in src/store/constants.ts.
2026-10-05 17:53:49 +00:00

88 lines
3.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdir, stat, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { BRAND } from '../src/store/constants';
/**
* Mirror the store icon into public/favicon.ico.
*
* Source: the icon the source site publishes at BRAND.faviconSourceUrl. That file is
* named `.ico` but its payload is a single 32×32 PNG (the source site serves the PNG
* bytes as `image/x-icon`) — so the bytes are validated against the PNG signature here,
* and the dev server labels `.ico` files with a PNG payload as `image/png`
* (scripts/dev-server.ts). Browsers ask for `/favicon.ico` by themselves, so serving that
* path correctly is all the storefront needs; the <link rel="icon"> in public/index.html
* just names the size and type up front.
*
* Idempotent: an existing non-empty file is kept; pass `--force` to re-download it
* (the source is a static theme asset, so a plain re-run is a no-op).
*
* pnpm fetch:favicon # download only when missing
* pnpm fetch:favicon -- --force # refresh the local copy
*
* The dev server also calls `ensureBrandFavicon()` once at boot (scripts/dev-server.ts),
* so a fresh checkout gets the icon without an extra manual step.
*/
const USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 la-rose-seed/1.0';
const REQUEST_TIMEOUT_MS = 20_000;
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const PUBLIC_DIR = fileURLToPath(new URL('../public', import.meta.url));
/** Absolute path of the local file served at BRAND.faviconLocalPath. */
export const BRAND_FAVICON_FILE = join(PUBLIC_DIR, ...BRAND.faviconLocalPath.split('/').filter(Boolean));
export type BrandFaviconStatus = 'kept' | 'downloaded';
/** True when `bytes` start with the PNG signature (the source icon is a PNG, not an ICO). */
export function isPng(bytes: Buffer): boolean {
return bytes.length >= PNG_SIGNATURE.length && bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
}
/** Ensure the local icon copy is on disk; download it when missing (or when forced). */
export async function ensureBrandFavicon(force = false): Promise<BrandFaviconStatus> {
if (!force) {
const info = await stat(BRAND_FAVICON_FILE).catch(() => null);
if (info && info.size > 0) return 'kept';
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(BRAND.faviconSourceUrl, {
headers: {
'user-agent': USER_AGENT,
accept: 'image/png,image/x-icon;q=0.9,image/*;q=0.8,*/*;q=0.5',
},
redirect: 'follow',
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (!isPng(bytes)) throw new Error('unexpected payload — expected the PNG icon');
await mkdir(dirname(BRAND_FAVICON_FILE), { recursive: true });
await writeFile(BRAND_FAVICON_FILE, bytes);
return 'downloaded';
} finally {
clearTimeout(timer);
}
}
const isDirectRun =
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectRun) {
const status = await ensureBrandFavicon(process.argv.includes('--force'));
const bytes = (await stat(BRAND_FAVICON_FILE)).size;
console.log(
status === 'kept'
? `fetch-favicon: kept ${BRAND.faviconLocalPath} (${bytes} B, ${BRAND.faviconSize}px PNG payload)`
: `fetch-favicon: downloaded ${BRAND.faviconLocalPath} (${bytes} B, ${BRAND.faviconSize}px PNG payload)`,
);
}