// Downloads the fonts the screenshot template needs, subset by Google Fonts (css2 `text=`) to exactly the characters // your copy uses, so even CJK stays small: title + body family for Latin text, plus one Noto family per non-Latin // script found in the copy (CJK, Arabic, Hebrew, Devanagari, Thai, Cyrillic / Greek). // storebound shots fonts (re-run after editing the copy) // Out: /.storebound/fonts/*.woff2 + fonts.css. All of these fonts are SIL Open Font License 1.1. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log } from '../lib/log'; import { shotsStyle } from '../lib/config'; import { type CopyFile, copyText, scriptFont, shotFonts } from './lib'; const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'; const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + 'ยท'; async function fetchSubset(family: string, chars: string, variable: boolean): Promise { const fam = encodeURIComponent(family) + (variable ? ':wght@100..900' : ''); let res = await fetch(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } }); if (!res.ok && variable) return fetchSubset(family, chars, false); if (!res.ok) throw new Error(`${family}: ${res.status} ${await res.text()}`); const src = /url\((https:[^)]+)\)/.exec(await res.text())?.[1]; if (!src) throw new Error(`${family}: no font url`); res = await fetch(src, { headers: { 'user-agent': UA } }); return res.arrayBuffer(); } export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const shots = need(config.shots, 'shots'); const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; const dir = join(root, '.storebound', 'fonts'); mkdirSync(dir, { recursive: true }); const { title, body, titleVariable } = shotFonts(shots, shotsStyle(config)); const latin = new Set(ASCII); const scripts = new Map }>(); for (const [lang, c] of Object.entries(copy)) { const text = copyText(lang, c); const s = scriptFont(lang, text); if (!s) { for (const ch of text) latin.add(ch); continue; } const cur = scripts.get(s.slug) ?? { family: s.family, chars: new Set(ASCII) }; for (const ch of text) cur.chars.add(ch); scripts.set(s.slug, cur); } for (const ch of config.app.name) latin.add(ch); const latinChars = [...latin].sort().join(''); let css = '/* Generated by storebound shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n'; const jobs: [string, string, string, boolean][] = [[title, 'title', latinChars, titleVariable], [body, 'body', latinChars, true], ...[...scripts.entries()].map(([slug, s]) => [s.family, slug, [...s.chars].sort().join(''), true] as [string, string, string, boolean])]; for (const [family, slug, chars, variable] of jobs) { const bin = await fetchSubset(family, chars, variable); const file = `${slug}.woff2`; writeFileSync(join(dir, file), Buffer.from(bin)); const face = slug === 'title' ? title : slug === 'body' ? body : `Store ${slug}`; css += `@font-face { font-family: '${face}'; font-weight: 100 900; src: url(${file}) format('woff2'); }\n`; log('shots', `${file.padEnd(18)} ${family.padEnd(22)} ${(bin.byteLength / 1024).toFixed(1)} KB, ${[...chars].length} chars`); } writeFileSync(join(dir, 'fonts.css'), css); done('shots', 'fonts done'); }