@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Regenerate the Boneyard bone files.
|
||||
*
|
||||
* Boneyard measures a running page, so this needs `pnpm dev` up. It captures exactly
|
||||
* two routes and does not crawl:
|
||||
*
|
||||
* - a real mint page, for the reviews panel's bones
|
||||
* - a /mint/ URL with no prerendered page, for the prerender miss skeleton
|
||||
*
|
||||
* The mint host is looked up from the API rather than hardcoded, so this keeps
|
||||
* working as mints come and go. Both islands render fixture content when the CLI
|
||||
* sets `window.__BONEYARD_BUILD`, so neither capture depends on a relay answering.
|
||||
*
|
||||
* Re-run after any layout change to a review card or to the prerender miss summary.
|
||||
*/
|
||||
import { spawn } from 'node:child_process';
|
||||
import { rmSync } from 'node:fs';
|
||||
|
||||
const WEB_PORT = process.env['WEB_PORT'] ?? '4321';
|
||||
const WEB_URL = process.env['BONES_URL'] ?? `http://localhost:${WEB_PORT}`;
|
||||
const API_URL = process.env['API_URL'] ?? 'http://127.0.0.1:8787';
|
||||
|
||||
/** A host that is never prerendered, so Astro serves the 404 fallback for it. */
|
||||
const MISS_HOST = 'not-prerendered.boneyard.invalid';
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n bones: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function reachable(url) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await reachable(WEB_URL))) {
|
||||
fail(`no dev server at ${WEB_URL}. Start one with "pnpm dev", then run this again.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dev server proxies /api to the API in same origin mode, so try it first and
|
||||
* fall back to the API's own address for setups that set PUBLIC_API_URL.
|
||||
*/
|
||||
async function firstMintHost() {
|
||||
for (const base of [WEB_URL, API_URL]) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/mints`, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) continue;
|
||||
const mints = await res.json();
|
||||
if (Array.isArray(mints) && mints[0]?.host) return mints[0].host;
|
||||
} catch {
|
||||
// Try the next base.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = await firstMintHost();
|
||||
if (!host) {
|
||||
fail(`no mints from the API (tried ${WEB_URL} and ${API_URL}). Start it with "pnpm dev:api".`);
|
||||
}
|
||||
|
||||
const targets = [`${WEB_URL}/mint/${host}`, `${WEB_URL}/mint/${MISS_HOST}`];
|
||||
console.log(`\n bones: measuring\n ${targets.join('\n ')}\n`);
|
||||
|
||||
// Passing explicit non-root paths puts the CLI in single page mode: it visits only
|
||||
// these URLs, with no link following and no filesystem route scan.
|
||||
//
|
||||
// --force because Boneyard decides a skeleton is unchanged by hashing the captured
|
||||
// element's innerHTML. Bone geometry depends just as much on CSS, so a styling change
|
||||
// alone would silently reuse stale bones. There are only two skeletons here, so
|
||||
// always recapturing costs a few seconds and removes the trap.
|
||||
const child = spawn('boneyard-js', ['build', '--force', ...targets], { stdio: 'inherit', shell: false });
|
||||
|
||||
child.on('error', (error) => fail(`could not run boneyard-js (${error.message}).`));
|
||||
child.on('exit', (code) => {
|
||||
// Boneyard also writes a registry that calls registerBones() through a framework
|
||||
// adapter, defaulting to boneyard-js/react. We have no React and no <Skeleton>
|
||||
// components: the islands import the .bones.json files directly. Leaving an
|
||||
// unresolvable import in src/ would only break typechecking.
|
||||
rmSync(new URL('../src/bones/registry.ts', import.meta.url), { force: true });
|
||||
rmSync(new URL('../src/bones/registry.js', import.meta.url), { force: true });
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* The i18n SEO check, over the built site.
|
||||
*
|
||||
* `check-links.mjs` answers "does every link go somewhere". This answers the question
|
||||
* that matters once a site is in three languages: does every page correctly declare
|
||||
* which language it is, which page it is, and where its siblings are. Those three
|
||||
* declarations are what stop three translations of one page being indexed as three
|
||||
* duplicates, and every one of them is easy to get subtly wrong in a way nothing else
|
||||
* catches.
|
||||
*
|
||||
* Seven checks per page:
|
||||
*
|
||||
* lang <html lang> matches the locale its URL is under
|
||||
* canonical present, absolute, and pointing at itself (never at another locale)
|
||||
* alternates one per locale, plus exactly one x-default
|
||||
* absolute every alternate href is absolute
|
||||
* targets every alternate href is a page the build actually emitted
|
||||
* reciprocal every alternate lists this page back, under this page's own hreflang
|
||||
* x-default points at the default locale's version of this same page
|
||||
*
|
||||
* Plus, over the sitemap: every emitted page appears exactly once, with its own full
|
||||
* xhtml:link alternate set.
|
||||
*
|
||||
* Usage: node scripts/check-hreflang.mjs [distDir]
|
||||
* Exits non-zero on any problem, so it can gate a deploy.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dist = path.resolve(process.argv[2] ?? path.join(root, 'dist'));
|
||||
|
||||
if (!existsSync(dist)) {
|
||||
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const { DEFAULT_LOCALE, LOCALE_CODES } = await import(path.join(root, 'src/i18n/locales.mjs'));
|
||||
const PREFIXED = LOCALE_CODES.filter((code) => code !== DEFAULT_LOCALE);
|
||||
|
||||
/* ---------- what the build emitted ---------- */
|
||||
|
||||
async function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const pages = files.filter((f) => f.endsWith('.html'));
|
||||
|
||||
/** dist-relative file to the route a visitor asks for. */
|
||||
const routeOf = (file) =>
|
||||
file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
|
||||
/** The locale a route is under, and the route with that prefix removed. */
|
||||
function split(route) {
|
||||
const first = route.split('/').filter(Boolean)[0];
|
||||
if (first && PREFIXED.includes(first)) {
|
||||
const rest = route.slice(first.length + 1);
|
||||
return { locale: first, path: rest === '' ? '/' : rest };
|
||||
}
|
||||
return { locale: DEFAULT_LOCALE, path: route };
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
const fail = (page, kind, detail) => problems.push({ page, kind, detail });
|
||||
|
||||
/* ---------- read every page ---------- */
|
||||
|
||||
const LANG = /<html[^>]*\slang="([^"]*)"/i;
|
||||
const CANONICAL = /<link[^>]*rel="canonical"[^>]*href="([^"]*)"/i;
|
||||
const ROBOTS = /<meta[^>]*name="robots"[^>]*content="([^"]*)"/i;
|
||||
const ALTERNATE = /<link[^>]*rel="alternate"[^>]*hreflang="([^"]*)"[^>]*href="([^"]*)"/gi;
|
||||
|
||||
const seen = new Map(); // route -> { locale, path, lang, canonical, alternates: Map }
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of pages) {
|
||||
const route = routeOf(file);
|
||||
routes.add(route);
|
||||
const html = await readFile(path.join(dist, file), 'utf8');
|
||||
|
||||
const alternates = new Map();
|
||||
for (const match of html.matchAll(ALTERNATE)) alternates.set(match[1], match[2]);
|
||||
|
||||
seen.set(route, {
|
||||
...split(route),
|
||||
route,
|
||||
lang: LANG.exec(html)?.[1] ?? null,
|
||||
canonical: CANONICAL.exec(html)?.[1] ?? null,
|
||||
robots: ROBOTS.exec(html)?.[1] ?? null,
|
||||
alternates,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* The 404 page is the one route with no place in the alternate graph.
|
||||
*
|
||||
* It is not a page anyone links to or a crawler should index; it is what a web server
|
||||
* hands back for a URL that does not exist. It still gets a `lang` and it still gets
|
||||
* one per locale, which is what the checks below verify, but it is deliberately absent
|
||||
* from the sitemap and it is not required to be reciprocal with anything.
|
||||
*/
|
||||
const isOffGraph = (page) => page.path === '/404';
|
||||
|
||||
/**
|
||||
* A page that exists but asks not to be indexed.
|
||||
*
|
||||
* Mint pages the site has never reached and nobody has reviewed carry this (see
|
||||
* `src/lib/seo.ts`). Unlike the 404 they are real pages: they keep their canonical and
|
||||
* their full alternate set, and only the sitemap leaves them out.
|
||||
*/
|
||||
const isNoindex = (page) => Boolean(page.robots?.includes('noindex'));
|
||||
|
||||
/* ---------- the seven checks ---------- */
|
||||
|
||||
const origins = new Set();
|
||||
|
||||
for (const page of seen.values()) {
|
||||
if (page.lang !== page.locale) {
|
||||
fail(page.route, 'lang', `<html lang="${page.lang}"> under /${page.locale}/`);
|
||||
}
|
||||
|
||||
if (isOffGraph(page)) {
|
||||
// No canonical, no alternates, and a robots meta saying so. Checked rather than
|
||||
// skipped: "the 404 has no alternate set" is a claim worth verifying, because the
|
||||
// easy mistake is for it to quietly acquire one.
|
||||
if (page.canonical) fail(page.route, 'noindex', `404 has a canonical: ${page.canonical}`);
|
||||
if (page.alternates.size > 0) {
|
||||
fail(page.route, 'noindex', `404 declares ${page.alternates.size} alternate(s)`);
|
||||
}
|
||||
if (!page.robots?.includes('noindex')) fail(page.route, 'noindex', '404 is missing noindex');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!page.canonical) {
|
||||
fail(page.route, 'canonical', 'no <link rel="canonical">');
|
||||
continue;
|
||||
}
|
||||
if (!/^https?:\/\//.test(page.canonical)) {
|
||||
fail(page.route, 'canonical', `relative: ${page.canonical}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalUrl = new URL(page.canonical);
|
||||
origins.add(canonicalUrl.origin);
|
||||
|
||||
|
||||
// Self-canonical. A locale canonicalising to another locale tells a crawler this
|
||||
// page is a duplicate that should not be indexed, which is the exact opposite of
|
||||
// what the alternate set beside it is claiming.
|
||||
if (canonicalUrl.pathname.replace(/\/$/, '') !== page.route.replace(/\/$/, '')) {
|
||||
fail(page.route, 'canonical', `points at ${canonicalUrl.pathname}, not at itself`);
|
||||
}
|
||||
|
||||
// One per locale, plus x-default, and nothing else.
|
||||
const expected = [...LOCALE_CODES, 'x-default'];
|
||||
for (const hreflang of expected) {
|
||||
if (!page.alternates.has(hreflang)) {
|
||||
fail(page.route, 'alternates', `missing hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
for (const hreflang of page.alternates.keys()) {
|
||||
if (!expected.includes(hreflang)) {
|
||||
fail(page.route, 'alternates', `unexpected hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hreflang, href] of page.alternates) {
|
||||
if (!/^https?:\/\//.test(href)) {
|
||||
fail(page.route, 'absolute', `hreflang="${hreflang}" is relative: ${href}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = new URL(href).pathname.replace(/\/$/, '') || '/';
|
||||
if (!routes.has(target)) {
|
||||
fail(page.route, 'targets', `hreflang="${hreflang}" points at ${target}, which was not emitted`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hreflang === 'x-default') {
|
||||
const expectedDefault = split(target);
|
||||
if (expectedDefault.locale !== DEFAULT_LOCALE || expectedDefault.path !== page.path) {
|
||||
fail(page.route, 'x-default', `points at ${target}, not at the ${DEFAULT_LOCALE} version of ${page.path}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reciprocity: the page this one points at has to point back, at this page, under
|
||||
// this page's own hreflang. A set that is not reciprocal is discarded whole, so a
|
||||
// one-sided link is worth less than no link.
|
||||
const other = seen.get(target);
|
||||
const back = other?.alternates.get(page.locale);
|
||||
if (!back) {
|
||||
fail(page.route, 'reciprocal', `${target} does not list hreflang="${page.locale}"`);
|
||||
continue;
|
||||
}
|
||||
const backPath = new URL(back).pathname.replace(/\/$/, '') || '/';
|
||||
if (backPath !== (page.route.replace(/\/$/, '') || '/')) {
|
||||
fail(page.route, 'reciprocal', `${target} lists hreflang="${page.locale}" as ${backPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (origins.size > 1) {
|
||||
fail('(site)', 'origin', `canonical URLs use more than one origin: ${[...origins].join(', ')}`);
|
||||
}
|
||||
|
||||
/* ---------- the sitemap ---------- */
|
||||
|
||||
const sitemapPath = path.join(dist, 'sitemap.xml');
|
||||
let sitemapCount = 0;
|
||||
|
||||
if (!existsSync(sitemapPath)) {
|
||||
fail('(sitemap)', 'sitemap', 'sitemap.xml was not emitted');
|
||||
} else {
|
||||
const xml = await readFile(sitemapPath, 'utf8');
|
||||
const blocks = [...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((m) => m[1]);
|
||||
sitemapCount = blocks.length;
|
||||
|
||||
const listed = new Map();
|
||||
for (const block of blocks) {
|
||||
const loc = /<loc>([^<]*)<\/loc>/.exec(block)?.[1];
|
||||
if (!loc) {
|
||||
fail('(sitemap)', 'sitemap', 'a <url> block has no <loc>');
|
||||
continue;
|
||||
}
|
||||
const route = new URL(loc).pathname.replace(/\/$/, '') || '/';
|
||||
if (listed.has(route)) fail(route, 'sitemap', 'listed more than once');
|
||||
|
||||
const alternates = new Set(
|
||||
[...block.matchAll(/<xhtml:link[^>]*hreflang="([^"]*)"/g)].map((m) => m[1]),
|
||||
);
|
||||
listed.set(route, alternates);
|
||||
|
||||
for (const hreflang of [...LOCALE_CODES, 'x-default']) {
|
||||
if (!alternates.has(hreflang)) {
|
||||
fail(route, 'sitemap', `entry is missing xhtml:link hreflang="${hreflang}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The invariant, both ways round: every indexable page is listed, and nothing that
|
||||
* asks not to be indexed is.
|
||||
*
|
||||
* Checking it here rather than trusting the two files to agree is the point. The
|
||||
* page's robots meta and the sitemap are produced by different code from one shared
|
||||
* predicate, and this reads the built output of both, so the day someone changes one
|
||||
* without the other the build says so.
|
||||
*/
|
||||
for (const page of seen.values()) {
|
||||
if (isNoindex(page)) {
|
||||
if (listed.has(page.route)) {
|
||||
fail(page.route, 'sitemap', 'says noindex but is listed in the sitemap');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!listed.has(page.route)) fail(page.route, 'sitemap', 'emitted but not in the sitemap');
|
||||
}
|
||||
for (const route of listed.keys()) {
|
||||
if (!routes.has(route)) fail(route, 'sitemap', 'listed but never emitted');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- report ---------- */
|
||||
|
||||
const localeCounts = LOCALE_CODES.map(
|
||||
(code) => `${code}: ${[...seen.values()].filter((p) => p.locale === code).length}`,
|
||||
).join(', ');
|
||||
|
||||
console.log(
|
||||
`check-hreflang: ${pages.length} pages (${localeCounts}), ${sitemapCount} sitemap entries.`,
|
||||
);
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log('Every page declares its language, canonicalises to itself, and has a complete reciprocal alternate set.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const grouped = new Map();
|
||||
for (const problem of problems) {
|
||||
if (!grouped.has(problem.kind)) grouped.set(problem.kind, []);
|
||||
grouped.get(problem.kind).push(problem);
|
||||
}
|
||||
|
||||
console.log(`\n${problems.length} problem(s):`);
|
||||
for (const [kind, list] of grouped) {
|
||||
console.log(`\n [${kind}] ${list.length}`);
|
||||
for (const problem of list.slice(0, 15)) console.log(` ${problem.page}: ${problem.detail}`);
|
||||
if (list.length > 15) console.log(` … and ${list.length - 15} more`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Catalog checker. Runs before every build, and on its own via `pnpm check:i18n`.
|
||||
*
|
||||
* Four questions, all of which have bitten a multilingual site before:
|
||||
*
|
||||
* missing a key English has and this locale does not (falls back, but silently)
|
||||
* unknown a key a locale has and English does not (a typo, or a stale key)
|
||||
* undefined a `t('...')` in the source that no catalog defines (would render a
|
||||
* humanised key on a real page)
|
||||
* client an island reaching for a key outside CLIENT_NAMESPACES, which would
|
||||
* not be in the page's inline payload and would render as a humanised key
|
||||
*
|
||||
* Missing keys are a warning: English is a working fallback and a half-translated
|
||||
* language is better than no language. Everything else is an error, because every one
|
||||
* of them puts a wrong string in front of a visitor.
|
||||
*
|
||||
* Usage: node scripts/check-i18n.mjs [--strict]
|
||||
* --strict also fails on missing keys, for a release build.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const i18nDir = path.join(root, 'src/i18n');
|
||||
const srcDir = path.join(root, 'src');
|
||||
|
||||
const strict = process.argv.includes('--strict');
|
||||
|
||||
/* ---------- the locale table, read from config.ts ---------- */
|
||||
|
||||
/*
|
||||
* Parsed out of the source rather than imported, because config.ts is TypeScript and
|
||||
* this script runs on plain node before anything is compiled. The two shapes it reads
|
||||
* are the two the file is allowed to have; a change to either fails loudly here rather
|
||||
* than quietly checking nothing.
|
||||
*/
|
||||
const configSource = await readFile(path.join(i18nDir, 'config.ts'), 'utf8');
|
||||
|
||||
const localeCodes = [...configSource.matchAll(/\{\s*code:\s*'([a-z-]+)'/g)].map((m) => m[1]);
|
||||
const defaultLocale = /DEFAULT_LOCALE\s*=\s*'([a-z-]+)'/.exec(configSource)?.[1];
|
||||
const clientBlock = /CLIENT_NAMESPACES\s*=\s*\[([^\]]*)\]/s.exec(configSource)?.[1] ?? '';
|
||||
const clientNamespaces = new Set([...clientBlock.matchAll(/'([\w-]+)'/g)].map((m) => m[1]));
|
||||
|
||||
if (localeCodes.length === 0 || !defaultLocale || clientNamespaces.size === 0) {
|
||||
console.error('check-i18n: could not read LOCALES, DEFAULT_LOCALE or CLIENT_NAMESPACES from src/i18n/config.ts');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/*
|
||||
* `locales.mjs` is the same list in plain JavaScript, for `astro.config.mjs` and this
|
||||
* script, both of which run before TypeScript exists. Two lists is one more than
|
||||
* ideal, so they are compared here rather than trusted.
|
||||
*/
|
||||
const { DEFAULT_LOCALE: mjsDefault, LOCALE_CODES: mjsCodes } = await import(
|
||||
path.join(i18nDir, 'locales.mjs')
|
||||
);
|
||||
|
||||
if (mjsDefault !== defaultLocale || mjsCodes.join(',') !== localeCodes.join(',')) {
|
||||
console.error('check-i18n: src/i18n/config.ts and src/i18n/locales.mjs disagree.');
|
||||
console.error(` config.ts: default ${defaultLocale}, locales [${localeCodes.join(', ')}]`);
|
||||
console.error(` locales.mjs: default ${mjsDefault}, locales [${mjsCodes.join(', ')}]`);
|
||||
console.error('Both need the new language. See README, "Adding a language".');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/* ---------- catalogs ---------- */
|
||||
|
||||
const catalogs = new Map();
|
||||
for (const code of localeCodes) {
|
||||
const file = path.join(i18nDir, `${code}.json`);
|
||||
try {
|
||||
catalogs.set(code, JSON.parse(await readFile(file, 'utf8')));
|
||||
} catch (error) {
|
||||
console.error(`check-i18n: cannot read src/i18n/${code}.json (${error.message})`);
|
||||
console.error(`Every locale in LOCALES needs a catalog. Copy en.json to ${code}.json and translate it.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const base = catalogs.get(defaultLocale);
|
||||
|
||||
/** CLDR plural categories, which are a key's suffix rather than a key of their own. */
|
||||
const PLURAL_SUFFIX = /\.(zero|one|two|few|many|other)$/;
|
||||
|
||||
/** `card.reviews.one` and `card.reviews` are the same key as far as coverage goes. */
|
||||
const stem = (key) => key.replace(PLURAL_SUFFIX, '');
|
||||
|
||||
/**
|
||||
* Every stem a catalog can resolve.
|
||||
*
|
||||
* Stems rather than literal keys, because a locale is allowed more plural categories
|
||||
* than English has: Spanish spells `time.ago.month` as `.one` and `.other` where
|
||||
* English needs one form, and neither is missing anything.
|
||||
*/
|
||||
const stems = (catalog) => new Set(Object.keys(catalog).map(stem));
|
||||
|
||||
const baseStems = stems(base);
|
||||
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const code of localeCodes) {
|
||||
if (code === defaultLocale) continue;
|
||||
const catalog = catalogs.get(code);
|
||||
const own = stems(catalog);
|
||||
|
||||
const missing = [...baseStems].filter((key) => !own.has(key)).sort();
|
||||
const unknown = [...own].filter((key) => !baseStems.has(key)).sort();
|
||||
|
||||
if (missing.length > 0) {
|
||||
(strict ? problems : warnings).push({
|
||||
code,
|
||||
kind: 'missing',
|
||||
keys: missing,
|
||||
note: `${missing.length} key(s) fall back to ${defaultLocale}`,
|
||||
});
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
problems.push({
|
||||
code,
|
||||
kind: 'unknown',
|
||||
keys: unknown,
|
||||
note: `${unknown.length} key(s) are not in ${defaultLocale}.json, so nothing reads them`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the warning copy, which lives in two packages ---------- */
|
||||
|
||||
/*
|
||||
* `shared/src/warnings.ts` decides which banner a mint gets and writes the English
|
||||
* sentence for it, because the API's fixture tests assert on that sentence. The
|
||||
* catalogs carry the same sentences so they can be translated. Two copies of safety
|
||||
* copy is exactly the sort of thing that drifts, and a mint page and a mint card
|
||||
* disagreeing about whether withdrawals work is the worst possible thing to get wrong,
|
||||
* so the two are diffed here on every build.
|
||||
*
|
||||
* Read from shared's build output rather than its source: it is a prerequisite of the
|
||||
* web build, so it is always there, and importing it beats parsing TypeScript.
|
||||
*/
|
||||
const SHARED_TABLES = [
|
||||
{ module: 'WARNING_COPY_EN', prefix: 'mint.warnings.' },
|
||||
{ module: 'CHIP_COPY_EN', prefix: 'mint.chip.' },
|
||||
];
|
||||
|
||||
try {
|
||||
const shared = await import(path.join(root, '../shared/dist/warnings.js'));
|
||||
|
||||
for (const { module, prefix } of SHARED_TABLES) {
|
||||
const table = shared[module];
|
||||
if (!table) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: [module],
|
||||
note: `shared/dist/warnings.js no longer exports ${module}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const drift = [];
|
||||
for (const [key, english] of Object.entries(table)) {
|
||||
const catalogKey = prefix + key;
|
||||
const inCatalog = base[catalogKey];
|
||||
if (inCatalog === undefined) drift.push(`${catalogKey} (in ${module}, not in ${defaultLocale}.json)`);
|
||||
else if (inCatalog !== english) {
|
||||
drift.push(`${catalogKey}\n shared: ${english}\n catalog: ${inCatalog}`);
|
||||
}
|
||||
}
|
||||
// And the other direction: a catalog key under this prefix that shared never writes
|
||||
// is a key no banner will ever ask for.
|
||||
for (const key of Object.keys(base)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (table[key.slice(prefix.length)] === undefined) {
|
||||
drift.push(`${key} (in ${defaultLocale}.json, not in ${module})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.length > 0) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: drift,
|
||||
note: `${drift.length} difference(s) between shared's ${module} and ${defaultLocale}.json`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'shared',
|
||||
keys: [String(error.message)],
|
||||
note: 'could not read shared/dist/warnings.js. Run `pnpm --filter ./shared build` first',
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- key usage in the source ---------- */
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'i18n' || entry.name === 'bones') continue;
|
||||
out.push(...(await walk(full)));
|
||||
} else if (/\.(astro|ts)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `t('some.key')` and `t('some.key', { ... })`, with either quote.
|
||||
*
|
||||
* Only literal keys are found, which is the point: a key built at runtime cannot be
|
||||
* checked, so the codebase does not build them. The two places that legitimately need
|
||||
* a computed key (`nut.${n}` and `status.${mint.status}`) use a helper with its own
|
||||
* exhaustive list, declared below.
|
||||
*/
|
||||
const T_CALL = /\bt\(\s*(['"])([\w.-]+)\1/g;
|
||||
|
||||
/**
|
||||
* Key families built from a value at runtime, listed here so the checker knows they
|
||||
* are reached. Each entry is a prefix; every catalog key under it counts as used.
|
||||
*/
|
||||
const DYNAMIC_PREFIXES = [
|
||||
'nut.', // nutName(n)
|
||||
'status.', // statusLabel(mint.status)
|
||||
'reviews.dialog.stars.', // the star picker's phrase per rating
|
||||
'reviews.breakdown.', // ratingBreakdown, one key per star count
|
||||
'mint.warnings.', // warningStrings(), keyed by kind
|
||||
'mint.chip.', // mintChip()
|
||||
'login.method.', // methodChipHtml(session.method)
|
||||
'wallets.platform.', // the platform list on each wallet card
|
||||
'wallets.item.', // one blurb per wallet, keyed by its id
|
||||
'login.connect.', // CONNECT_COPY, keyed by method
|
||||
'time.ago.', // formatRelative, keyed by unit
|
||||
'time.short.', // formatShortDuration, keyed by unit
|
||||
];
|
||||
|
||||
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
|
||||
const files = await walk(srcDir);
|
||||
const used = new Set();
|
||||
const undefinedKeys = [];
|
||||
const clientLeaks = [];
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
const relative = path.relative(root, file);
|
||||
|
||||
/*
|
||||
* An .astro file is two programs: frontmatter and template render at build time, and
|
||||
* anything inside a <script> ships to the browser. Only the second half is limited to
|
||||
* the client namespaces, so the two are checked separately.
|
||||
*/
|
||||
const scripts = [...source.matchAll(/<script(?![^>]*\bis:inline\b)[^>]*>([\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.join('\n');
|
||||
// A .ts file under lib/ or scripts/ is island code wholesale.
|
||||
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
|
||||
|
||||
for (const match of source.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
used.add(key);
|
||||
if (!baseStems.has(stem(key))) {
|
||||
undefinedKeys.push({ key, file: relative });
|
||||
}
|
||||
}
|
||||
|
||||
const clientSource = shipsToBrowser ? source : scripts;
|
||||
for (const match of clientSource.matchAll(T_CALL)) {
|
||||
const key = match[2];
|
||||
const namespace = key.split('.')[0];
|
||||
if (!clientNamespaces.has(namespace)) {
|
||||
clientLeaks.push({ key, file: relative, namespace });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (undefinedKeys.length > 0) {
|
||||
problems.push({
|
||||
code: defaultLocale,
|
||||
kind: 'undefined',
|
||||
keys: undefinedKeys.map((u) => `${u.key} (${u.file})`),
|
||||
note: `${undefinedKeys.length} key(s) used in the source are not defined in ${defaultLocale}.json`,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientLeaks.length > 0) {
|
||||
problems.push({
|
||||
code: '-',
|
||||
kind: 'client',
|
||||
keys: clientLeaks.map((c) => `${c.key} (${c.file}, namespace "${c.namespace}")`),
|
||||
note: `${clientLeaks.length} island key(s) outside CLIENT_NAMESPACES: add the namespace to config.ts or move the string`,
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- keys nothing reads ---------- */
|
||||
|
||||
const unused = [...baseStems]
|
||||
.filter((key) => !used.has(key) && !isDynamic(key))
|
||||
.filter((key) => !base[key] === false || true)
|
||||
.sort();
|
||||
|
||||
/* ---------- report ---------- */
|
||||
|
||||
const report = (list, level) => {
|
||||
for (const entry of list) {
|
||||
console.log(`\n${level} [${entry.kind}] ${entry.code}: ${entry.note}`);
|
||||
for (const key of entry.keys.slice(0, 40)) console.log(` ${key}`);
|
||||
if (entry.keys.length > 40) console.log(` … and ${entry.keys.length - 40} more`);
|
||||
}
|
||||
};
|
||||
|
||||
const total = Object.keys(base).length;
|
||||
console.log(
|
||||
`check-i18n: ${localeCodes.length} locales, ${total} keys in ${defaultLocale}.json, ` +
|
||||
`${used.size} literal key(s) used in src/.`,
|
||||
);
|
||||
|
||||
if (process.env['LIST_UNUSED'] && unused.length > 0) {
|
||||
console.log(`\n ${unused.length} key(s) with no literal or dynamic use:`);
|
||||
for (const key of unused) console.log(` ${key}`);
|
||||
}
|
||||
|
||||
report(warnings, 'WARN ');
|
||||
report(problems, 'ERROR');
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.log(`\ncheck-i18n failed with ${problems.length} problem group(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log('\ncheck-i18n passed with warnings. Untranslated keys fall back to English.');
|
||||
} else {
|
||||
console.log('Every locale is complete and every key used in the source is defined.');
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Internal link checker for the built site.
|
||||
*
|
||||
* Walks every .html file in dist/, pulls out every href and src that stays on this
|
||||
* origin, and resolves it against what the build actually emitted. Three failures are
|
||||
* reported, all of them things a visitor would hit:
|
||||
*
|
||||
* missing the target route was never emitted (a 404 on a real link)
|
||||
* anchor the route exists but the #id it points at is not in that page
|
||||
* asset a referenced local file (css, js, image) is not in dist/
|
||||
*
|
||||
* Query strings are stripped before resolving: /mints?sort=rating is /mints. Links
|
||||
* that leave the origin (http(s)://other, mailto:, wss:) are counted and skipped.
|
||||
*
|
||||
* Usage: node scripts/check-links.mjs [distDir]
|
||||
* Exits non-zero when anything is broken, so it can gate a deploy.
|
||||
*/
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dist = path.resolve(process.argv[2] ?? 'dist');
|
||||
|
||||
if (!existsSync(dist)) {
|
||||
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/** Every file under dir, as paths relative to dir with forward slashes. */
|
||||
async function walk(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await walk(full, base)));
|
||||
else out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = await walk(dist);
|
||||
const fileSet = new Set(files);
|
||||
const pages = files.filter((f) => f.endsWith('.html'));
|
||||
|
||||
/**
|
||||
* Routes a visitor can reach. Astro writes /mints as mints/index.html, and the site
|
||||
* runs with trailingSlash: 'ignore', so both spellings resolve.
|
||||
*/
|
||||
/*
|
||||
* `/404` counts as a route.
|
||||
*
|
||||
* It used to be skipped, because nothing linked to it and it exists to be served by
|
||||
* `error_page` rather than navigated to. The language switcher changed that: on a 404,
|
||||
* every language's version of the 404 is a real destination, and the nginx block in the
|
||||
* README resolves `/404` through its `$uri.html` rule exactly as it resolves the
|
||||
* prefixed ones through their directory index.
|
||||
*/
|
||||
const routes = new Set();
|
||||
for (const file of pages) {
|
||||
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
routes.add(route);
|
||||
if (route !== '/') routes.add(route + '/');
|
||||
}
|
||||
|
||||
/** #ids present in each page, so anchor links can be checked against the real markup. */
|
||||
const idsByRoute = new Map();
|
||||
const HREF = /(?:href|src)\s*=\s*"([^"]*)"/gi;
|
||||
const ASSET_EXT = /\.(css|js|mjs|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|map|webmanifest|pdf|mp4|webm)$/i;
|
||||
const ID = /\sid\s*=\s*"([^"]+)"/gi;
|
||||
|
||||
const problems = [];
|
||||
const seen = new Map(); // target -> Set of pages linking to it
|
||||
let external = 0;
|
||||
|
||||
for (const file of pages) {
|
||||
const html = await readFile(path.join(dist, file), 'utf8');
|
||||
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
||||
|
||||
const ids = new Set();
|
||||
for (const m of html.matchAll(ID)) ids.add(m[1]);
|
||||
idsByRoute.set(route, ids);
|
||||
idsByRoute.set(route + '/', ids);
|
||||
|
||||
for (const m of html.matchAll(HREF)) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw || raw.startsWith('#') || raw.startsWith('data:')) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) { external++; continue; }
|
||||
if (!raw.startsWith('/')) continue; // relative asset paths: Astro does not emit any
|
||||
|
||||
const [pathPart, hashPart] = raw.split('#');
|
||||
const target = (pathPart ?? '').split('?')[0] || '/';
|
||||
|
||||
const key = hashPart ? `${target}#${hashPart}` : target;
|
||||
if (!seen.has(key)) seen.set(key, new Set());
|
||||
seen.get(key).add(route);
|
||||
|
||||
// The API serves /api/* and /icons/* in production; nothing under them is in dist.
|
||||
if (target.startsWith('/api/') || target.startsWith('/icons/')) continue;
|
||||
|
||||
const asFile = target.replace(/^\//, '');
|
||||
// Extension based, from a fixed list: mint slugs are domains, so "ends in a dot
|
||||
// and some letters" would call /mint/cashu.boats an asset and look for the file.
|
||||
const isAsset = ASSET_EXT.test(asFile);
|
||||
|
||||
if (isAsset) {
|
||||
if (!fileSet.has(asFile)) problems.push({ kind: 'asset', target, from: route });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!routes.has(target)) {
|
||||
problems.push({ kind: 'missing', target, from: route });
|
||||
continue;
|
||||
}
|
||||
if (hashPart) {
|
||||
const known = idsByRoute.get(target);
|
||||
// The target page may not be parsed yet: anchors are re-checked in a second pass.
|
||||
if (known && !known.has(hashPart)) problems.push({ kind: 'anchor', target: `${target}#${hashPart}`, from: route });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass for anchors whose target page had not been read yet on the first pass.
|
||||
for (const [key, froms] of seen) {
|
||||
if (!key.includes('#')) continue;
|
||||
const [target, hash] = key.split('#');
|
||||
if (!routes.has(target)) continue;
|
||||
const ids = idsByRoute.get(target);
|
||||
if (!ids || ids.has(hash)) continue;
|
||||
for (const from of froms) {
|
||||
if (!problems.some((p) => p.kind === 'anchor' && p.target === key && p.from === from)) {
|
||||
problems.push({ kind: 'anchor', target: key, from });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const internalTargets = [...seen.keys()].sort();
|
||||
console.log(`Checked ${pages.length} pages, ${routes.size / 1} route spellings, ${internalTargets.length} distinct internal targets (${external} external links skipped).`);
|
||||
|
||||
if (process.env['LIST_TARGETS']) {
|
||||
for (const target of internalTargets) {
|
||||
const froms = [...seen.get(target)].sort();
|
||||
const shown = froms.length > 4 ? `${froms.slice(0, 4).join(', ')} +${froms.length - 4} more` : froms.join(', ');
|
||||
console.log(` ${target.padEnd(34)} <- ${shown}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log('No broken internal links.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const label = { missing: 'route not emitted', anchor: 'no such id on the target page', asset: 'file not in dist' };
|
||||
console.log(`\n${problems.length} problem(s):`);
|
||||
for (const p of problems) console.log(` [${label[p.kind]}] ${p.target} linked from ${p.from}`);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The build's front door to `check-i18n.mjs`.
|
||||
*
|
||||
* Imported by `astro.config.mjs` so a catalog problem is found before a single page
|
||||
* renders, rather than after a two-minute build produces 180 pages with a raw key on
|
||||
* them. Errors throw and stop the build; missing keys print and let it continue.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function checkCatalogs() {
|
||||
const result = spawnSync(process.execPath, [path.join(here, 'check-i18n.mjs')], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
// Warnings and the summary go straight through, so a missing-key list is visible in
|
||||
// the build log where whoever is adding a language will actually read it.
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
'check-i18n failed. Fix the keys listed above, or run `pnpm check:i18n` to see them again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Load the workspace .env into process.env.
|
||||
*
|
||||
* The API gets this from node's own --env-file-if-exists, but astro is launched by its
|
||||
* own binary, so the web side needs it done in-process. astro.config.mjs imports this
|
||||
* first, before it reads anything off process.env, and the build-time consumers
|
||||
* (web/src/lib/api.ts, web/src/lib/nostr-build.ts) run in the same process afterwards.
|
||||
*
|
||||
* Matching node's semantics: a variable already present in the real environment is left
|
||||
* alone, so `PORT=9000 pnpm dev` and systemd Environment= still win over the file.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Repo root first, then a web-local override if someone wants one. */
|
||||
const files = [path.join(webRoot, '..', '.env'), path.join(webRoot, '.env')];
|
||||
|
||||
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
||||
|
||||
function unquote(value) {
|
||||
const quote = value[0];
|
||||
if ((quote === '"' || quote === "'") && value.at(-1) === quote && value.length > 1) {
|
||||
const inner = value.slice(1, -1);
|
||||
return quote === '"' ? inner.replace(/\\n/g, '\n').replace(/\\"/g, '"') : inner;
|
||||
}
|
||||
// Unquoted: an inline comment ends the value.
|
||||
return value.split(/\s+#/)[0].trim();
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue; // No .env is a supported setup — every variable has a default.
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const match = LINE.exec(line);
|
||||
if (!match) continue;
|
||||
const [, name, raw] = match;
|
||||
if (name in process.env) continue;
|
||||
process.env[name] = unquote(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generates the social card and the icon set into `public/`.
|
||||
*
|
||||
* Run by hand after a brand change; the output is committed. This is deliberately not
|
||||
* wired into `pnpm build` — the site must build on a machine with no browser, and these
|
||||
* files change roughly never.
|
||||
*
|
||||
* node scripts/make-assets.mjs
|
||||
*
|
||||
* Playwright and sharp are not dependencies of this workspace. Both arrive transitively
|
||||
* under `astro` (which ships sharp for its image pipeline) and are resolved through it,
|
||||
* the same trick `bones.mjs` relies on for its capture browser. If that ever stops
|
||||
* working, `pnpm dlx playwright@1` and `pnpm dlx sharp-cli` do the same job.
|
||||
*
|
||||
* The card is drawn in a real browser rather than assembled by hand because it uses the
|
||||
* site's own fonts and tokens: a social card that does not look like the site is worth
|
||||
* less than no card. The moai is the same rect geometry as `components/Moai.astro`.
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const publicDir = path.join(root, 'public');
|
||||
const fontsDir = path.join(root, 'src', 'assets', 'fonts');
|
||||
|
||||
// astro's entry point; requiring from there reaches what pnpm hoisted under it.
|
||||
const require = createRequire(import.meta.resolve('astro'));
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
const sharp = require('sharp');
|
||||
|
||||
/* The pixel moai, identical to components/Moai.astro. viewBox is 11x13. */
|
||||
const MOAI_RECTS = [
|
||||
[2, 0, 7, 1, '#C9BFA4'],
|
||||
[1, 1, 9, 2, '#C9BFA4'],
|
||||
[1, 3, 2, 7, '#A89B7C'],
|
||||
[3, 3, 6, 7, '#C9BFA4'],
|
||||
[3, 4, 2, 1, '#5C5340'],
|
||||
[7, 4, 2, 1, '#5C5340'],
|
||||
[5, 5, 1, 3, '#A89B7C'],
|
||||
[2, 10, 8, 2, '#A89B7C'],
|
||||
];
|
||||
|
||||
const moaiSvg = (width) =>
|
||||
`<svg width="${width}" height="${Math.round((width / 11) * 13)}" viewBox="0 0 11 13" ` +
|
||||
`shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">` +
|
||||
MOAI_RECTS.map(
|
||||
([x, y, w, h, fill]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`,
|
||||
).join('') +
|
||||
`</svg>`;
|
||||
|
||||
const fontFace = (family, weight, file) => `
|
||||
@font-face {
|
||||
font-family: '${family}';
|
||||
font-style: normal;
|
||||
font-weight: ${weight};
|
||||
src: url('file://${path.join(fontsDir, file)}') format('woff2');
|
||||
}`;
|
||||
|
||||
/* The 1200x630 card. Design tokens copied from styles/global.css. */
|
||||
const cardHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
${fontFace('Space Grotesk', 700, 'space-grotesk-latin.woff2')}
|
||||
${fontFace('Inter', 400, 'inter-latin.woff2')}
|
||||
${fontFace('JetBrains Mono', 400, 'jetbrains-mono-latin.woff2')}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 1200px; height: 630px; background: #0E0D0B; color: #EDE8DC;
|
||||
font-family: 'Inter', sans-serif; display: flex; flex-direction: column;
|
||||
justify-content: center; padding: 0 88px; position: relative; overflow: hidden;
|
||||
}
|
||||
/* The same warm glow the hero carries, kept subtle so text stays legible. */
|
||||
body::after {
|
||||
content: ''; position: absolute; inset: auto -200px -320px auto;
|
||||
width: 720px; height: 720px; border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240,169,59,.16), transparent 68%);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 18px; margin-bottom: 40px; }
|
||||
.brand span {
|
||||
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 34px;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 76px;
|
||||
line-height: 1.06; letter-spacing: -.03em; max-width: 15ch;
|
||||
}
|
||||
h1 em { font-style: normal; color: #F0A93B; }
|
||||
p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-width: 34ch; }
|
||||
.rule { margin-top: 44px; width: 132px; height: 5px; background: #F0A93B; border-radius: 3px; }
|
||||
.host {
|
||||
position: absolute; bottom: 54px; right: 88px;
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 21px; color: #6B6555;
|
||||
}
|
||||
</style></head><body>
|
||||
<div class="brand">${moaiSvg(40)}<span>Cashumints.space</span></div>
|
||||
<h1>Find a Cashu mint <em>you can trust</em></h1>
|
||||
<p>Every mint on the Nostr network, with community reviews you can verify yourself.</p>
|
||||
<div class="rule"></div>
|
||||
<div class="host">cashumints.space</div>
|
||||
</body></html>`;
|
||||
|
||||
/* The icon master: the moai on the site's background, generous padding. */
|
||||
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body {
|
||||
width: 512px; height: 512px; background: #0E0D0B;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
</style></head><body>${moaiSvg(320)}</body></html>`;
|
||||
|
||||
/**
|
||||
* A 32x32 ICO wrapping a PNG payload.
|
||||
*
|
||||
* sharp cannot write ICO and the format needs no library for this case: the container
|
||||
* is a 6-byte directory header, one 16-byte entry, then the PNG bytes verbatim. Worth
|
||||
* having because bare `/favicon.ico` requests from crawlers and feed readers still
|
||||
* happen, and a 404 there is noise in the log for no reason.
|
||||
*/
|
||||
function icoFromPng(png) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); // reserved
|
||||
header.writeUInt16LE(1, 2); // type: icon
|
||||
header.writeUInt16LE(1, 4); // one image
|
||||
|
||||
const entry = Buffer.alloc(16);
|
||||
entry[0] = 32; // width
|
||||
entry[1] = 32; // height
|
||||
entry[2] = 0; // palette size: 0 means truecolour
|
||||
entry[3] = 0; // reserved
|
||||
entry.writeUInt16LE(1, 4); // colour planes
|
||||
entry.writeUInt16LE(32, 6); // bits per pixel
|
||||
entry.writeUInt32LE(png.length, 8);
|
||||
entry.writeUInt32LE(header.length + entry.length, 12);
|
||||
|
||||
return Buffer.concat([header, entry, png]);
|
||||
}
|
||||
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
await page.setContent(cardHtml, { waitUntil: 'load' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.screenshot({ path: path.join(publicDir, 'og.png') });
|
||||
console.log('og.png');
|
||||
|
||||
await page.setViewportSize({ width: 512, height: 512 });
|
||||
await page.setContent(iconHtml, { waitUntil: 'load' });
|
||||
const master = await page.screenshot({ type: 'png' });
|
||||
|
||||
for (const [name, size] of [
|
||||
['icon-512.png', 512],
|
||||
['icon-192.png', 192],
|
||||
['favicon-32.png', 32],
|
||||
]) {
|
||||
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
|
||||
console.log(name);
|
||||
}
|
||||
|
||||
// iOS ignores transparency and composites on white, so flatten onto the brand ground.
|
||||
await sharp(master)
|
||||
.resize(180, 180)
|
||||
.flatten({ background: '#0E0D0B' })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
|
||||
console.log('apple-touch-icon.png');
|
||||
|
||||
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
|
||||
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
|
||||
console.log('favicon.ico');
|
||||
|
||||
// The standalone SVG favicon: the same mark, at any size, for browsers that take it.
|
||||
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
|
||||
console.log('favicon.svg');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user