| 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 | |
| 12 | import { mkdir, writeFile } from "node:fs/promises"; |
| 13 | import path from "node:path"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | import { render, renderSvg } from "takumi-js"; |
| 16 | import { brandFonts } from "../src/brand/fonts.ts"; |
| 17 | import { |
| 18 | OG_DEFAULT_H, |
| 19 | OG_DEFAULT_W, |
| 20 | OgDefault, |
| 21 | } from "../src/brand/OgDefault.tsx"; |
| 22 | import { |
| 23 | YANTRA_THEMES, |
| 24 | type YantraTheme, |
| 25 | yantraSvg, |
| 26 | } from "../src/brand/yantra.ts"; |
| 27 | |
| 28 | const here = path.dirname(fileURLToPath(import.meta.url)); |
| 29 | const publicDir = path.resolve(here, "../public"); |
| 30 | /** Rasterize the geometric icon artwork with Takumi. */ |
| 31 | function 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. */ |
| 43 | function 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 | */ |
| 57 | function 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. */ |
| 64 | function brandMarkSvg(theme: YantraTheme): string { |
| 65 | return yantraSvg({ size: 512, theme }); |
| 66 | } |
| 67 | |
| 68 | /** Opaque background treatment with a slightly smaller centered mark. */ |
| 69 | function 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 | */ |
| 89 | function 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 | |
| 98 | const OG_PIXEL_RATIO = 2.5; |
| 99 | |
| 100 | async 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 | |
| 187 | await main(); |