I built a site with 150+ browser-only dev tools (Astro + Vue 3 + TypeScript) — here's how it's put together

I built a site with 150+ browser-only dev tools (Astro + Vue 3 + TypeScript) — here's how it's put together

What this is Torinoa Tools is a side project I've been building solo: 152 developer utilities — Base64 conversion, JSON formatting, a regex tester, subnet calculators, AES encrypt/decrypt, and more — all in one site, all running entirely client-side. No server processing — input never leaves the browser Bilingual (Japanese/English) via Astro Content Collections Static site built with Astro + Vue 3 + TypeScript, also distributed as a Docker image Instead of a feature tour, this post is about a few implementation details I think are worth sharing: a build-time architecture fix, a Web Crypto–only encryption tool, and a small VLSM subnetting algorithm. The 755KB CSS chunk problem Early on, all 143 tools shared a single dynamic route, pages/tools/[slug].astro, and a "switchboard" component (ToolWidget.astro) statically imported all 143 Vue widgets and branched on which one to render. Because Vite extracts CSS from the entire dependency graph reachable from a route file, this meant one 755KB CSS bundle was shipped to all 286 pages (143 tools × 2 locales) — even though any single page only ever used one tool's worth of CSS. The root cause wasn't how the files were split — it was that multiple tools shared one route file at all. The fix: generate a fully independent page file per tool (a separate module, from Vite's point of view) before every build. // scripts/generate-tool-pages.mjs (excerpt) function renderJaPage({ slug, component, importPath }) { const shellImport = shellImportPath(JA_OUT_DIR); const widgetImport = resolveWidgetImportPath(importPath, JA_OUT_DIR); return `--- import { getCollection } from "astro:content"; import ToolPageShell from "${shellImport}"; import ${component} from "${widgetImport}"; const entries = await getCollection( "tools", ({ id }) => id.split("/").pop()?.replace(/\\.md$/, "") === "${slug}", ); const entry = entries[0]; --- `; } Enter fullscreen mode Exit fullscreen mode src/data/tool-widgets.json holds the single source of truth — slug → { componentName, importPath } — and the script regenerates every .astro page from it in a predev / prebuild step. Generated files aren't committed; shared layout logic lives in ToolPageShell.astro so nothing is duplicated per-tool. Result: CSS chunks went from a shared 755KB down to under 16KB per page. Adding a new tool is now just one entry in tool-widgets.json — page generation, CSS splitting, and i18n consistency checks all run automatically from there. An AES tool built only on Web Crypto No crypto library dependency — AES-GCM / AES-CBC encryption and decryption run entirely on the browser's native SubtleCrypto. Keys are derived from a passphrase using PBKDF2 (SHA-256, 100,000 iterations), and the salt, IV, and algorithm marker are packed together with the ciphertext into a single self-contained Base64 blob. const PBKDF2_ITERATIONS = 100_000; async function deriveKey( passphrase: string, salt: Uint8Array, algorithm: AesAlgorithm, keySize: AesKeySize, ): Promise { const enc = new TextEncoder(); const baseKey = await crypto.subtle.importKey( "raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, baseKey, { name: algorithm, length: keySize }, false, ["encrypt", "decrypt"], ); } Enter fullscreen mode Exit fullscreen mode The final blob is [algorithm byte, keySize byte] + salt + IV + ciphertext, Base64-encoded, so decryption only ever needs the passphrase — nothing else has to be typed in again. AES-GCM's authentication tag doubles as tamper detection: if the passphrase is wrong or the data was altered, crypto.subtle.decrypt simply throws, with no extra integrity-checking code needed. This combined format is intentionally self-contained rather than OpenSSL-compatible — it's designed so the tool can decrypt what it encrypted, not to interoperate with external crypto tooling. VLSM packing for the subnet calculator One of the more niche tools does CIDR subnetting, including VLSM (Variable Length Subnet Masking). Given a list of required host counts, it sorts them descending, computes the needed host bits per subnet with ceilLog2(hosts + 2), and greedily packs each subnet aligned to its own boundary. const sorted = [...reqs].sort((a, b) => b - a); // pack largest requirements first for (const hosts of sorted) { const hostBits = ceilLog2(hosts + 2); // +2 for network and broadcast addresses const subnetSize = Math.pow(2, hostBits); const alignedNum = (Math.ceil(currentNum / subnetSize) * subnetSize) >>> 0; if (alignedNum + subnetSize - 1 > networkNum + parentTotal - 1) { // doesn't fit in the parent network — surface an error } // ... push subnet info, advance currentNum } Enter fullscreen mode Exit fullscreen mode If the requested subnets don't fit in the parent network, it fails explicitly rather than silently truncating — so you find out immediately whether your host-count list actually fits. Auto-restoring input with localStorage (and a bug pattern worth knowing) Almost every tool restores its input after you close the tab, via a small useLocalStorage composable: export function useLocalStorage( key: string, defaultValue: T, options: { debounceMs?: number; syncAcrossTabs?: boolean } = {}, ): Ref { const storageKey = `torinoa:${key}`; const state = ref(defaultValue) as Ref; if (typeof window !== "undefined") { const raw = window.localStorage.getItem(storageKey); if (raw !== null) state.value = JSON.parse(raw) as T; } // ... debounced writes back to localStorage return state; } Enter fullscreen mode Exit fullscreen mode Astro's SSG build has no window, so the guard just falls back to a plain in-memory ref during the build. Sensitive tools — password generator, JWT tools, cookie parser, HMAC generator — deliberately skip this auto-restore behavior entirely. One recurring bug worth naming: restore logic would be fully implemented in the script, but the corresponding element was simply missing from the template — the feature worked, but was invisible to users. It happened often enough across widgets that "restore logic exists AND its UI element actually renders" is now a standing thing I check for, not just something I assume works because the code is there. Enforcing bilingual parity Japanese and English tool content live in separate Astro Content Collections (src/content/tools/ and src/content/tools-en/). A script, check-i18n-en-parity.mjs, runs before every build and checks: Matching Markdown file counts between ja/en Matching slug counts between tools.json and tools.ts Matching category counts Registering one new tool touches exactly four places — tools.json, tools.ts, and the ja/en Markdown files — and the parity script blocks the build if any of them are out of sync. Self-hosting via Docker Pre-built images are pushed to ghcr.io/mi8bi/torinoa-tools via GitHub Actions CI, tagged with latest, semver, and commit SHA. Since it's a fully static site, it's straightforward to self-host anywhere, including networks that can't reach the public internet. Closing thoughts The thing that's made maintaining 152 tools solo actually feasible is turning "remember to do X when adding a tool" into a script that enforces it — auto-generating pages instead of hand-writing switchboards, a parity checker instead of a mental checklist, catching "restore logic exists but its UI element doesn't" as a named pattern instead of a one-off bug. Scripts catching mistakes beats discipline, basically every time. Site: https://tools.torinoa.com Happy to answer questions about any of this in the comments.

Original Source

Read the full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.