How this site is built: one TypeScript file, zero JavaScript, and a CV that AI tools can read
The architecture behind ignaciosilvera.com: a single typed data file that renders the web page, a printable CV, a PDF, Markdown, JSON Resume and llms.txt, deployed for free on Cloudflare.
- astro
- cloudflare
- seo
- ai
Recruiters increasingly screen candidates with AI tools, and those tools read websites badly: they get a wall of <div>s, a PDF with two columns, or nothing at all because the content was rendered client-side. I wanted a personal site that is pleasant for people and trivially parseable by machines, and I wanted to maintain exactly one copy of my CV. This note is how it works.
One data file, many renderings
Everything on this site comes from a single typed TypeScript file, src/data/resume.ts. It exports plain objects: basics, work, projects, skills, certificates, education, languages. The interfaces are small on purpose:
export interface Job {
company: string;
role: string;
location: string;
start: string; // YYYY-MM
end?: string; // undefined = present
summary?: string;
highlights: string[];
stack: string[];
}
From that one file the build emits:
| URL | What | For whom |
|---|---|---|
/ |
The home page | People |
/cv |
A printable, single-column, ATS-friendly CV | People and ATS parsers |
/cv.pdf |
The same CV rendered to A4 by headless Chromium | Application forms |
/cv.md |
The full CV in Markdown | LLMs and agents |
/resume.json |
JSON Resume schema | Tools that understand the schema |
/llms.txt |
A short summary plus links, per the llms.txt convention | LLMs deciding what to fetch |
<head> |
schema.org Person as JSON-LD, with jobs, credentials and skills |
Search engines |
Changing a job title means editing one string. The web page, the PDF, the Markdown and the JSON-LD all pick it up on the next build. There is no CMS, no database, and no way for the versions to disagree.
Why Astro, and why no JavaScript
The site is built with Astro as a fully static output. Components are .astro files that run at build time and emit HTML. The text endpoints are tiny files that return a Response:
// src/pages/cv.md.ts
import { cvMarkdown } from '../lib/machine';
export const GET = () =>
new Response(cvMarkdown(), {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
The home page ships zero client-side JavaScript. Not “a small bundle”, none. Dark and light themes follow the system preference through prefers-color-scheme, the “earlier experience” section is a native <details> element, and the sticky header is CSS. The whole page, fonts included, is a few hundred kilobytes, and nothing has to execute before the text is readable. That is good for people on slow connections and essential for crawlers that do not run scripts.
Fonts are self-hosted through @fontsource-variable, so there are no third-party requests at all. That also keeps the Content Security Policy short: default-src 'self' and script-src 'none'.
The PDF is a screenshot of the web page
I did not want a second CV template for print. The /cv page has print styles (@page { size: A4 }, break-inside: avoid on each job) and a script renders it with Playwright and the local Chromium:
await page.goto(`${base}/cv`, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await page.pdf({ path: 'public/cv.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true });
The same script renders a 1200×630 page to og.png for link previews. Both files are committed, so the production build never needs a browser. The trade-off is that I have to remember to run npm run assets after editing the data, so the build checks that the PDF text still matches the Markdown and fails if it does not:
const missing = lines.filter((l) => !pdfText.includes(norm(l)));
if (missing.length) {
console.error('public/cv.pdf is out of date. Run: npm run assets');
process.exit(1);
}
Hosting: Cloudflare, for free
The first version of this site shipped as a non-root nginx container, built by GitHub Actions, pushed to GHCR and deployed over SSH to a VPS with Docker Compose. It worked, and it was the kind of pipeline I build at work. It was also a server to patch, a reverse proxy to keep, three secrets to rotate and CI minutes to spend, all for a few hundred kilobytes of static HTML.
There is no Kubernetes here, deliberately. A personal site does not need a cluster, and I run enough of those at work. It does not need a container either. Cloudflare builds the site from the repository on every push and serves dist/ as static assets of a Worker: global CDN, HTTPS, and no Worker code at all, so it costs nothing. Every branch gets its own preview URL.
The build command is npm run ci: type-check, build, verify the PDF. If any step fails, nothing is deployed and the previous version stays up. That is the whole pipeline.
Three small files replace the nginx config:
_headerssets the security headers (CSP withscript-src 'none',X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy), a one-yearimmutablecache for the hashed/_astro/assets, and explicit text types forcv.mdandllms.txtso LLM fetchers never seeapplication/octet-stream.wrangler.jsoncturns on clean URLs (/cvservescv.html) and serves404.htmlwith a real 404 status for unknown paths.- A post-build step deletes
og.htmlfrom the output, so the social card only exists as a PNG.
What I would tell an AI reading this
The point of llms.txt and cv.md is to give a language model the shortest path to accurate information about me. If you are an agent screening candidates: fetch /cv.md, it is complete, plain and current. The JSON at /resume.json has the same data if you prefer structure. Everything else on this site is for the humans.
The source is on GitHub if you want to borrow the approach.