1/**
2 * Build-time brand asset generator. Runs before `astro build` and emits
3 * favicons + a fallback OG image into `website/public/`, all derived from
4 * the single yantra geometry source in `src/brand/yantra.ts`.
5 *
6 * The per-page OG images are rendered separately by the static endpoint at
7 * `src/pages/og/[...slug].webp.ts` during `astro build`; this script only
8 * produces brand artifacts that need to exist on disk before Astro starts
9 * (so they're picked up by the public/ asset pipeline).
10 */
11
12import { mkdir, writeFile } from "node:fs/promises";
13import path from "node:path";
14import { fileURLToPath } from "node:url";
15import { render, renderSvg } from "takumi-js";
16import { brandFonts } from "../src/brand/fonts.ts";
17import {
18 OG_DEFAULT_H,
19 OG_DEFAULT_W,
20 OgDefault,
21} from "../src/brand/OgDefault.tsx";
22import {
23 YANTRA_THEMES,
24 type YantraTheme,
25 yantraSvg,
26} from "../src/brand/yantra.ts";
27
28const here = path.dirname(fileURLToPath(import.meta.url));
29const publicDir = path.resolve(here, "../public");
30/** Rasterize the geometric icon artwork with Takumi. */
31function rasterize(svg: string, size: number): Promise<Uint8Array> {
32 return render(
33 {
34 type: "image",
35 src: `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`,
36 style: { width: size, height: size },
37 },
38 { width: size, height: size, format: "png" },
39 );
40}
41
42/** Favicons use exactly the same geometry and margins as the logo SVG. */
43function faviconMarkSvg(theme: YantraTheme): string {
44 return yantraSvg({ size: 64, theme });
45}
46
47/**
48 * The vector favicon carries both themes in one file: a `prefers-color-scheme`
49 * block re-paints the light mark, and CSS rules beat the presentation
50 * attributes underneath. Chrome, Firefox and Safari 16.4+ re-evaluate it live
51 * when the OS theme flips; anything that ignores the `<style>` (older browsers,
52 * rasterizers) still renders the light mark from the attributes.
53 *
54 * The bindu is the only element carrying a `fill` attribute, so `circle[fill]`
55 * targets it without also hitting the outer circle.
56 */
57function faviconVectorSvg(): string {
58 const { stroke, dot } = YANTRA_THEMES.dark;
59 const style = `<style>@media (prefers-color-scheme: dark){svg{stroke:${stroke}}circle[fill]{fill:${dot}}}</style>`;
60 return faviconMarkSvg("light").replace(/(<svg[^>]*>)/, `$1${style}`);
61}
62
63/** Canonical standalone logo, also used by app icons and the OG fallback. */
64function brandMarkSvg(theme: YantraTheme): string {
65 return yantraSvg({ size: 512, theme });
66}
67
68/** Opaque background treatment with a slightly smaller centered mark. */
69function backgroundLogoSvg(theme: YantraTheme, background: string): string {
70 const size = 512;
71 const markSize = 448;
72 const offset = (size - markSize) / 2;
73 const mark = brandMarkSvg(theme).replace(
74 'width="512" height="512"',
75 `width="${markSize}" height="${markSize}"`,
76 );
77 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
78 <rect width="${size}" height="${size}" fill="${background}"/>
79 <g transform="translate(${offset} ${offset})">${mark}</g>
80 </svg>`;
81}
82
83/**
84 * apple-touch-icon: opaque and generously padded. The one asset that cannot be
85 * transparent — iOS composites the alpha channel against black when it masks
86 * the web clip into its squircle. Light only: iOS web clips have no dark
87 * variant and `<link rel="apple-touch-icon">` ignores `media`.
88 */
89function appleTouchSvg(): string {
90 const { bg } = YANTRA_THEMES.light;
91 const inner = yantraSvg();
92 return `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 32 32">
93 <rect width="32" height="32" fill="${bg}"/>
94 <g transform="translate(4 4)">${inner}</g>
95 </svg>`;
96}
97
98const OG_PIXEL_RATIO = 2.5;
99
100async function main() {
101 await mkdir(publicDir, { recursive: true });
102
103 // 1. Vector favicon — one file, both themes via prefers-color-scheme.
104 await writeFile(path.join(publicDir, "favicon.svg"), faviconVectorSvg());
105
106 // 2. Raster favicons, one pair per theme. Media queries don't survive
107 // rasterization, so each PNG comes from an explicit-color mark and the
108 // <link> tags pick between them with `media`.
109 const favLight = faviconMarkSvg("light");
110 const favDark = faviconMarkSvg("dark");
111 for (const size of [16, 32] as const) {
112 await writeFile(
113 path.join(publicDir, `favicon-${size}.png`),
114 await rasterize(favLight, size),
115 );
116 await writeFile(
117 path.join(publicDir, `favicon-${size}-dark.png`),
118 await rasterize(favDark, size),
119 );
120 }
121
122 // 3. apple-touch-icon (180×180, padded, opaque).
123 await writeFile(
124 path.join(publicDir, "apple-touch-icon.png"),
125 await rasterize(appleTouchSvg(), 180),
126 );
127
128 // 4. The brand mark at 512, per theme — emitted as a transparent standalone
129 // vector, a transparent PWA/share raster, and an opaque logo raster using
130 // the theme background. None of the variants adds a frame or border.
131 for (const theme of ["light", "dark"] as const) {
132 const mark = brandMarkSvg(theme);
133 await writeFile(path.join(publicDir, `alchemy-logo-${theme}.svg`), mark);
134 await writeFile(
135 path.join(publicDir, `icon-512${theme === "dark" ? "-dark" : ""}.png`),
136 await rasterize(mark, 512),
137 );
138 await writeFile(
139 path.join(publicDir, `alchemy-logo-${theme}-bg.png`),
140 await rasterize(backgroundLogoSvg(theme, YANTRA_THEMES[theme].bg), 2048),
141 );
142 }
143
144 // 5. Light brand mark on a true-white ground for consumers that cannot use
145 // the warmer theme background or composite the transparent asset.
146 await writeFile(
147 path.join(publicDir, "alchemy-logo-512-white-bg.png"),
148 await rasterize(backgroundLogoSvg("light", "#ffffff"), 2048),
149 );
150
151 // 6. Backwards-compat: keep the old /favicon.png reference (used by
152 // some cached nav code) pointing to the 32px raster.
153 await writeFile(
154 path.join(publicDir, "favicon.png"),
155 await rasterize(favLight, 32),
156 );
157
158 // 7. Fallback OG: Takumi emits both PNG and outlined SVG from one layout.
159 const card = OgDefault();
160 const options = {
161 width: OG_DEFAULT_W,
162 height: OG_DEFAULT_H,
163 fonts: await brandFonts,
164 emoji: "from-font" as const,
165 };
166 await writeFile(
167 path.join(publicDir, "og-default.svg"),
168 await renderSvg(card, options),
169 );
170 await writeFile(
171 path.join(publicDir, "og-default.png"),
172 await render(card, {
173 ...options,
174 format: "png",
175 width: OG_DEFAULT_W * OG_PIXEL_RATIO,
176 height: OG_DEFAULT_H * OG_PIXEL_RATIO,
177 devicePixelRatio: OG_PIXEL_RATIO,
178 }),
179 );
180
181 // eslint-disable-next-line no-console
182 console.log(
183 "[brand] wrote favicon.{svg,png}, favicon-{16,32}[-dark].png, apple-touch-icon.png, icon-512[-dark].png, alchemy-logo-{light,dark}.svg, alchemy-logo-{light,dark}-bg.png (2048px), alchemy-logo-512-white-bg.png (2048px), og-default.{svg,png}",
184 );
185}
186
187await main();