README is a short front page (about 120 lines) with one real visual per section; every former section moved unchanged into docs/*.md with a docs/README.md index (journey, rejections, getting-started, commands, growth, studios, store-art, listing-languages, api, skills, mcp, safety, faq, project). The site tells the same story on one page: the teaser in the hero, real CLI output as text, the desktop app as a section with light/dark captures of eight screens and the per-system downloads (desktop/latest.json); /desktop/ redirects to /#download. New capture tools: tools/site-desktop-shots.ts (fixture-mode desktop frames) and tools/site-term-shots.ts (real CLI output as text and WebP). tests/docs-links.test.ts checks README, docs and site links resolve.
334 lines
21 KiB
TypeScript
334 lines
21 KiB
TypeScript
// Glyph coverage of the screenshot fonts, per language and role (title / body), and the automatic Noto fallbacks.
|
|
// `shots fonts` plans the faces with planFonts() (downloads through an injected getter, so tests run offline) and
|
|
// writes .storebound/fonts/fonts.json; `shots render` re-checks coverage against the local files before rendering
|
|
// (uncoveredChars) and measures, in the page, which face drew every character (measureFonts). `shots check` turns a
|
|
// glyph drawn by a fallback the config did not choose, or by a system font, into an ERROR.
|
|
//
|
|
// Default stack per script (the template defaults, documented in docs/store-art.md and the store-screenshots skill):
|
|
// Latin incl. Latin Extended-A / B and Vietnamese, Cyrillic, Greek: the configured title / body font
|
|
// (defaults: game Paytone One + Montserrat, app Inter + Inter; all four cover Latin Extended and Vietnamese;
|
|
// Inter and Montserrat cover Cyrillic too). A Cyrillic or Greek line switches to Noto Sans as a whole.
|
|
// Arabic / Hebrew / Devanagari / Bengali / Thai: Noto Sans Arabic / Hebrew / Devanagari / Bengali / Thai (whole line).
|
|
// Japanese / Korean / Chinese: Noto Sans JP / KR / SC (TC for zh-Hant, TW, HK) (whole line).
|
|
// Any character the stack lacks: Noto Sans Display (titles) or Noto Sans (body text) for Latin / Cyrillic / Greek,
|
|
// Noto Sans <Script> for other scripts, then Noto Sans Symbols 2, Noto Sans Symbols, Noto Sans Math and Noto Color
|
|
// Emoji, added per character (logged, and an ERROR in `shots check` unless listed in shots.fonts.fallback).
|
|
import type { SceneCopy } from './lib';
|
|
import { scriptFont } from './lib';
|
|
import { needsGlyph } from './cmap';
|
|
import { type LocalFontInfo, localFile, slug } from './local-fonts';
|
|
|
|
export type Role = 'title' | 'body';
|
|
export interface RoleTexts { title: string; body: string }
|
|
|
|
/** The text each font role draws for one language: titles, badge values and the app name (feature graphic) use the
|
|
* title font; eyebrows (also upper-cased, as the template shows them), sublines, badge labels and scene numbers the
|
|
* body font. */
|
|
export function roleTexts(lang: string, c: Record<string, SceneCopy> | undefined, appName = ''): RoleTexts {
|
|
const title: string[] = [appName], body: string[] = ['0123456789'];
|
|
for (const s of Object.values(c ?? {})) {
|
|
title.push(...(s.title ?? []).filter(Boolean));
|
|
body.push(s.eyebrow ?? '', (s.eyebrow ?? '').toLocaleUpperCase(lang), s.sub ?? '');
|
|
for (const [big, small] of s.badges ?? []) { title.push(big ?? ''); body.push(small ?? ''); }
|
|
}
|
|
return { title: title.join('').normalize('NFC'), body: body.join('').normalize('NFC') };
|
|
}
|
|
|
|
/** Unique characters of a text that need a glyph, in first-seen order. */
|
|
export const glyphChars = (text: string): string[] => [...new Set([...text.normalize('NFC')].filter(needsGlyph))];
|
|
|
|
/** Characters of `text` that none of the faces covers. */
|
|
export function uncoveredChars(text: string, faces: Set<number>[]): string[] {
|
|
return glyphChars(text).filter(ch => { const cp = ch.codePointAt(0)!; return !faces.some(f => f.has(cp)); });
|
|
}
|
|
|
|
const NOTO_SCRIPTS = ['Arabic', 'Hebrew', 'Devanagari', 'Bengali', 'Thai', 'Armenian', 'Georgian', 'Ethiopic', 'Tamil', 'Telugu',
|
|
'Kannada', 'Malayalam', 'Gujarati', 'Gurmukhi', 'Oriya', 'Khmer', 'Lao', 'Myanmar', 'Sinhala', 'Thaana', 'Syriac'];
|
|
const SYMBOLS = ['Noto Sans Symbols 2', 'Noto Sans Symbols', 'Noto Sans Math', 'Noto Color Emoji'];
|
|
|
|
/** Noto families to try, in order, for a character the configured stack lacks. */
|
|
export function fallbackFamilies(ch: string, lang: string, role: Role): string[] {
|
|
const latin = role === 'title' ? ['Noto Sans Display', 'Noto Sans'] : ['Noto Sans'];
|
|
if (/\p{Extended_Pictographic}/u.test(ch) && !/[©®™]/u.test(ch)) return SYMBOLS;
|
|
if (/[\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}]/u.test(ch)) return latin;
|
|
if (/\p{Script=Hangul}/u.test(ch)) return ['Noto Sans KR'];
|
|
if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ch)) return ['Noto Sans JP'];
|
|
if (/\p{Script=Han}/u.test(ch)) return /Hant|TW|HK/.test(lang) ? ['Noto Sans TC', 'Noto Sans JP'] : ['Noto Sans SC', 'Noto Sans JP'];
|
|
for (const s of NOTO_SCRIPTS) if (new RegExp(`\\p{Script=${s}}`, 'u').test(ch)) return [`Noto Sans ${s}`];
|
|
return [...latin, ...SYMBOLS];
|
|
}
|
|
|
|
/** Global order the fallback families are tried in (every per-character list above is a subsequence of it). */
|
|
export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans TC',
|
|
...NOTO_SCRIPTS.map(s => `Noto Sans ${s}`), ...SYMBOLS];
|
|
|
|
|
|
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
|
|
/** One downloaded face: `face` is the @font-face family name the templates use. axis: css2 axis spec ("wght@800",
|
|
* "wght@100..900") or null for the font's default instance. */
|
|
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo }
|
|
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
|
|
export interface FontManifest {
|
|
v: 1;
|
|
/** local: a local font file (shots.fonts entry with a path), read in place, never copied; file = its served path. */
|
|
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo }>;
|
|
langs: Record<string, LangFonts>;
|
|
}
|
|
|
|
export interface PlanInput {
|
|
langs: Record<string, RoleTexts>;
|
|
title: string; body: string;
|
|
/** css2 axis for the title / body family (null = default instance). */
|
|
titleAxis: string | null; bodyAxis: string | null;
|
|
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
|
|
fallbackTitleAxis: string | null;
|
|
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
|
|
* Noto fallbacks (another Google family, a local file) are tried first, for every character the stack lacks. */
|
|
chosen?: string[];
|
|
/** Local font files by family (title, body or fallback). */
|
|
local?: Record<string, LocalFontInfo>;
|
|
/** Extra characters for the title / body downloads (ASCII, so later copy edits rarely need a new download). */
|
|
base?: string;
|
|
}
|
|
|
|
/** No font covers these characters: listed per language and role. */
|
|
export class GlyphError extends Error {
|
|
constructor(public readonly gaps: { lang: string; role: Role; chars: string[]; tried: string[] }[]) {
|
|
super('no font covers these characters (pick fonts that do, or change the copy):\n' + gaps.map(g =>
|
|
` ${g.lang} (${g.role}): ${g.chars.map(c => `"${c}" U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`).join(', ')}; tried ${g.tried.join(', ')}`).join('\n'));
|
|
}
|
|
}
|
|
|
|
/** Faces for every language's copy: title + body, one Noto face per non-Latin script (whole lines, as before), and an
|
|
* automatic Noto fallback for each character the stack still lacks. `get` downloads a face (only the listed chars)
|
|
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
|
|
export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise<Set<number>>): Promise<FontManifest> {
|
|
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
|
|
const local = input.local ?? {};
|
|
const extra = [...new Set(input.chosen ?? [])].filter(f => !FALLBACK_ORDER.some(n => n.toLowerCase() === f.toLowerCase()));
|
|
const order = [...extra, ...FALLBACK_ORDER];
|
|
const fileOf = (family: string, google: string): string => local[family] ? localFile(family, local[family]!) : google;
|
|
const loc = (family: string): { local?: LocalFontInfo } => (local[family] ? { local: local[family] } : {});
|
|
const base = input.base ?? '';
|
|
const scripts = new Map<string, { family: string; chars: Set<string> }>();
|
|
const langScript = new Map<string, string | null>();
|
|
const roleChars: Record<Role, Set<string>> = { title: new Set(base), body: new Set(base) };
|
|
for (const [lang, t] of Object.entries(input.langs)) {
|
|
const s = scriptFont(lang, t.title + t.body);
|
|
langScript.set(lang, s ? `Store ${s.slug}` : null);
|
|
if (s) {
|
|
const cur = scripts.get(s.slug) ?? { family: s.family, chars: new Set(base) };
|
|
for (const ch of t.title + t.body) cur.chars.add(ch);
|
|
scripts.set(s.slug, cur);
|
|
}
|
|
// the title / body faces get every Latin / common character (and every character of Latin-only copy)
|
|
for (const r of ['title', 'body'] as const) for (const ch of t[r]) if (!s || !/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(ch)) roleChars[r].add(ch);
|
|
}
|
|
const sorted = (s: Iterable<string>): string => [...new Set(s)].sort().join('');
|
|
const faces: FontManifest['faces'] = {};
|
|
const cps = new Map<string, Set<number>>();
|
|
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
|
|
cps.set(job.face, await get(job));
|
|
if (job.kind === 'fallback' && ![...job.chars].some(ch => cps.get(job.face)!.has(ch.codePointAt(0)!))) return; // covers none: not a face
|
|
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}) };
|
|
};
|
|
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title) }, true);
|
|
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body) }, true);
|
|
else { // same family: one download (title axis) with both roles' characters
|
|
const t = faces[input.title]!;
|
|
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title) }));
|
|
}
|
|
for (const [s, v] of scripts) await add({ face: `Store ${s}`, family: v.family, file: `${s}.woff2`, kind: 'script', axis: 'wght@100..900', chars: sorted(v.chars) }, true);
|
|
|
|
// per language and role: the stack, and what it lacks
|
|
const langs: FontManifest['langs'] = {};
|
|
const pending: { lang: string; role: Role; ch: string; tries: string[] }[] = [];
|
|
for (const [lang, t] of Object.entries(input.langs)) {
|
|
const x = langScript.get(lang);
|
|
langs[lang] = { title: [...(x ? [x] : []), input.title], body: [...(x ? [x] : []), input.body], fallbacks: [] };
|
|
for (const role of ['title', 'body'] as const) {
|
|
const stack = langs[lang]![role].map(f => cps.get(f)!);
|
|
for (const ch of uncoveredChars(t[role], stack)) pending.push({ lang, role, ch, tries: [...extra, ...fallbackFamilies(ch, lang, role)] });
|
|
}
|
|
}
|
|
const gaps = new Map<string, { lang: string; role: Role; chars: string[]; tried: string[] }>();
|
|
const hits = new Map<string, Map<string, Set<string>>>(); // lang|role -> face -> chars
|
|
for (const role of ['title', 'body'] as const) for (const family of order) {
|
|
const want = pending.filter(p => p.role === role && p.tries.includes(family));
|
|
if (!want.length) continue;
|
|
const face = `Store fb ${slug(family)} ${role}`;
|
|
const axis = family === 'Noto Color Emoji' ? null : role === 'title' ? input.fallbackTitleAxis : 'wght@100..900';
|
|
await add({ face, family, file: fileOf(family, `fb-${slug(family)}-${role}.woff2`), kind: 'fallback', role, axis, chars: sorted(want.map(p => p.ch)), ...loc(family) }, chosen.has(family.toLowerCase()));
|
|
const got = cps.get(face)!;
|
|
for (const p of want) {
|
|
p.tries = p.tries.filter(f => f !== family);
|
|
if (!got.has(p.ch.codePointAt(0)!)) continue;
|
|
const k = `${p.lang}|${role}`;
|
|
const m = hits.get(k) ?? new Map<string, Set<string>>();
|
|
m.set(face, (m.get(face) ?? new Set()).add(p.ch));
|
|
hits.set(k, m);
|
|
p.tries = [];
|
|
(p as { done?: boolean }).done = true;
|
|
}
|
|
}
|
|
for (const p of pending) {
|
|
if ((p as { done?: boolean }).done) continue;
|
|
const k = `${p.lang}|${p.role}`;
|
|
const g = gaps.get(k) ?? { lang: p.lang, role: p.role, chars: [], tried: [...langs[p.lang]![p.role].map(f => faces[f]?.family ?? f), ...extra, ...fallbackFamilies(p.ch, p.lang, p.role)] };
|
|
g.chars.push(p.ch);
|
|
gaps.set(k, g);
|
|
}
|
|
if (gaps.size) throw new GlyphError([...gaps.values()]);
|
|
for (const [k, m] of hits) {
|
|
const [lang, role] = k.split('|') as [string, Role];
|
|
for (const face of [...m.keys()].sort((a, b) => order.indexOf(faces[a]!.family) - order.indexOf(faces[b]!.family))) {
|
|
langs[lang]![role].push(face);
|
|
langs[lang]!.fallbacks.push({ role, face, family: faces[face]!.family, chars: [...m.get(face)!].join('') });
|
|
}
|
|
}
|
|
return { v: 1, faces, langs };
|
|
}
|
|
|
|
/** fonts.css for a manifest (every face covers all weights: static faces are never synthesised bolder). */
|
|
export function fontsCss(m: FontManifest): string {
|
|
const seen = new Set<string>();
|
|
let css = '/* Generated by storebound shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n';
|
|
for (const [face, f] of Object.entries(m.faces)) {
|
|
if (seen.has(face)) continue;
|
|
seen.add(face);
|
|
if (f.local) { // the project's own font file, served in place; ?v= changes with its content
|
|
const l = f.local;
|
|
css += `/* ${f.family}: local file ${l.path} (${l.license ? 'license: ' + l.license.replace(/\*\//g, '') : 'no license note'}) */\n`;
|
|
css += `@font-face { font-family: '${face}'; font-weight: ${l.weight ?? '100 900'}; font-style: ${l.style ?? 'normal'}; font-synthesis: none; src: url(${f.file}?v=${l.hash}) format('${l.format}'); }\n`;
|
|
continue;
|
|
}
|
|
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
|
|
}
|
|
return css;
|
|
}
|
|
|
|
// ---- render time ------------------------------------------------------------------------------------------------
|
|
|
|
/** Per text element of a rendered page: which face drew which characters ("(system)" = none of ours). */
|
|
export interface MeasuredText { el: string; text: string; faces: Record<string, string> }
|
|
|
|
/** Runs in the page (`(${COLLECT_TEXT})(faceNames)`): every element with its own text whose font stack starts with one
|
|
* of our faces, with the characters it shows (after text-transform) and its stack of our faces; plus one hidden probe
|
|
* span per (face, weight, style, character) in #sb-glyph-probes, whose fonts `measureFonts` reads over CDP. Marks and
|
|
* whitespace are skipped (checked offline against the cmaps). */
|
|
export const COLLECT_TEXT = `(ours) => {
|
|
const OURS = new Set(ours);
|
|
const label = el => {
|
|
const me = el.tagName.toLowerCase() + (el.classList.length ? '.' + [...el.classList].join('.') : '');
|
|
if (!/^(span|b|small|i|em|strong)$/.test(el.tagName.toLowerCase()) || !el.parentElement) return me;
|
|
const p = el.parentElement, sib = [...p.children].filter(c => c.tagName === el.tagName);
|
|
return label(p) + ' ' + me + (sib.length > 1 ? ':' + (sib.indexOf(el) + 1) : '');
|
|
};
|
|
const lang = document.documentElement.lang || undefined;
|
|
const els = [], probes = [], seen = new Map();
|
|
for (const el of document.body.querySelectorAll('*')) {
|
|
if (/^(SCRIPT|STYLE|SVG)$/i.test(el.tagName) || el.closest('#sb-glyph-probes')) continue;
|
|
let text = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.data).join('').normalize('NFC');
|
|
if (!text.trim()) continue;
|
|
const cs = getComputedStyle(el);
|
|
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
|
|
const fams = cs.fontFamily.split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
if (!OURS.has(fams[0])) continue;
|
|
const stack = [];
|
|
for (const f of fams) { if (!OURS.has(f)) break; stack.push(f); }
|
|
if (cs.textTransform === 'uppercase') text = text.toLocaleUpperCase(lang);
|
|
else if (cs.textTransform === 'lowercase') text = text.toLocaleLowerCase(lang);
|
|
const chars = [...new Set([...text])].filter(ch => !/^[\\s\\p{Cc}\\p{Cf}\\p{M}\\u{FE00}-\\u{FE0F}]$/u.test(ch));
|
|
const probe = {};
|
|
for (const ch of chars) for (const face of stack) {
|
|
const k = [face, cs.fontWeight, cs.fontStyle, ch].join('|');
|
|
if (!seen.has(k)) { seen.set(k, probes.length); probes.push({ face, weight: cs.fontWeight, style: cs.fontStyle, ch }); }
|
|
probe[face + '|' + ch] = seen.get(k);
|
|
}
|
|
els.push({ el: label(el), text: text.trim().slice(0, 60), stack, chars, probe });
|
|
}
|
|
const box = document.createElement('div');
|
|
box.id = 'sb-glyph-probes';
|
|
box.style.cssText = 'position:absolute;left:-20000px;top:0;white-space:pre';
|
|
for (const p of probes) {
|
|
const s = document.createElement('span');
|
|
// display:block: one character per line box. Inline probe spans side by side are shaped as one run, so "f" + "i"
|
|
// became the fi ligature, the "i" span reported no glyph of ours and was blamed on a system font.
|
|
s.style.cssText = 'display:block;font-size:32px;font-family:"' + p.face.replace(/"/g, '') + '";font-weight:' + p.weight + ';font-style:' + p.style;
|
|
s.textContent = p.ch;
|
|
box.append(s);
|
|
}
|
|
document.body.append(box);
|
|
return { els, probes: probes.length };
|
|
}`;
|
|
|
|
export interface CollectedText { el: string; text: string; stack: string[]; chars: string[]; probe: Record<string, number> }
|
|
|
|
/** Which face drew each character: the first face of the element's stack whose probe span used a web font (the probe
|
|
* span names that face only, so a web font in its platform fonts can only be that face). Pure. */
|
|
export function attribute(els: CollectedText[], covered: boolean[]): MeasuredText[] {
|
|
return els.map(e => {
|
|
const faces: Record<string, string> = {};
|
|
for (const ch of e.chars) {
|
|
const f = e.stack.find(face => covered[e.probe[face + '|' + ch]!]) ?? '(system)';
|
|
faces[f] = (faces[f] ?? '') + ch;
|
|
}
|
|
return { el: e.el, text: e.text, faces };
|
|
});
|
|
}
|
|
|
|
/** Measures a rendered page (Playwright chromium page): collects the text, then asks Chrome over CDP
|
|
* (CSS.getPlatformFontsForNode) which fonts drew each probe. Removes the probes afterwards. */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export async function measureFonts(page: any, ours: string[]): Promise<MeasuredText[]> {
|
|
const { els } = await page.evaluate(`(${COLLECT_TEXT})(${JSON.stringify(ours)})`) as { els: CollectedText[] };
|
|
await page.evaluate('document.fonts.ready.then(() => 0)');
|
|
const cdp = await page.context().newCDPSession(page);
|
|
try {
|
|
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
|
|
const { root } = await cdp.send('DOM.getDocument', { depth: 0 });
|
|
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: '#sb-glyph-probes > span' }) as { nodeIds: number[] };
|
|
const covered: boolean[] = [];
|
|
for (const nodeId of nodeIds) {
|
|
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId }) as { fonts: { isCustomFont: boolean; glyphCount: number }[] };
|
|
covered.push(fonts.some(f => f.isCustomFont && f.glyphCount > 0));
|
|
}
|
|
return attribute(els, covered);
|
|
} finally {
|
|
await cdp.detach().catch(() => {});
|
|
await page.evaluate("document.getElementById('sb-glyph-probes')?.remove()");
|
|
}
|
|
}
|
|
|
|
/** Font usage of one rendered image, as stored in report.json. fallback = drawn by a face the config did not choose;
|
|
* missing = drawn by a system font (no face of ours has the glyph, or the font file did not load). */
|
|
export interface FontUsage {
|
|
used: Record<string, number>;
|
|
fallback: { el: string; text: string; face: string; family: string; chars: string }[];
|
|
missing: { el: string; text: string; chars: string }[];
|
|
}
|
|
|
|
/** Classifies what the page measured: faces of kind title / body / script and chosen fallbacks are fine. */
|
|
export function fontUsage(measured: MeasuredText[], faces: FontManifest['faces']): FontUsage {
|
|
const u: FontUsage = { used: {}, fallback: [], missing: [] };
|
|
for (const m of measured) for (const [face, chars] of Object.entries(m.faces)) {
|
|
const n = [...chars].length;
|
|
const fam = face === '(system)' ? '(system)' : faces[face]?.family ?? face;
|
|
u.used[fam] = (u.used[fam] ?? 0) + n;
|
|
if (face === '(system)') u.missing.push({ el: m.el, text: m.text, chars });
|
|
else if (faces[face] && !faces[face]!.chosen) u.fallback.push({ el: m.el, text: m.text, face, family: faces[face]!.family, chars });
|
|
}
|
|
return u;
|
|
}
|
|
|
|
/** check messages for an image's font usage (all errors). */
|
|
export function fontIssues(u: FontUsage | undefined): { level: 'error'; code: 'font-fallback' | 'glyph-missing'; msg: string }[] {
|
|
if (!u) return [];
|
|
const sp = (s: string): string => [...s].join(' ');
|
|
return [
|
|
...u.fallback.map(f => ({ level: 'error' as const, code: 'font-fallback' as const,
|
|
msg: `${f.el} "${f.text}": ${sp(f.chars)} drawn with ${f.family}, an automatic fallback the config did not choose (use a title / body font that covers them, or add '${f.family}' to shots.fonts.fallback, then shots fonts + render)` })),
|
|
...u.missing.map(f => ({ level: 'error' as const, code: 'glyph-missing' as const,
|
|
msg: `${f.el} "${f.text}": ${sp(f.chars)} drawn with a system font (no downloaded font has them or the fonts did not load: storebound shots fonts, then render again)` })),
|
|
];
|
|
}
|