Imported from ezeluduena/ezeluduena.dev.ar (
AGENTS.md). Install upstream withnpx skills add ezeluduena/ezeluduena.dev.ar. Copyright stays with the author.
AGENTS.md
Context for AI code agents working on ezeluduena.dev.ar — a personal portfolio and blog website built with Next.js 16, React 19, TypeScript, and TailwindCSS v4, deployed as a static export to GitHub Pages.
Commands
yarn install # install deps (yarn classic, frozen-lockfile in CI)
yarn start # dev server at localhost:3000 (next dev)
yarn build # static export to ./out (set SITE_URL for canonical URLs)
yarn typecheck # tsc --noEmit
yarn lint # eslint .
yarn format # prettier --write .
yarn format:check # prettier --check . (CI gate)
Production build requires SITE_URL (throws if missing):
SITE_URL=https://ezeluduena.dev.ar yarn build
Tech stack
| Package | Version | Notes |
|---|---|---|
| next | ^16.3.1 | Static export (output: 'export'), Turbopack |
| react | ^19.2.8 | |
| typescript | ^6.0.3 | Must stay <7 — TS 7 breaks next build |
| tailwindcss | ^4.3.3 | v4 via @tailwindcss/postcss, @custom-variant dark |
| eslint | ^9.39.5 | Must stay <10 — eslint-config-next plugins don't support 10 |
| eslint-config-next | ^16.3.0 | Flat config, ships core-web-vitals + typescript |
| prettier | ^3.9.6 | With prettier-plugin-organize-imports |
| lint-staged | ^16 | Must stay <17 — v17 requires Node ≥22.22 |
| husky | ^9.1.7 | Pre-commit hook |
| feed | ^6.0.0 | RSS generation (ESM) |
| react-markdown | ^10.1.0 | Blog post rendering |
| react-syntax-highlighter | ^16.1.1 | Prism, dark/light themes |
| @giscus/react | ^3.1.0 | Blog comments via GitHub Discussions |
Node: .nvmrc = 24 (CI), local dev = Node 20.19.3. engines.node = >=20.9.0.
Project structure
components/ UI components (PascalCase .tsx)
data/ Content + loaders
blog/ Per-locale markdown posts + _loader.ts factory
locale/ i18n translation tables (home, layout, blog, projects, talks)
projects/ Project metadata
talks/ Talk metadata (Spanish content)
hooks/ Custom React hooks (camelCase .ts)
pages/ Next.js Pages Router routes
utils/ Pure helpers (env, sitemap, array, async, object, url)
public/ Static assets (blog/ and sitemap.xml are gitignored — generated at build)
.github/ CI workflows + dependabot
.husky/ Git hooks (pre-commit → lint-staged)
Path alias: ~/* → repo root (e.g. ~/components/layout, ~/hooks/useLocale).
Architecture
i18n (client-side, no SSR locale)
- Locales:
'en' | 'es', default'es'. - Translation tables:
data/locale/{home,layout,blog,projects,talks}.ts— objects keyed by locale. useLocalehook: resolves locale asuserPreferredLocale(localStorage) →window.__INITIAL_LOCALE(from no-flash script) →systemPreferredLocale(matchMedia('(prefers-language: en)')) →'es'.- No-flash script (
pages/_document.tsx): inline<script>runs before paint, readslocalStorage.theme/locale, falls back tomatchMedia, setsdocument.documentElement.lang+.darkclass, and setswindow.__INITIAL_LOCALE. - Language switcher: toggles locale, redirects
/blog/{old}→/blog/{new}. Only re-aligns URL to stored preference whenuserPreferredLocale !== null(first-time visitors keep shared links stable). - Known limitation:
og:localeshowses_ARon EN pages in static build (locale resolved client-side; would need SSG per-locale to fix).
Blog
- Factory loader (
data/blog/_loader.ts):createBlogLoader(locale)returns{ loadBlogPosts, loadBlogPostRefs, loadBlogPost, publishBlogPostAssets, publishBlogFeed }. - Posts:
data/blog/{en,es}/<id>/index.mdwith frontmatter{ title, date, description, comment_section_title }(all required). Covers:cover.pngin the same folder. - RSS:
publishBlogFeedwritespublic/blog/{locale}/rss.xml(gitignored, generated at build). Title/description localized per locale. - Sitemap:
publishSitemap(called frompages/blog/index.tsxgetStaticProps) writespublic/sitemap.xmlwith hreflang alternates for blog posts. - Comment section: Giscus via
@giscus/react, keyed bycomment_section_title, theme tracksuserPreferredTheme.
SEO meta (components/meta.tsx)
- Canonical URL from
router.asPath(stripped of?/#). - Open Graph:
og:type(article/website),og:locale(es_AR/en_US),og:locale:alternatefor other hreflang locales. - Twitter cards:
summaryorsummary_large_image. - hreflang:
<link rel="alternate" hrefLang>for blog posts (both en/es). - JSON-LD:
WebSite+Person(withsameAs) always;BlogPostingwhenpublishedTime(withdateModified,publisher,author). - Default description is locale-aware (
defaultDescriptions[locale]). - RSS link title localized.
Layout (components/layout.tsx)
Layoutwraps<Meta/>+Page.Pagesyncs.darkclass +langon<html>.Header: skip-to-content link, desktop nav,ThemeSwitcher(sun/moon),LanguageSwitcher(flags), mobile hamburger.Loader: progress bar shown after 300ms of navigation loading.Main: wraps children inFadeInkeyed byrouter.pathname.FadeIn: staggered opacity/translate; respectsprefers-reduced-motion.
Projects (data/projects/index.ts + pages/projects/index.tsx)
- Data model:
Project = { id, categories: ProjectCategory[], technologies: string[], homepageUrl? }. - Categories:
'odoo' | 'data-science' | 'backend' | 'web'. - Nested i18n:
data/locale/projects.ts→{ en: { title, description, categories: {...}, projects: { '<id>': { name, description, url } } }, es: {...} }. - Filter tabs: client-side
useState, tabs from locale categories, active tab styled cyan. - Cards: name, description, tech chips, optional homepage link.
Talks (data/talks/index.ts + pages/talks/index.tsx)
- Data model:
Talk = { name, url?, description?, date?, event?, event_url?, video_url?, slides_url?, with?, language? }. Content authored in Spanish. - Translation pattern:
data/locale/talks.tsmaps Spanish strings → English. Page renderst[string] || string(fallback to original). - Slides PDFs in
public/talks/.
Configuration
next.config.js (CJS)
output: 'export',images.unoptimized: true,reactStrictMode: true.env.BUILD_ID:git rev-parse --short HEAD+ tag at HEAD.env.SITE_URL:process.env.SITE_URL→VERCEL_URL→http://localhost:3000.- Uses
require()with/* eslint-disable @typescript-eslint/no-require-imports */.
tsconfig.json
strict: true,noUncheckedIndexedAccess: true,noImplicitReturns: true.- Path alias:
"~/*": ["./*"]. moduleResolution: bundler,jsx: react-jsx,isolatedModules: true.
eslint.config.mjs (flat config)
- Imports
eslint-config-next/core-web-vitals+eslint-config-next/typescript. No custom rules.
.prettierrc.json
singleQuote,semi,tabWidth: 2,printWidth: 100,trailingComma: "none",arrowParens: "always".- Plugin:
prettier-plugin-organize-imports. .prettierignore:next.config.js,postcss.config.js,yarn.lock,public/,out/,.next/.
pages/globals.css
- Responsive root font sizes (18px → 16px → 14px).
@import 'tailwindcss';+@custom-variant dark (&:where(.dark, .dark *));.
CI/CD
nextjs.yml — GitHub Pages deploy
- Trigger:
pushonmaster. Node 24. - Gates:
yarn install --frozen-lockfile→typecheck→lint→format:check→next build(withSITE_URL). - Writes
out/CNAME(ezeluduena.dev.ar), deploys via GitHub Pages.
redeploy.yml — daily cron
- Same deploy, skips typecheck/lint/format gates.
codeql.yml — CodeQL
javascript-typescript,build-mode: none, weekly cron.
.github/dependabot.yml
- Monthly, grouped. Ignore rules:
eslint >=10,lint-staged >=17,typescript >=7.
Git hooks
.husky/pre-commit→npx lint-staged.lint-staged:*.{js,jsx,ts,tsx,mjs}→eslint --fix+prettier --write;*.{json,css,md,yml}→prettier --write.
Key constraints
- TypeScript <7 — TS 7 drops
lib/typescript.js, breaksnext build. - ESLint <10 — eslint-config-next's bundled plugins crash on ESLint 10.
- lint-staged <17 — v17 requires Node ≥22.22, local is Node 20.
- Static export — no SSR. Locale/theme resolved client-side.
og:localemay not match user's locale in static HTML. images.unoptimized: true— nonext/imageoptimization.public/blog/andpublic/sitemap.xmlare gitignored — generated at build time, never commit them.deleteUndefinedrequired before returninggetStaticPropsdata (Next.js can't serializeundefined).noUncheckedIndexedAccess— indexed access returnsT | undefined; use??/||fallbacks.basePathinjected by CI (actions/configure-pages), not innext.config.js.
Conventions
- Imports:
~/path alias, never relative across folders. - Style: single quotes, semicolons, 2-space indent, no trailing commas,
printWidth 100. - Components: camelCase filenames, PascalCase components,
FC/PropsWithChildren. - Hooks/utils/data: camelCase
.ts. - Styling:
classnames(import c from 'classnames'') with object-conditional classes; dark mode via.darkclass. - Icons:
react-icons/fi(Feather). - Focus-visible: interactive elements use
focus-visible:ring-2 ring-cyan-500 ring-offset-2.
Content authoring
Blog post
- Create
data/blog/{en,es}/<id>/index.mdwith frontmatter{ title, date, description, comment_section_title }. - Place
cover.pngin the same folder. - Add any non-markdown assets (PDFs, images) in the same folder — they're copied to
public/blog/{locale}/<id>/. - Run
yarn buildto regenerate RSS, sitemap, and copied assets.
Project
- Add to
data/projects/index.ts:{ id, categories, technologies, homepageUrl? }. - Add
data/locale/projects.ts→projects[id] = { name, description, url }under bothenandes. - Add new categories to
ProjectCategorytype andt.categoriesin both locales.
Talk
- Add to
data/talks/index.ts(Spanish content). - Add Spanish strings as keys under
enindata/locale/talks.tswith English translations. - Place slides in
public/talks/.