Diego Said's site: essays in Spanish with their English translations, plus a CV — a statically rendered Astro site with an academic, LaTeX-inspired visual language. Every page ships as complete HTML with no client-side JavaScript. Hosted on Cloudflare Pages.
Live: https://diegosaid.com
The site is built so that the content exists in the HTML, not in a bundle that has to run first. That constraint drives most of the decisions here.
- Zero client JavaScript. No framework runtime, no hydration, no router. The build emits HTML, CSS, and images; the only script on the page is the analytics beacon. Crawlers, link-preview bots, and text extractors see the full content on first fetch.
- Build-time rendering for everything dynamic. Syntax highlighting runs through Shiki and math through KaTeX, both at build time — the browser receives styled markup and a stylesheet, never a highlighter or a formula parser.
- Content as data. Posts are Markdown in a typed content collection (
src/content.config.ts); the schema is enforced at build, and/blogderives its index from the collection rather than a hand-maintained array that can drift from the posts themselves. - Per-route metadata.
BaseLayout.astroemits<title>, description, canonical, Open Graph and article tags, and JSON-LD per page (PersonandWebSiteon the home page,BlogPostingon each post; seesrc/lib/schema.ts), so a shared post preview shows that post. - Readable by machines too. Each post is also served as plain Markdown at its URL with
.mdin place of the trailing slash, and/llms.txtlists them for language models.robots.txtwelcomes search engines and on-demand assistants and turns away training crawlers, matching what Cloudflare already enforces at the edge. - Generated OG images.
src/pages/og/[...route].png.tsrenders a 1200×630 card per route at build time with satori and resvg, using the site's own typography and palette. - Security headers. CSP, HSTS,
X-Content-Type-Options,Referrer-Policy, andPermissions-Policyare served frompublic/_headers. The CSP allows no inline script, which the markup respects. - Self-hosted fonts. Source Serif 4 and IBM Plex Mono come from
@fontsourceand ship from this domain, so no third party sits in front of the first paint.
| Concern | Choice |
|---|---|
| Framework | Astro 7 (static output) + TypeScript 5.9 |
| Content | Markdown via content collections, MDX enabled |
| Styling | Tailwind CSS 3 via PostCSS |
| Highlighting | Shiki (github-light / github-dark), build time |
| Math | remark-math + rehype-katex, build time |
| OG images | satori + resvg, build time |
| Hosting | Cloudflare Pages via Wrangler |
npm install
npm run dev # http://localhost:4321| Script | Purpose |
|---|---|
npm run dev |
Astro dev server with HMR. |
npm run build |
Build the static site to dist/. |
npm run check |
Type-check .astro and .ts files. |
npm test |
Check that the built site is fully bilingual (build first). |
npm run preview |
Serve the production build locally. |
npm run deploy |
Build, check, test, then upload dist/ to Cloudflare Pages. |
npm run deploy:prod |
Push main, then build and deploy. |
The site is bilingual and Spanish comes first: Spanish at the root, English under /en/. The essays are written in Spanish; the English versions are translations. Every route below exists in both trees, and the ES / EN switch in the navbar links each page to its counterpart. It is a plain link, so the site still ships no client JavaScript.
/,/en/— Home: who Diego is, a line of credentials each linked to its evidence, what he is doing now (dated; update it insrc/i18n/home.ts), the three latest essays, a pointer to the CV, and contact./cv/,/en/cv/— CV: summary, experience, open source and publications, education and honors, skills, contact./blog/,/en/blog/— Writing index, generated from that language's posts: every post, newest first, featured ones marked with a small star, with a switch to read it oldest first. The switch is two radio buttons and a CSS:has()rule./blog/<slug>/,/en/blog/<slug>/— Individual posts, with a contents list when a post has three or more sections, an author note, and links to the previous and next posts./blog/<slug>.md,/en/blog/<slug>.md— The same posts as plain Markdown./rss.xml,/en/rss.xml— One feed per language. Items carry the full post, except posts with math, which carry the excerpt and a link: KaTeX does not survive a feed reader./llms.txt— The site in brief for language models./og/<route>.png,/og/en/<route>.png— Generated Open Graph image per route.
Pages carry hreflang alternates (with x-default on the Spanish page), and the sitemap pairs /x/ with /en/x/ and dates each URL. Cloudflare Pages serves en/404.html for missing paths under /en/; a small hook in astro.config.mjs moves it there, since Astro writes nested 404 pages as 404/index.html. The move to Spanish-first in October 2026 left redirects in public/_redirects: /es/* goes to the root.
src/
assets/fonts/ Source Serif 4 TTFs, used only by the build to draw OG images
components/ Navbar (with the language switch), Footer, Contact, PostItem, Tags
content/blog/ Posts as Markdown with typed frontmatter, one folder per language (en/, es/)
content.config.ts Collection schema
i18n/ Languages, path helpers, chrome strings and profiles; home.ts and cv.ts hold those pages in both languages; feed.ts and markdown.ts build the feeds and .md versions
layouts/ BaseLayout (head + chrome), BlogPost
lib/ Date strings, scheduling, JSON-LD, the contents list
pages/ Thin route entry points per language, plus the OG image endpoint
views/ The pages themselves, rendered once per language
styles/global.css Tailwind layers, article typography, focus rings
public/ Static assets, _headers, _redirects, robots, manifest
Create src/content/blog/es/<slug>.md or src/content/blog/en/<slug>.md with the frontmatter the schema requires — title, subtitle, excerpt, date, readMinutes, tags. The folder is the language: it sets <html lang>, og:locale, the header labels and the URL (/blog/<slug>/ or /en/blog/<slug>/). A post that annotates an older document can set origin: to a sentence saying where it comes from; it shows under the header, and its translation must carry one too. The post is listed on that language's index and feed, enters the sitemap, and gets an OG image, with no other file to touch.
Set featured: true to mark a post with a small star on /blog/; a translation must carry the same flag.
A post dated in the future is scheduled: it stays out of the pages, index, feeds, sitemap and OG cards until that date in Mexico City, and the deploy workflow rebuilds every day at 00:10 Mexico City time to publish it. npm run dev shows scheduled posts anyway, marked on the index, so they can be read before their date. The rule lives in src/lib/schedule.ts.
A translation is the same file name in the other folder; the two link to each other through the navbar switch and hreflang. If the translation needs its own slug, set key: in its frontmatter to the original's file name. Until a post has a translation, the switch on it leads to the other language's /blog/. Links between posts point inside the same tree (/en/blog/adr47/ from an English post).
Write math as $inline$ or a $$ block with the delimiters on their own lines. Posts are .md because MDX would parse LaTeX braces as JSX expressions; .mdx still works for a post that genuinely needs a component.
tests/bilingual.test.mjs runs on Node's built-in test runner with no dependencies. Nothing in it is listed by hand: a new post or page is covered as soon as it exists, and fails until its translation does.
- Source. Every post in
en/has a translation ines/and the reverse, keys are unique, and each pair shares a date, a tag count, itsfeaturedflag and whether it has anorigin. The CV has the same entries in both languages. - Build. Every page in
dist/hashreflanglinks to both languages, the targets exist and point back,<html lang>matches the tree, and the navbar switch leads to the translation rather than a fallback. Each translation keeps the original's skeleton (headings, code blocks, math, tables, lists, images), so a section dropped in translation fails. Links inside a page stay in its language, except a link to the page's own translation. Every page has its own OG card, each language has its own 404, both feeds carry every published post, and no scheduled post is built before its date.robots.txtnames a sitemap that exists, every post has its.mdversion listed inllms.txt, and every post carries validBlogPostingJSON-LD. - Types.
UI.esis typed against the English keys, so a chrome string added in one language only failsnpm run check.
CI runs the type check and the tests after the build and before the deploy, so a post published in one language does not ship until its translation exists.
npm run deploy:prod # push to GitHub, build, deploy
npm run deploy # build and deploy without pushingPushes to main also build and deploy through .github/workflows/deploy.yml, which also runs every day at 00:10 Mexico City time to publish scheduled posts, and can be run by hand from the Actions tab. Wrangler reads the Cloudflare Pages project name from the script flag (--project-name=diegosaid); auth is handled once with wrangler login.
All rights reserved. Code is published for portfolio review; reuse of brand assets and copy requires permission.