My portfolio and blog, live at https://murugappan.dev. It's an Astro site served by one Cloudflare Worker, which also runs the AI chat (Jarvis), a public API and an MCP server.
apps/site/ the Astro site
apps/api/ the Worker: chat, API, MCP, audio, 404
apps/tts/ makes the blog's read-aloud audio, run by hand on a Mac
apps/brand/ the X profile banners
packages/content/ the site's content, including blog posts
packages/contracts/ types and schemas the site and the Worker share
packages/utils/ small shared helpers
tooling/ lint and format config
More detail lives next to the code:
- Site: folder layout, analytics, the resume.
- Blog: writing a post, diagrams, tags.
- Read-aloud audio
- Worker: routes, the 404, Jarvis.
- Public API and MCP server
- Contracts and banners
- The site's public
/AGENTS.mdis the templatepackages/content/agent-guide.md.packages/content/agent-docs.tsfills its{{placeholders}}frompackages/contracts/mcp.tsand frompackages/content/generated/, whichapps/api/test/mcp.test.tswrites (refresh withbun run test -u).
bun install
bunx astro sync --root apps/site && bun run types # once, after cloning
bun run dev # site on :4399, Worker on :8787
bun run build # builds the site, then the Worker
bun run preview # serves the built site and Worker on :8787Things that will trip you up:
devandpreviewboth use port 8787. Stop one before starting the other.- In
dev, the Worker can't see the built pages, so the chat's page fetching and the 404 page don't work there. Usepreviewfor those. - To use the chat from
:4399, addPUBLIC_CHAT_HOST=localhost:8787toapps/site/.env. - Read-aloud audio in
devandpreviewcomes from the Worker's local R2, which starts empty, so the Listen button falls back to the browser voice. Copy a post's audio fromapps/apiwithbunx cf r2 objects get blog/breeze/<slug>.mp3 --bucket-name murugappan-dev-audio > <slug>.mp3, thenbunx cf r2 objects put blog/breeze/<slug>.mp3 --bucket-name murugappan-dev-audio --file <slug>.mp3 --content-type audio/mpeg --local --persist-to .cloudflare/state. Repeat for the.jsonwith--content-type application/json. previewruns the built Worker, so rebuild after changingapps/api/src/orpackages/content/.- Local settings go in
apps/site/.env(site) andapps/api/.dev.vars(Worker).
Packages import each other by name and full file path, like @murugappan/content/posts.ts. Inside an app, use the #src/* imports, plus #test/* for the Worker's test fixtures. Lint blocks ../ imports and controls which folder may import which (LAYERS in tooling/lint.config.ts). If lint blocks an import, the code probably belongs somewhere else. Logic both apps need goes in packages/content/ or packages/utils/.
Bun installs each package's dependencies into its own node_modules (linker = "isolated" in bunfig.toml), so a package can import only what its own package.json declares, workspace packages included. A missing declaration fails as "Cannot find package" or "failed to resolve import". The exception is the root's own dependencies: every package can still reach them, so declare those too, even though nothing fails without them.
.githooks/pre-push runs every check CI runs. Both call bun run check:static for the format, lint and typecheck gates. It regenerates the Worker types from apps/api/cloudflare.config.ts first, because lint and the typechecks read bindings from them. It needs two tools from Homebrew: brew install typos-cli uv.
- Run tests with
bun run test.bun teststarts Bun's own test runner, which is the wrong one. bun run test:e2erunsapps/api/e2e/againstbun run build's output: it serves the build withvite previewin thee2emode (apps/api/e2e/.dev.vars, no state on disk) on a free port and checks the served media types, Link headers, markdown negotiation, the links in the agent documents and the home page's JavaScript budget. Build first.- Before the first run, install Chromium with
bunx playwright install --only-shell chromiumand set up Python withuv sync --locked --project apps/tts. bun run test:liveruns the tests that call the real DeepSeek API. They cost money, so the normal run skips them.- Bun installs packages, but Node runs Astro,
cf, Vitest andtsc. - The type checker is a TypeScript 7.1 prerelease. Point your editor at the TypeScript 7 language service.
bun run check:effectruns Effect's diagnostics (@effect/tsgo) overapps/api. It fails on every Effect error and warning, and the plugin entry inapps/api/tsconfig.jsonraises more rules to errors. Without that entry it checks nothing and passes.- Each
@effect/tsgorelease supports only the TypeScript versions its README lists, sotypescriptis pinned to one of them. Renovate doesn't bumptypescript(renovate.json), so Renovate's@effect/tsgoPR fails until you settypescriptto a version the new@effect/tsgolists. - Oxlint and oxfmt cover TypeScript, JSON and Markdown. Oxfmt also formats
.astrotemplates and sorts their Tailwind classes. Biome lints.astrotemplates and lints and formats CSS, which oxc can't parse (biome.jsonc). tooling/oxlint/anti-slop/is a vendored subset of dmmulroy/anti-slop, owned and linted as repo code.UPSTREAM.mdthere names the commit it came from, says how to copy another rule in, and lists what differs from upstream.- Astro deletes a line break beside a tag or
{expression}, soon\n<a>renders ason<a>. Write the space as{" "}at the end of the line.tooling/astro-whitespace.test.tsfails on a line break that joins two words and on a space just inside an inline element. - In CSS, use the
--font-fira-codevariable, never"Fira Code". The build renames the font.
These env vars change the build. All are optional locally.
GITHUB_TOKEN: any token with public read access. It shows the GitHub card on the homepage.POST_HOG_TOKENandPOST_HOG_URL: turn on analytics.POSTHOG_API_KEYandPOSTHOG_PROJECT_ID: upload source maps for error tracking.RESUME_PHONE: adds a phone number to the resume.PUBLIC_TURNSTILE_SITE_KEY: the Turnstile site key the chat widget passes before it connects. A build in Workers Builds fails without it, because the deployed chat would refuse every visitor. See the Worker's README.
bun run build is cached, and the site build sees only the variables in apps/site/build-env.ts, so declare a new one there. A cached build keeps the GitHub card it fetched. Run bunx vp cache clean to fetch it again.
Chrome and Edge 123+, Firefox 128+ and Safari 17.5+, including iOS. Older browsers get sent to a notice page. Open /outdated/?from=/about/ to see it.
The floor comes from light-dark() colours, Tailwind 4 and the Popover API in the phone menu. If you use a newer feature, either make it fail gracefully or raise the floor. Raising it means changing MIN_VERSIONS and redirectIfOutdated in apps/site/src/lib/browser-support.ts together.
Every push to main deploys. Cloudflare Workers Builds does the deploy, and GitHub Actions only runs checks. Weekly jobs check links and run Lighthouse against the live site, and Renovate opens dependency updates.
The build settings live in the Cloudflare dashboard, not in this repo:
- Build command
bun run build. Deploy commandbun run deploy, which applies D1 migrations, then runscf deploywithapps/api/cloudflare.config.ts. - Build env vars: the ones above, plus
BUN_VERSION(matchpackageManagerinpackage.json) andREQUIRE_GITHUB_PROFILE=1to fail the build if the GitHub card can't load. - The Worker secrets, each a
bindings.secret()inapps/api/cloudflare.config.ts. Set each fromapps/apiwithread -rs VALUE && bunx cf workers secrets update <NAME> --worker murugappan-dev --type secret_text --text "$VALUE"; unset VALUE, which keeps the value out of shell history.
The contact inbox is OPPORTUNITY_INBOX in apps/api/cloudflare.config.ts. The EMAIL binding sends to the same address.
History starts at the Astro migration. Run git blame --ignore-revs-file .git-blame-ignore-revs so formatting sweeps don't hide the commit that changed a line.
- Design inspired by Soumyajit4419's Portfolio. The hero desk illustration is adapted from it.
- Originally based on developerFolio.