Architecture, in one picture
At a glance
| Type | Static, multi-page website with small interactive “islands” |
| Framework | Astro (static output, islands architecture) |
| Language | TypeScript (strict) |
| Styling | Tailwind CSS v4, single generated theme layer |
| Charts | Chart.js 4, lazy-loaded only where used |
| Tests | Vitest — pure calculation logic, verified against known values |
| Hosting | Cloudflare Pages (static edge) |
| CI/CD | GitHub Actions → wrangler pages deploy |
| Performance | Lighthouse 100 / 100 / 100 / 100 (mobile, preview build) · ~4 KB first-load JS |
| Currency | INR (₹) only, Indian digit grouping (lakh / crore) |
Tech stack
Core
| Concern | Choice | Notes |
|---|---|---|
| Site framework | Astro 7 | output: 'static'; islands hydrate individually; zero JS by default |
| Language | TypeScript 6, strict |
astro check in CI gate |
| Styling | Tailwind CSS v4 via @tailwindcss/vite |
@theme tokens; no config file; one generated theme partial |
| Content | Astro Content Collections | one JSON file, Zod-validated schema, file() loader |
| Charts | Chart.js 4 | dynamic import() inside a single client:visible-style island, gated by IntersectionObserver |
| Sitemap | @astrojs/sitemap | calculator pages boosted in priority |
| Unit tests | Vitest (+ v8 coverage) | math layer only — no DOM, no framework |
| Icon / OG generation | sharp | build-time raster generation from vector source |
Tooling & delivery
| Concern | Choice |
|---|---|
| Package manager | npm (package-lock.json, npm ci in CI) |
| Runtime (build) | Node ≥ 22.12 (CI pinned to 22.19) |
| Hosting | Cloudflare Pages, custom domain, trailingSlash: 'always' |
| Deploy | GitHub Actions: npm ci → astro check → vitest → astro build → wrangler pages deploy |
| Edge headers | public/_headers — security headers + immutable caching for hashed assets |
Deliberately not used
No backend framework, no database, no ORM, no auth, no state store, no CSS-in-JS runtime, no web fonts (system font stack), no client-side router. Interactivity is progressive enhancement layered on static HTML.
Architecture
Principles
- Static & crawlable. Every page renders its full content — headings, formulas, definitions, worked examples, FAQs — as raw HTML with no hydration required. Interactive widgets are an enhancement, never a prerequisite for the information.
- All math on the client. Calculations run in the browser. Nothing the visitor types is transmitted, stored, or logged.
- Pure calculation core. Financial formulas live in a UI-free library of pure functions, each unit-tested against an independently verifiable value before it is wired to a page.
- SEO + GEO first. Semantic HTML, one
<h1>, clean URLs,sitemap.xml,robots.txtthat explicitly welcomes AI answer-engine crawlers, andschema.orgstructured data on every page. - Core Web Vitals as a budget. Ship the smallest possible JS; reserve space for late-loading widgets so layout never shifts.
Repository structure
src/
pages/
index.astro # home / landing
calculators/
index.astro # calculator directory
mortgage-calculator.astro # one .astro file per calculator page
robots.txt.ts # generated robots.txt (AI crawlers allow-listed)
layouts/
BaseLayout.astro # <head>, meta, Open Graph / Twitter, JSON-LD
components/
CalculatorLayout.astro # shared calculator shell: header, footer,
# breadcrumb, ad slots, schema, related list
MortgageCalculator.astro # the interactive island (form + results + charts)
Faq.astro # FAQ list + matching FAQPage JSON-LD
SiteHeader / SiteFooter / AdSlot / Logo
lib/
finance/
mortgage.ts # PURE math: payment, amortization schedule, extras
mortgageView.ts # maps calc results -> display-ready view model
format/
currency.ts # the ONLY place Intl / en-IN formatting lives
categories.ts # calculator category grouping + labels
content/
calculators.json # one record per calculator (drives head/schema/lists)
content.config.ts # Zod schema for the collection
styles/
global.css # Tailwind entry + base layer + component classes
theme.generated.css # AUTO-GENERATED theme (do not edit)
tests/
mortgage.test.ts # known-value validation of the finance core
currency.test.ts # formatting edge cases
themes/
ledger-desk.mjs # theme preset — warm paper retro
graphite.mjs # theme preset — neutral dark grey
midnight.mjs # theme preset — code-editor dark, blue accent
theme.config.mjs # one-line selector: which preset is active
scripts/
gen-theme.mjs # preset -> theme.generated.css + logo/favicon/OG SVGs + manifest
gen-icons.mjs # preset -> raster icons + og.png (sharp)
public/ # static assets, _headers, generated icons
Render & data flow
calculators.json + Zod schema
│ getCollection
▼
Astro page (.astro)
│
▼
CalculatorLayout.astro ──────────► BaseLayout.astro
│ head · OG/Twitter · JSON-LD
├──► Static explanatory HTML (formula, terms, worked example, FAQ)
│
└──► MortgageCalculator island
▲ │
│ server render │ hydrated
src/lib/finance/*.ts ▼
(pure functions) Client recalculation on input, in-browser
│ IntersectionObserver
▼
Chart.js loaded on demand
theme.config.mjs -> themes/*.mjs ──npm run gen:brand──► theme.generated.css
+ logo / favicon / OG
- The content collection (
calculators.json, validated by a Zod schema) is the single source for each calculator’s<title>, meta description, keywords, FAQ entries, related-calculator list, and JSON-LD. - Each page composes
CalculatorLayout→BaseLayout. The layout server-renders the finance functions once to produce the initial numbers, table, and chart data, so the page is useful before any JavaScript runs. - The calculator island is the only hydrated component. On input it re-runs the same pure functions in the browser and updates the DOM.
- Chart.js (~68 KB gzip) is a separate chunk, dynamically imported the first time the chart scrolls near the viewport.
Data model — calculators.json
{
"slug": "mortgage-calculator", // -> /calculators/mortgage-calculator/
"title": "Mortgage Calculator",
"shortTitle": "Mortgage",
"category": "home-loan",
"description": "… meta description, <= 160 chars …",
"keywords": ["mortgage calculator", "home loan emi", "…"],
"featured": true,
"faqs": [{ "question": "…", "answer": "… plain text / minimal inline HTML …" }],
"related": ["amortization-calculator", "auto-loan-calculator"]
}
Key engineering details
1 · Zero-hydration content, one small island
The calculator page ships ~4 KB gzip of first-load JavaScript — just the island that wires up the form. Everything a search engine, screen reader, or AI answer engine needs (the formula, the definitions, a worked example, the FAQ, the initial results, the amortization table) is in the server-rendered HTML. Chart.js loads only on demand and only if the user reaches the chart.
2 · Pure, tested finance core
src/lib/finance/** contains pure functions only — plain numbers in, plain
numbers/objects out. No Intl, no DOM, no rounding for display; full precision
is preserved and rounding happens only at the presentation edge
(src/lib/format/currency.ts, the single home of en-IN formatting).
Every formula has a Vitest test asserting it against an independently
calculable value (e.g. a known EMI for a given principal / rate / term, the
final-payment adjustment that drives the balance to exactly zero) before it
is used on a page. astro check and vitest are both required-green in CI.
3 · Single-source theming
The entire visual identity — palette, corner radii, font stacks, chart colours, logo, favicons, Open Graph image, PWA manifest colours — resolves to one file.
themes/<name>.mjs # a complete theme preset (all values)
theme.config.mjs # one line: which preset is active
│
▼ npm run gen:brand (also runs automatically before every build)
│
src/styles/theme.generated.css # Tailwind @theme block + :root semantic vars
public/{favicon,logo,logo-mono-*}.svg
public/{icons, og.png, favicon.ico} # rasterised with sharp
public/site.webmanifest
- Tailwind’s
slate-*/brand-*scales are remapped in the generated@themeblock, so hundreds of existing utility classes re-skin with no markup changes. - A small set of semantic custom properties (
--surface,--ink,--shadow,--accent,--accent-text,--mark-stroke,--chart-1..6,color-scheme, …) covers the things Tailwind utilities can’t reach: the inline logo SVG, the hard-shadow colour, and the Chart.js palette (read once viagetComputedStylewhen the chart initialises). - Three presets ship: ledger-desk (warm-paper retro), graphite (neutral
dark grey), midnight (near-black, editor blue). Switching is a one-line
edit plus
npm run gen:brand. Runtime cost of the system is zero beyond ordinary CSS variables.
4 · SEO, GEO & structured data
- Semantic document outline, single
<h1>, ordered headings, clean slash-terminated URLs. sitemap-index.xmlgenerated at build; individual calculator pages get a priority boost over index pages.robots.txtis generated (src/pages/robots.txt.ts) and explicitly allow-lists answer-engine and model crawlers —GPTBot,ClaudeBot,PerplexityBot,Google-Extended,Applebot-Extended,CCBot, and others — so GEO visibility is unambiguous.- JSON-LD on every surface:
WebApplication+BreadcrumbListon calculator pages,FAQPagewherever an FAQ renders (generated from the same source as the visible list, so they can’t drift),WebSiteon the home page. - Full Open Graph + Twitter Card tags, including image dimensions and alt text; the OG image itself is regenerated per theme.
5 · Performance & Core Web Vitals
- Lighthouse 100 / 100 / 100 / 100 (Performance / Accessibility / Best Practices / SEO) on a mobile-emulated preview build.
- System font stack — no web-font download, no render-blocking requests.
- First-load JS ≈ 4 KB gzip (island only). Chart.js ≈ 68 KB gzip is a separate, lazily-imported chunk.
- Ad-slot containers are reserved at their real dimensions from day one, so filling them later causes no cumulative layout shift.
public/_headerssetsCache-Control: public, max-age=31536000, immutablefor content-hashed/_astro/*assets, plusX-Content-Type-Options,Referrer-Policy,X-Frame-Options, and a minimalPermissions-Policy.
6 · CI/CD & hosting
Push to main triggers GitHub Actions:
npm ci → npm run check (astro check) → npm test (vitest) → npm run build → wrangler pages deploy dist
prebuildregeneratestheme.generated.cssfrom the active preset, so a deploy can never ship a stale theme.wrangleris pinned as a dev dependency (installed by the cachednpm ci) so the publish step never does a fresh, flakynpm i wrangleron the runner.- Output is a fully static
dist/served from Cloudflare’s edge.trailingSlash: 'always'matches Cloudflare Pages’ canonical URL form, so there are no redirect hops on canonical links, the sitemap, or internal links.
Local development
npm install
npm run dev # Astro dev server
npm test # Vitest (watch: npm run test:watch)
npm run check # astro check (types + template diagnostics)
npm run build # static build -> dist/
npm run preview # serve the production build locally
npm run gen:brand # regenerate theme CSS + logo/favicon/OG from the active preset
Switching theme: edit the single re-export line in theme.config.mjs to
point at another file in themes/, run npm run gen:brand, commit.
Currency is INR-only by design; a multi-currency mode is deliberately out of
scope. All monetary display uses Intl.NumberFormat('en-IN', …) for lakh/crore
grouping.