Skip to content
redcppPublic

About

Personal portfolio for Diego Said — React + Vite + Tailwind + GSAP, deployed on Cloudflare Pages.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

diegosaid

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

What this repo demonstrates

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 /blog derives its index from the collection rather than a hand-maintained array that can drift from the posts themselves.
  • Per-route metadata. BaseLayout.astro emits <title>, description, canonical, Open Graph and article tags, and JSON-LD per page (Person and WebSite on the home page, BlogPosting on each post; see src/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 .md in place of the trailing slash, and /llms.txt lists them for language models. robots.txt welcomes 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.ts renders 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, and Permissions-Policy are served from public/_headers. The CSP allows no inline script, which the markup respects.
  • Self-hosted fonts. Source Serif 4 and IBM Plex Mono come from @fontsource and ship from this domain, so no third party sits in front of the first paint.

Stack

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

Getting started

npm install
npm run dev          # http://localhost:4321

Scripts

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.

Routes

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 in src/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.

Project layout

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

Adding a post

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.

Bilingual tests

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 in es/ and the reverse, keys are unique, and each pair shares a date, a tag count, its featured flag and whether it has an origin. The CV has the same entries in both languages.
  • Build. Every page in dist/ has hreflang links 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.txt names a sitemap that exists, every post has its .md version listed in llms.txt, and every post carries valid BlogPosting JSON-LD.
  • Types. UI.es is typed against the English keys, so a chrome string added in one language only fails npm 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.

Deployment

npm run deploy:prod   # push to GitHub, build, deploy
npm run deploy        # build and deploy without pushing

Pushes 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.

License

All rights reserved. Code is published for portfolio review; reuse of brand assets and copy requires permission.

About

Personal portfolio for Diego Said — React + Vite + Tailwind + GSAP, deployed on Cloudflare Pages.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages