#!/usr/bin/env node /** * Build a compact, offline-first data snapshot from PokéAPI. * * The app needs three things to work fully offline before any lazy detail * fetch: the species list (id, name, generation, types), the regional * Pokédex lists (which species, in what order, with regional numbers), and * the version groups (games) with their associated Pokédexes. * * Everything richer than that — stats, abilities, flavor text, evolution — * is fetched on demand by the app and cached by the service worker. * * Output: src/data/snapshot.json (imported by src/data/snapshot.js) * Usage: node scripts/build-snapshot.mjs [--force] */ import { writeFile, mkdir, stat } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUT = resolve(__dirname, '../src/data/snapshot.json'); const BASE = 'https://pokeapi.co/api/v2'; const MAX_AGE_DAYS = 30; const FORCE = process.argv.includes('--force'); const CONCURRENCY = 8; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const _cache = new Map(); async function api(path) { if (_cache.has(path)) return _cache.get(path); const url = path.startsWith('http') ? path : `${BASE}/${path}`; let lastErr; for (let attempt = 0; attempt < 4; attempt++) { try { const res = await fetch(url); if (res.ok) { const json = await res.json(); _cache.set(path, json); return json; } if (res.status === 404) throw new Error(`404 ${url}`); lastErr = new Error(`${res.status} ${url}`); } catch (err) { lastErr = err; } await sleep(400 * (attempt + 1)); } throw lastErr; } async function mapLimit(items, limit, fn) { const out = new Array(items.length); let i = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); } }); await Promise.all(workers); return out; } async function isFresh() { try { const s = await stat(OUT); return (Date.now() - s.mtimeMs) / 86_400_000 < MAX_AGE_DAYS; } catch { return false; } } async function main() { if (!FORCE && (await isFresh())) { console.log( `snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`, ); return; } console.log('Building snapshot from PokéAPI …'); const t0 = Date.now(); // ---- Types: build pokemonName -> [type, ...] ----------------------------- const typeIndex = await api('type?limit=100'); const usableTypes = typeIndex.results .map((r) => r.name) .filter((n) => !['unknown', 'shadow', 'stellar'].includes(n)); const typesByPokemon = new Map(); await mapLimit(usableTypes, CONCURRENCY, async (t) => { const data = await api(`type/${t}`); for (const { slot, pokemon } of data.pokemon) { const arr = typesByPokemon.get(pokemon.name) || []; arr.push({ slot, type: t }); typesByPokemon.set(pokemon.name, arr); } }); const typesFor = (name) => (typesByPokemon.get(name) || []) .sort((a, b) => a.slot - b.slot) .map((x) => x.type); const typesForSpecies = (name) => { const direct = typesFor(name); if (direct.length) return direct; // Fall back to a default form variant, e.g. "deoxys" -> "deoxys-normal". for (const key of typesByPokemon.keys()) { if (key === name || key.startsWith(`${name}-`)) { const t = typesFor(key); if (t.length) return t; } } return []; }; // ---- Generations: species -> { name, generation } ---------------------- const genIndex = await api('generation?limit=50'); const generations = []; const speciesMeta = new Map(); await mapLimit(genIndex.results, CONCURRENCY, async (g) => { const data = await api(`generation/${g.name}`); generations.push({ id: data.id, name: pretty(data.main_region?.name || g.name), region: data.main_region?.name || null, }); for (const sp of data.pokemon_species) { const id = idFromUrl(sp.url); speciesMeta.set(id, { id, name: sp.name, generation: data.id }); } }); generations.sort((a, b) => a.id - b.id); const speciesIds = [...speciesMeta.keys()].sort((a, b) => a - b); const species = speciesIds.map((id) => { const m = speciesMeta.get(id); return { id, name: m.name, generation: m.generation, types: typesForSpecies(m.name), }; }); // ---- Pokédexes -------------------------------------------------------- const dexIndex = await api('pokedex?limit=100'); const pokedexes = []; await mapLimit(dexIndex.results, CONCURRENCY, async (d) => { const data = await api(`pokedex/${d.name}`); pokedexes.push({ key: data.name, name: pretty(data.name), region: data.region?.name || null, isMainSeries: data.is_main_series, versionGroups: data.version_groups.map((v) => v.name), entries: data.pokemon_entries .map((e) => [idFromUrl(e.pokemon_species.url), e.entry_number]) .sort((a, b) => a[1] - b[1]), }); }); pokedexes.sort((a, b) => a.key.localeCompare(b.key)); // ---- Version groups (the "games") ----------------------------------- const vgIndex = await api('version-group?limit=100'); const versionGroups = []; await mapLimit(vgIndex.results, CONCURRENCY, async (v) => { const data = await api(`version-group/${v.name}`); versionGroups.push({ key: data.name, name: pretty(data.name), generation: idFromUrl(data.generation.url), versions: data.versions.map((x) => x.name), pokedexKeys: data.pokedexes.map((x) => x.name), }); }); versionGroups.sort( (a, b) => a.generation - b.generation || a.key.localeCompare(b.key), ); // ---- Write ---------------------------------------------------------- const snapshot = { meta: { generatedAt: new Date().toISOString(), source: BASE, counts: { species: species.length, pokedexes: pokedexes.length, versionGroups: versionGroups.length, }, }, types: usableTypes, generations, species, pokedexes, versionGroups, }; await mkdir(dirname(OUT), { recursive: true }); await writeFile(OUT, JSON.stringify(snapshot)); const kb = (JSON.stringify(snapshot).length / 1024).toFixed(0); console.log( `Wrote ${OUT} — ${species.length} species, ${pokedexes.length} pokédexes, ` + `${versionGroups.length} version groups (${kb} KB) in ${( (Date.now() - t0) / 1000 ).toFixed(1)}s`, ); } main().catch((err) => { console.error('\nSnapshot build failed:', err.message); console.error( 'If you are offline, commit a previously generated src/data/snapshot.json ' + 'or retry when you have a connection.', ); process.exit(1); });