i18n refactor

This commit is contained in:
Gabriel Franco 2026-09-29 16:50:21 -03:00
parent 5f114e21e7
commit 6cd13476dc
33 changed files with 285 additions and 366 deletions

View file

@ -7,17 +7,37 @@ import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import vercel from '@astrojs/vercel'; import vercel from '@astrojs/vercel';
// English used to be served without a /en prefix. Every URL published before
// the move keeps working through a 301; new pages only ever existed under /en.
// (Astro can't express this as a dynamic redirect in a static build: the
// destination must be an existing route with the same params.)
const LEGACY_EN_PATHS = [
'/',
'/about',
'/posts',
'/posts/hello-world',
'/posts/spotify-complaining',
'/posts/understanding-plugin4shell-git-trick-ai-coding-agents',
'/tags/ai',
'/tags/blogging',
'/tags/cybersecurity',
'/tags/git',
'/tags/life',
'/tags/meta',
'/tags/software',
'/feed.xml',
];
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: 'https://gabeefran.co', site: 'https://gabeefran.co',
i18n: { // No `i18n` block on purpose: locales come from src/lib/i18n.ts and every
defaultLocale: 'en', // locale, English included, lives under src/pages/[lang]/. Astro's i18n
locales: ['en', 'pt'], // routing would add middleware that 404s the unprefixed on-demand
routing: { // /page-card and /social-card routes (prefix-always) or /en/* (default).
prefixDefaultLocale: false,
}, redirects: Object.fromEntries(LEGACY_EN_PATHS.map((path) => [path, path === '/' ? '/en' : `/en${path}`])),
},
integrations: [react(), mdx(), sitemap()], integrations: [react(), mdx(), sitemap()],

View file

@ -9,20 +9,20 @@
// /social-card and /page-card routes must be deployed at BASE_URL. // /social-card and /page-card routes must be deployed at BASE_URL.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { PAGES, pageCardPath, tagCardPath, tagPage, type PageKey } from '../src/lib/pages.ts'; import { LANGS, pageMeta, tagPageMeta, type Lang, type PageKey } from '../src/lib/i18n.ts';
import { pageCardPath, postCardPath, tagCardPath } from '../src/lib/social-cards.ts';
const BASE_URL = process.env.SOCIAL_CARD_BASE_URL ?? 'https://gabeefran.co'; const BASE_URL = process.env.SOCIAL_CARD_BASE_URL ?? 'https://gabeefran.co';
const CF_API = 'https://api.cloudflare.com/client/v4/accounts'; const CF_API = 'https://api.cloudflare.com/client/v4/accounts';
const POSTS_DIR = 'src/content/posts'; const POSTS_DIR = 'src/content/posts';
const LANGS = ['en', 'pt'] as const;
// 404.html is prerendered once, in English (see src/pages/404.astro). // 404.html is prerendered once, in English (see src/pages/404.astro).
const LANG_PAGES: Record<(typeof LANGS)[number], PageKey[]> = { const LANG_PAGES: Record<Lang, PageKey[]> = {
en: ['home', 'posts', 'about', '404'], en: ['home', 'posts', 'about', '404'],
pt: ['home', 'posts', 'about'], pt: ['home', 'posts', 'about'],
}; };
interface Post { interface Post {
lang: (typeof LANGS)[number]; lang: Lang;
slug: string; slug: string;
title: string; title: string;
description?: string; description?: string;
@ -72,13 +72,13 @@ function listCards(posts: Post[]): Card[] {
const postCards: Card[] = posts.map((post) => { const postCards: Card[] = posts.map((post) => {
const params: Record<string, string> = { title: post.title, author: 'Gabriel Franco' }; const params: Record<string, string> = { title: post.title, author: 'Gabriel Franco' };
if (post.description) params.description = post.description; if (post.description) params.description = post.description;
return { path: `/social-cards/${post.lang}/${post.slug}.png`, route: '/social-card', params }; return { path: postCardPath(post.lang, post.slug), route: '/social-card', params };
}); });
const pageCards: Card[] = LANGS.flatMap((lang) => { const pageCards: Card[] = LANGS.flatMap((lang) => {
const pages = LANG_PAGES[lang].map((key) => ({ path: pageCardPath(lang, key), meta: PAGES[lang][key] })); const pages = LANG_PAGES[lang].map((key) => ({ path: pageCardPath(lang, key), meta: pageMeta(lang, key) }));
const tags = [...new Set(posts.filter((p) => p.lang === lang).flatMap((p) => p.tags))].sort(); const tags = [...new Set(posts.filter((p) => p.lang === lang).flatMap((p) => p.tags))].sort();
const tagPages = tags.map((tag) => ({ path: tagCardPath(lang, tag), meta: tagPage(lang, tag) })); const tagPages = tags.map((tag) => ({ path: tagCardPath(lang, tag), meta: tagPageMeta(lang, tag) }));
return [...pages, ...tagPages].map(({ path, meta }) => ({ return [...pages, ...tagPages].map(({ path, meta }) => ({
path, path,
route: '/page-card' as const, route: '/page-card' as const,

View file

@ -23,7 +23,7 @@ const navLinks = [
]; ];
function isActive(href: string): boolean { function isActive(href: string): boolean {
if (href === homeHref) return pathname === href; if (href === homeHref) return pathname.replace(/\/$/, '') === href;
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
} }
--- ---
@ -31,7 +31,7 @@ function isActive(href: string): boolean {
<header class="sticky top-0 z-40 border-b border-border/80 bg-bg/85 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-border/80 bg-bg/85 backdrop-blur">
<div class="flex flex-col justify-center gap-4 px-4 py-3 sm:px-6"> <div class="flex flex-col justify-center gap-4 px-4 py-3 sm:px-6">
<div class="flex flex-row justify-between items-end"> <div class="flex flex-row justify-between items-center">
<a href={homeHref} class="flex hover:underline shrink-0 items-center gap-2.5" aria-label={t('site.name')} data-umami-event="logo-header-click"> <a href={homeHref} class="flex hover:underline shrink-0 items-center gap-2.5" aria-label={t('site.name')} data-umami-event="logo-header-click">
<img src="/logo.png" class="h-4"/> <img src="/logo.png" class="h-4"/>
<span class="font-mono text-[15px] font-semibold tracking-tight text-text">{t('site.name')}</span> <span class="font-mono text-[15px] font-semibold tracking-tight text-text">{t('site.name')}</span>

View file

@ -9,6 +9,7 @@ const posts = defineCollection({
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
language: z.enum(['en', 'pt']),
pubDate: z.coerce.date(), pubDate: z.coerce.date(),
slug: z.string(), slug: z.string(),
updatedDate: z.coerce.date().optional(), updatedDate: z.coerce.date().optional(),

View file

@ -1,9 +1,10 @@
--- ---
title: "This blog's Hello World (or whatever you want to call this site)" title: "This blog's Hello World (or whatever you want to call this site)"
slug: "hello-world" slug: "en-hello-world"
description: "A short note on why this exists and what I plan to write about here." description: "A short note on why this exists and what I plan to write about here."
pubDate: 2026-09-22 pubDate: 2026-09-22
tags: ["meta", "blogging"] tags: ["meta", "blogging"]
language: "en"
--- ---
I've always had a creative side, as anyone who knows me can tell you. Sometimes it gets tucked away somewhere obscure, in the back of a drawer, waiting years to be used. And sometimes I make really good use of it. I've always had a creative side, as anyone who knows me can tell you. Sometimes it gets tucked away somewhere obscure, in the back of a drawer, waiting years to be used. And sometimes I make really good use of it.

View file

@ -1,9 +1,10 @@
--- ---
title: "Spotify's Time Capsule Change and the Enshittification of Software" title: "Spotify's Time Capsule Change and the Enshittification of Software"
slug: "spotify-complaining" slug: "en-spotify-complaining"
description: "Spotify just removed one of its best features. Beyond a bit of complaining, I want to open a debate about what software is turning into." description: "Spotify just removed one of its best features. Beyond a bit of complaining, I want to open a debate about what software is turning into."
pubDate: 2026-09-2 pubDate: 2026-09-2
tags: ["software", "life"] tags: ["software", "life"]
language: "en"
--- ---
import ObservationCard from '../../../components/ObservationCard.tsx' import ObservationCard from '../../../components/ObservationCard.tsx'

View file

@ -1,10 +1,11 @@
--- ---
title: "Understanding Plugin4Shell: An Exploit That Uses a Simple Git Trick to Fool AI Coding Agents" title: "Understanding Plugin4Shell: An Exploit That Uses a Simple Git Trick to Fool AI Coding Agents"
description: "A git branch named after a commit hash was enough to make AI coding agents run unverified plugins. I reproduced the Plugin4Shell trick on GitHub and Forgejo." description: "A git branch named after a commit hash was enough to make AI coding agents run unverified plugins. I reproduced the Plugin4Shell trick on GitHub and Forgejo."
slug: "understanding-plugin4shell-git-trick-ai-coding-agents" slug: "en-understanding-plugin4shell-git-trick-ai-coding-agents"
pubDate: 2026-09-28 pubDate: 2026-09-28
tags: ["cybersecurity", "git", "ai", "software"] tags: ["cybersecurity", "git", "ai", "software"]
seoKeywords: "Plugin4Shell, git branch named after commit hash, SHA pinning bypass, AI coding agents security, Claude Code plugin vulnerability, supply chain attack, git internals, Forgejo" seoKeywords: "Plugin4Shell, git branch named after commit hash, SHA pinning bypass, AI coding agents security, Claude Code plugin vulnerability, supply chain attack, git internals, Forgejo"
language: "en"
--- ---
This post is based on [Ana Maria Constantin's post at TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) and [Air Security's report](https://www.air.security/blog-posts/plugin4shell). This post is based on [Ana Maria Constantin's post at TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) and [Air Security's report](https://www.air.security/blog-posts/plugin4shell).

View file

@ -2,8 +2,9 @@
title: "O Hello World desse blog (ou seja lá como você queira chamar esse site)" title: "O Hello World desse blog (ou seja lá como você queira chamar esse site)"
description: "Uma nota curta sobre por que isso existe e sobre o que pretendo escrever aqui." description: "Uma nota curta sobre por que isso existe e sobre o que pretendo escrever aqui."
pubDate: 2026-09-22 pubDate: 2026-09-22
slug: "hello-world" slug: "pt-hello-world"
tags: ["meta", "blogging"] tags: ["meta", "blogging"]
language: "pt"
--- ---
Eu sempre tive um lado criativo, quem me conhece sabe. Às vezes ele fica guardado em algum lugar obscuro, no fundo da gaveta, esperando anos pra ser utilizado. E às vezes eu me aproveito dele muito bem. Eu sempre tive um lado criativo, quem me conhece sabe. Às vezes ele fica guardado em algum lugar obscuro, no fundo da gaveta, esperando anos pra ser utilizado. E às vezes eu me aproveito dele muito bem.

View file

@ -1,9 +1,10 @@
--- ---
title: "A mudança na cápsula do tempo do Spotify e a bostificação do software" title: "A mudança na cápsula do tempo do Spotify e a bostificação do software"
description: "Spotify simplesmente removeu uma das suas melhores funcionalidades. Além de reclamar um pouco, quero abrir um debate sobre o que os softwares estão se tornando." description: "Spotify simplesmente removeu uma das suas melhores funcionalidades. Além de reclamar um pouco, quero abrir um debate sobre o que os softwares estão se tornando."
slug: "spotify-complaining" slug: "pt-spotify-complaining"
pubDate: 2026-09-2 pubDate: 2026-09-2
tags: ["software", "life"] tags: ["software", "life"]
language: "pt"
--- ---
import ObservationCard from '../../../components/ObservationCard.tsx' import ObservationCard from '../../../components/ObservationCard.tsx'

View file

@ -1,10 +1,11 @@
--- ---
title: "Entendendo o Plugin4Shell: um exploit que usa um truque simples do git para enganar agentes de IA de programação" title: "Entendendo o Plugin4Shell: um exploit que usa um truque simples do git para enganar agentes de IA de programação"
description: "Uma branch do git com o nome de um hash de commit bastou para fazer agentes de IA de programação rodarem plugins não verificados. Reproduzi o truque do Plugin4Shell no GitHub e no Forgejo." description: "Uma branch do git com o nome de um hash de commit bastou para fazer agentes de IA de programação rodarem plugins não verificados. Reproduzi o truque do Plugin4Shell no GitHub e no Forgejo."
slug: "understanding-plugin4shell-git-trick-ai-coding-agents" slug: "pt-understanding-plugin4shell-git-trick-ai-coding-agents"
pubDate: 2026-09-28 pubDate: 2026-09-28
tags: ["cybersecurity", "git", "ai", "software"] tags: ["cybersecurity", "git", "ai", "software"]
seoKeywords: "Plugin4Shell, branch do git com nome de hash de commit, bypass de SHA pinning, segurança de agentes de IA de programação, vulnerabilidade de plugin do Claude Code, ataque à cadeia de suprimentos, internals do git, Forgejo" seoKeywords: "Plugin4Shell, branch do git com nome de hash de commit, bypass de SHA pinning, segurança de agentes de IA de programação, vulnerabilidade de plugin do Claude Code, ataque à cadeia de suprimentos, internals do git, Forgejo"
language: "pt"
--- ---
Este post é baseado [no texto da Ana Maria Constantin no TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) e [no relatório da Air Security](https://www.air.security/blog-posts/plugin4shell). Este post é baseado [no texto da Ana Maria Constantin no TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) e [no relatório da Air Security](https://www.air.security/blog-posts/plugin4shell).

View file

@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import '../styles/global.css'; import '../styles/global.css';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import type { Lang } from '../lib/i18n'; import { getLocalizedPath, type Lang } from '../lib/i18n';
interface Props { interface Props {
title: string; title: string;
@ -31,8 +31,8 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
<link rel="alternate icon" href="/favicon.ico" /> <link rel="alternate icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL} /> <link rel="canonical" href={canonicalURL} />
<link rel="alternate" hreflang="en" href={new URL(Astro.url.pathname.replace(/^\/pt/, '') || '/', Astro.site)} /> <link rel="alternate" hreflang="en" href={new URL(getLocalizedPath(Astro.url.pathname, 'en'), Astro.site)} />
<link rel="alternate" hreflang="pt-BR" href={new URL(`/pt${Astro.url.pathname.replace(/^\/pt/, '')}`, Astro.site)} /> <link rel="alternate" hreflang="pt-BR" href={new URL(getLocalizedPath(Astro.url.pathname, 'pt'), Astro.site)} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />

View file

@ -1,3 +1,6 @@
// Every localized string on the site lives here. Keep this file free of
// runtime imports: scripts/generate-social-cards.ts loads it directly with Node.
export const LANGS = ['en', 'pt'] as const; export const LANGS = ['en', 'pt'] as const;
export type Lang = (typeof LANGS)[number]; export type Lang = (typeof LANGS)[number];
export const DEFAULT_LANG: Lang = 'en'; export const DEFAULT_LANG: Lang = 'en';
@ -22,11 +25,14 @@ export const ui = {
"Notes on computer science, systems, and the open source projects that shaped how I think about software. I'm Gabriel Franco — welcome.", "Notes on computer science, systems, and the open source projects that shaped how I think about software. I'm Gabriel Franco — welcome.",
'hero.cta.posts': 'Read the posts', 'hero.cta.posts': 'Read the posts',
'hero.cta.about': 'About me', 'hero.cta.about': 'About me',
'home.description':
'Notes on computer science, systems, and the open source projects that shaped how I think about software.',
'home.recent': 'Recent posts', 'home.recent': 'Recent posts',
'home.recent.subtitle': 'The latest things I’ve written down.', 'home.recent.subtitle': 'The latest things I’ve written down.',
'home.viewAll': 'View all posts', 'home.viewAll': 'View all posts',
'posts.title': 'All posts', 'posts.title': 'All posts',
'posts.subtitle': 'Everything I’ve written, oldest to newest below, newest on top.', 'posts.subtitle': 'Everything I’ve written, oldest to newest below, newest on top.',
'posts.description': 'Everything I’ve written about computer science, systems, and open source, newest first.',
'posts.empty': 'No posts yet. Check back soon.', 'posts.empty': 'No posts yet. Check back soon.',
'posts.filteredBy': 'Posts tagged', 'posts.filteredBy': 'Posts tagged',
'posts.clearFilter': 'Clear filter', 'posts.clearFilter': 'Clear filter',
@ -35,10 +41,22 @@ export const ui = {
'post.readingTime': 'min read', 'post.readingTime': 'min read',
'post.updated': 'Updated', 'post.updated': 'Updated',
'tag.label': 'Tag', 'tag.label': 'Tag',
'tag.description': 'Everything I’ve written tagged #{tag}.',
'about.title': 'About', 'about.title': 'About',
'about.description':
'Gabriel Franco: computer science student at PUCRS, interested in how software actually works and in the history of open source.',
// Paragraphs are separated by a blank line.
'about.body': [
"Hi, I'm Gabe — Gabriel Franco. I'm a computer science student at PUCRS (Pontifícia Universidade Católica do Rio Grande do Sul), and this blog is where I write down the things I'm learning and thinking about along the way.",
"I care a lot about computer science as a field, not just as a set of tools to ship products with. I have a fairly specific way of doing things: I'd rather understand a system properly than memorize the steps to make it work, and I tend to slow down on purpose to figure out what's actually happening under the hood — in a compiler, an operating system, a language runtime, whatever it is. That habit shapes most of what ends up on this blog.",
"I'm also genuinely interested in the history of the open source movement: the people, the arguments, and the decisions that shaped the software we all rely on. Figures like Linus Torvalds and projects like GNU come up often in what I read and write about, not as trivia, but because understanding where a tool came from usually explains a lot about how and why it works the way it does today.",
'This site is bilingual, in English and in Portuguese, and every post exists in both languages. Thanks for stopping by.',
].join('\n\n'),
'footer.tagline': 'Written by hand in Porto Alegre.', 'footer.tagline': 'Written by hand in Porto Alegre.',
'footer.rights': 'All rights reserved.', 'footer.rights': 'All rights reserved.',
'footer.source': 'Source', 'footer.source': 'Source',
'feed.title': 'gabeefran.co',
'feed.description': "Gabriel Franco's notes about computer science and other topics",
'404.title': 'Page not found', '404.title': 'Page not found',
'404.body': 'There’s nothing here. Let’s get you back home.', '404.body': 'There’s nothing here. Let’s get you back home.',
'404.cta': 'Back to home', '404.cta': 'Back to home',
@ -57,11 +75,15 @@ export const ui = {
'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software. Eu sou o Gabriel Franco — seja bem-vindo.', 'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software. Eu sou o Gabriel Franco — seja bem-vindo.',
'hero.cta.posts': 'Ler os posts', 'hero.cta.posts': 'Ler os posts',
'hero.cta.about': 'Sobre mim', 'hero.cta.about': 'Sobre mim',
'home.description':
'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software.',
'home.recent': 'Posts recentes', 'home.recent': 'Posts recentes',
'home.recent.subtitle': 'As últimas coisas que escrevi.', 'home.recent.subtitle': 'As últimas coisas que escrevi.',
'home.viewAll': 'Ver todos os posts', 'home.viewAll': 'Ver todos os posts',
'posts.title': 'Todos os posts', 'posts.title': 'Todos os posts',
'posts.subtitle': 'Tudo que já escrevi, do mais recente para o mais antigo.', 'posts.subtitle': 'Tudo que já escrevi, do mais recente para o mais antigo.',
'posts.description':
'Tudo que já escrevi sobre ciência da computação, sistemas e open source, do mais recente ao mais antigo.',
'posts.empty': 'Ainda não há posts. Volte em breve.', 'posts.empty': 'Ainda não há posts. Volte em breve.',
'posts.filteredBy': 'Posts com a tag', 'posts.filteredBy': 'Posts com a tag',
'posts.clearFilter': 'Limpar filtro', 'posts.clearFilter': 'Limpar filtro',
@ -70,10 +92,21 @@ export const ui = {
'post.readingTime': 'min de leitura', 'post.readingTime': 'min de leitura',
'post.updated': 'Atualizado em', 'post.updated': 'Atualizado em',
'tag.label': 'Tag', 'tag.label': 'Tag',
'tag.description': 'Tudo que já escrevi com a tag #{tag}.',
'about.title': 'Sobre', 'about.title': 'Sobre',
'about.description':
'Gabriel Franco: estudante de ciência da computação na PUCRS, interessado em como o software realmente funciona e na história do open source.',
'about.body': [
'Olá, eu sou o Gabe — Gabriel Franco. Sou estudante de ciência da computação na PUCRS (Pontifícia Universidade Católica do Rio Grande do Sul), e este blog é onde registro as coisas que vou aprendendo e pensando pelo caminho.',
'Eu me importo bastante com ciência da computação como área, não só como um conjunto de ferramentas para lançar produtos. Tenho um jeito bem específico de fazer as coisas: prefiro entender um sistema de verdade a decorar os passos para fazê-lo funcionar, e costumo desacelerar de propósito para descobrir o que realmente está acontecendo por baixo dos panos — em um compilador, em um sistema operacional, no runtime de uma linguagem, seja lá o que for. Esse hábito molda a maior parte do que acaba virando post aqui.',
'Também tenho um interesse genuíno pela história do movimento open source: as pessoas, os debates e as decisões que moldaram o software do qual todos dependemos. Figuras como Linus Torvalds e projetos como o GNU aparecem com frequência no que leio e escrevo, não como curiosidade, mas porque entender de onde uma ferramenta veio costuma explicar bastante sobre como e por que ela funciona do jeito que funciona hoje.',
'Este site é bilíngue, em inglês e em português, e todo post existe nos dois idiomas. Obrigado pela visita.',
].join('\n\n'),
'footer.tagline': 'Escrito à mão em Porto Alegre.', 'footer.tagline': 'Escrito à mão em Porto Alegre.',
'footer.rights': 'Todos os direitos reservados.', 'footer.rights': 'Todos os direitos reservados.',
'footer.source': 'Código-fonte', 'footer.source': 'Código-fonte',
'feed.title': 'gabeefran.co',
'feed.description': 'Anotações do Gabriel Franco sobre ciência da computação e outros assuntos',
'404.title': 'Página não encontrada', '404.title': 'Página não encontrada',
'404.body': 'Não há nada aqui. Vamos te levar de volta para o início.', '404.body': 'Não há nada aqui. Vamos te levar de volta para o início.',
'404.cta': 'Voltar para o início', '404.cta': 'Voltar para o início',
@ -82,30 +115,51 @@ export const ui = {
export type UiKey = keyof (typeof ui)['en']; export type UiKey = keyof (typeof ui)['en'];
export function getLangFromUrl(url: URL): Lang { export function isLang(value: string | undefined): value is Lang {
const [, first] = url.pathname.split('/'); return LANGS.some((lang) => lang === value);
return first === 'pt' ? 'pt' : DEFAULT_LANG;
} }
/** getStaticPaths() result for pages that only vary by language. */
export function langPaths() {
return LANGS.map((lang) => ({ params: { lang } }));
}
/** `{name}` placeholders in a string are replaced with `vars[name]`. */
export function useTranslations(lang: Lang) { export function useTranslations(lang: Lang) {
return function t(key: UiKey): string { return function t(key: UiKey, vars: Record<string, string> = {}): string {
return ui[lang][key] ?? ui[DEFAULT_LANG][key]; const template: string = ui[lang][key] ?? ui[DEFAULT_LANG][key];
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => vars[name] ?? placeholder);
}; };
} }
/** Prefix-strip the given pathname down to its language-agnostic form. */ /** Pages with their own title/description (tag pages use tagPageMeta). */
export function stripLangPrefix(pathname: string): string { export type PageKey = 'home' | 'posts' | 'about' | '404';
return pathname.replace(/^\/pt(?=\/|$)/, '') || '/';
export interface PageMeta {
title: string;
description: string;
}
const PAGE_META_KEYS: Record<PageKey, { title: UiKey; description: UiKey }> = {
home: { title: 'hero.title', description: 'home.description' },
posts: { title: 'posts.title', description: 'posts.description' },
about: { title: 'about.title', description: 'about.description' },
'404': { title: '404.title', description: '404.body' },
};
export function pageMeta(lang: Lang, page: PageKey): PageMeta {
const t = useTranslations(lang);
const keys = PAGE_META_KEYS[page];
return { title: t(keys.title), description: t(keys.description) };
}
export function tagPageMeta(lang: Lang, tag: string): PageMeta {
return { title: `#${tag}`, description: useTranslations(lang)('tag.description', { tag }) };
} }
/** Given any pathname, build the equivalent path in the target language. */ /** Given any pathname, build the equivalent path in the target language. */
export function getLocalizedPath(pathname: string, lang: Lang): string { export function getLocalizedPath(pathname: string, lang: Lang): string {
const stripped = stripLangPrefix(pathname); const [, first] = pathname.split('/');
if (lang === DEFAULT_LANG) return stripped; const stripped = (isLang(first) ? pathname.slice(first.length + 1) : pathname) || '/';
return stripped === '/' ? '/pt' : `/pt${stripped}`; return stripped === '/' ? `/${lang}` : `/${lang}${stripped}`;
}
export function localizeHref(path: string, lang: Lang): string {
if (lang === DEFAULT_LANG) return path;
return path === '/' ? '/pt' : `/pt${path}`;
} }

View file

@ -1,69 +0,0 @@
// Title and description for every non-post page, shared by the pages
// themselves and by scripts/generate-social-cards.ts (so keep this file free
// of runtime imports: the script loads it directly with Node).
import type { Lang } from './i18n';
export type PageKey = 'home' | 'posts' | 'about' | '404';
export interface PageMeta {
title: string;
description: string;
}
export const PAGES: Record<Lang, Record<PageKey, PageMeta>> = {
en: {
home: {
title: 'Building things, and figuring out how they work.',
description:
'Notes on computer science, systems, and the open source projects that shaped how I think about software.',
},
posts: {
title: 'All posts',
description: 'Everything I’ve written about computer science, systems, and open source, newest first.',
},
about: {
title: 'About',
description:
'Gabriel Franco: computer science student at PUCRS, interested in how software actually works and in the history of open source.',
},
'404': {
title: 'Page not found',
description: 'There’s nothing here. Let’s get you back home.',
},
},
pt: {
home: {
title: 'Construindo coisas e entendendo como elas funcionam.',
description:
'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software.',
},
posts: {
title: 'Todos os posts',
description: 'Tudo que já escrevi sobre ciência da computação, sistemas e open source, do mais recente ao mais antigo.',
},
about: {
title: 'Sobre',
description:
'Gabriel Franco: estudante de ciência da computação na PUCRS, interessado em como o software realmente funciona e na história do open source.',
},
'404': {
title: 'Página não encontrada',
description: 'Não há nada aqui. Vamos te levar de volta para o início.',
},
},
};
export function tagPage(lang: Lang, tag: string): PageMeta {
return lang === 'pt'
? { title: `#${tag}`, description: `Tudo que já escrevi com a tag #${tag}.` }
: { title: `#${tag}`, description: `Everything I’ve written tagged #${tag}.` };
}
/** Where the generated card for a page lives, relative to public/. */
export function pageCardPath(lang: Lang, key: PageKey): string {
return `/social-cards/${lang}/pages/${key}.png`;
}
export function tagCardPath(lang: Lang, tag: string): string {
return `/social-cards/${lang}/tags/${tag}.png`;
}

View file

@ -9,12 +9,12 @@ export function langOf(entry: PostEntry): Lang {
} }
export function slugOf(entry: PostEntry): string { export function slugOf(entry: PostEntry): string {
return entry.id.split('/').slice(1).join('/'); return entry.id.replace('pt-', '').replace('en-', '');
} }
export async function getPostsByLang(lang: Lang): Promise<PostEntry[]> { export async function getPostsByLang(lang: Lang): Promise<PostEntry[]> {
const posts = await getCollection('posts', ({ id, data }) => { const posts = await getCollection('posts', ({ id, data }) => {
return id.startsWith(`${lang}/`) && !data.draft; return id.startsWith(`${lang}-`) && !data.draft;
}); });
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
} }

16
src/lib/social-cards.ts Normal file
View file

@ -0,0 +1,16 @@
// Where generated OG cards live, relative to public/. Shared by the pages and
// by scripts/generate-social-cards.ts (so keep this file free of runtime
// imports: the script loads it directly with Node).
import type { Lang, PageKey } from './i18n';
export function postCardPath(lang: Lang, slug: string): string {
return `/social-cards/${lang}/${slug}.png`;
}
export function pageCardPath(lang: Lang, page: PageKey): string {
return `/social-cards/${lang}/pages/${page}.png`;
}
export function tagCardPath(lang: Lang, tag: string): string {
return `/social-cards/${lang}/tags/${tag}.png`;
}

View file

@ -1,24 +1,24 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { getLocalizedPath, ui, type Lang } from '../lib/i18n'; import { getLocalizedPath, LANGS, pageMeta, ui } from '../lib/i18n';
import { PAGES, pageCardPath } from '../lib/pages'; import { pageCardPath } from '../lib/social-cards';
// This file is prerendered once as a static /404.html and served by the host // This file is prerendered once as a static /404.html and served by the host
// for any unmatched path, so we can't know at build time whether the visitor // for any unmatched path, so we can't know at build time whether the visitor
// was under /pt/*. Render both languages and pick one at runtime with a tiny // was under /pt/*. Render both languages and pick one at runtime with a tiny
// inline script based on the actual requested path. // inline script based on the actual requested path.
const langs: Lang[] = ['en', 'pt']; const page = pageMeta('en', '404');
--- ---
<BaseLayout <BaseLayout
title={`${PAGES.en['404'].title} — gabeefranco`} title={`${page.title} — ${ui.en['site.name']}`}
description={PAGES.en['404'].description} description={page.description}
lang="en" lang="en"
image={pageCardPath('en', '404')} image={pageCardPath('en', '404')}
> >
<section class="mx-10 flex flex-col items-start px-4 py-24 sm:px-6 font-mono"> <section class="mx-10 flex flex-col items-start px-4 py-24 sm:px-6 font-mono">
<p class="font-mono text-sm text-primary">404</p> <p class="font-mono text-sm text-primary">404</p>
{langs.map((lang) => ( {LANGS.map((lang) => (
<div data-notfound-lang={lang} class={lang === 'en' ? '' : 'hidden'}> <div data-notfound-lang={lang} class={lang === 'en' ? '' : 'hidden'}>
<h1 class="mt-2 text-3xl font-bold tracking-tight text-text">{ui[lang]['404.title']}</h1> <h1 class="mt-2 text-3xl font-bold tracking-tight text-text">{ui[lang]['404.title']}</h1>
<p class="mt-3 text-text-muted">{ui[lang]['404.body']}</p> <p class="mt-3 text-text-muted">{ui[lang]['404.body']}</p>

View file

@ -0,0 +1,21 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { langPaths, pageMeta, useTranslations, type Lang } from '../../lib/i18n';
import { pageCardPath } from '../../lib/social-cards';
export const getStaticPaths = langPaths;
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
const page = pageMeta(lang, 'about');
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={pageCardPath(lang, 'about')}>
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{page.title}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
{t('about.body').split('\n\n').map((paragraph) => <p>{paragraph}</p>)}
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,24 @@
import rss from '@astrojs/rss';
import type { APIContext } from 'astro';
import { getLocalizedPath, langPaths, useTranslations, type Lang } from '../../lib/i18n';
import { getPostsByLang, slugOf } from '../../lib/posts';
export const getStaticPaths = langPaths;
export async function GET(context: APIContext) {
const lang = context.params.lang as Lang;
const t = useTranslations(lang);
const posts = await getPostsByLang(lang);
return rss({
title: t('feed.title'),
description: t('feed.description'),
site: new URL(getLocalizedPath('/', lang), context.site),
customData: `<language>${lang}</language>`,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `${getLocalizedPath(`/posts/${slugOf(post)}`, lang)}/`,
})),
});
}

View file

@ -0,0 +1,20 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeContent from '../../components/HomeContent.astro';
import { langPaths, pageMeta, useTranslations, type Lang } from '../../lib/i18n';
import { pageCardPath } from '../../lib/social-cards';
export const getStaticPaths = langPaths;
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
---
<BaseLayout
title={`${t('site.name')} — ${t('hero.kicker')}`}
description={pageMeta(lang, 'home').description}
lang={lang}
image={pageCardPath(lang, 'home')}
>
<HomeContent lang={lang} />
</BaseLayout>

View file

@ -0,0 +1,35 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostLayout from '../../../layouts/PostLayout.astro';
import ObservationCard from '../../../components/ObservationCard';
import { LANGS, useTranslations, type Lang } from '../../../lib/i18n';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../../lib/posts';
import { postCardPath } from '../../../lib/social-cards';
export async function getStaticPaths() {
const pathsByLang = await Promise.all(
LANGS.map(async (lang) =>
(await getPostsByLang(lang)).map((post) => ({ params: { lang, slug: slugOf(post) }, props: { post } })),
),
);
return pathsByLang.flat();
}
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout
title={`${post.data.title} — ${t('site.name')}`}
description={post.data.description}
lang={lang}
image={postCardPath(lang, slugOf(post))}
>
<PostLayout post={post} lang={lang} readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -0,0 +1,16 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { langPaths, pageMeta, useTranslations, type Lang } from '../../../lib/i18n';
import { pageCardPath } from '../../../lib/social-cards';
export const getStaticPaths = langPaths;
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
const page = pageMeta(lang, 'posts');
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={pageCardPath(lang, 'posts')}>
<PostsContent lang={lang} />
</BaseLayout>

View file

@ -0,0 +1,23 @@
---
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import PostsContent from '../../../../components/PostsContent.astro';
import { LANGS, tagPageMeta, useTranslations, type Lang } from '../../../../lib/i18n';
import { getAllTags } from '../../../../lib/posts';
import { tagCardPath } from '../../../../lib/social-cards';
export async function getStaticPaths() {
const pathsByLang = await Promise.all(
LANGS.map(async (lang) => (await getAllTags(lang)).map((tag) => ({ params: { lang, tag } }))),
);
return pathsByLang.flat();
}
const lang = Astro.params.lang as Lang;
const tag = Astro.params.tag!;
const t = useTranslations(lang);
const page = tagPageMeta(lang, tag);
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={tagCardPath(lang, tag)}>
<PostsContent lang={lang} tag={tag} />
</BaseLayout>

View file

@ -1,41 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { useTranslations } from '../lib/i18n';
import { PAGES, pageCardPath } from '../lib/pages';
const t = useTranslations('en');
const page = PAGES.en.about;
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="en" image={pageCardPath('en', 'about')}>
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('about.title')}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
<p>
Hi, I'm Gabe — Gabriel Franco. I'm a computer science student at PUCRS (Pontifícia
Universidade Católica do Rio Grande do Sul), and this blog is where I write down the
things I'm learning and thinking about along the way.
</p>
<p>
I care a lot about computer science as a field, not just as a set of tools to ship
products with. I have a fairly specific way of doing things: I'd rather understand a
system properly than memorize the steps to make it work, and I tend to slow down on
purpose to figure out what's actually happening under the hood — in a compiler, an
operating system, a language runtime, whatever it is. That habit shapes most of what ends
up on this blog.
</p>
<p>
I'm also genuinely interested in the history of the open source movement: the people,
the arguments, and the decisions that shaped the software we all rely on. Figures like
Linus Torvalds and projects like GNU come up often in what I read and write about, not
as trivia, but because understanding where a tool came from usually explains a lot about
how and why it works the way it does today.
</p>
<p>
This site is bilingual, in English and in Portuguese, and every post exists in both
languages. Thanks for stopping by.
</p>
</div>
</section>
</BaseLayout>

View file

@ -1,19 +0,0 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const blog = await getCollection('posts');
return rss({
title: 'gabeefran.co',
description: 'Gabriel Franco\'s notes about computer science and other topics',
site: context.site,
items: blog.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
// Compute RSS link from post `slug`
// This example assumes all posts are rendered as `/blog/[slug]` routes
link: `/posts/${post.data.slug}/`,
})),
});
}

View file

@ -1,18 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HomeContent from '../components/HomeContent.astro';
import { useTranslations } from '../lib/i18n';
import { PAGES, pageCardPath } from '../lib/pages';
const t = useTranslations('en');
const page = PAGES.en.home;
---
<BaseLayout
title={`${t('site.name')} — ${t('hero.kicker')}`}
description={page.description}
lang="en"
image={pageCardPath('en', 'home')}
>
<HomeContent lang="en" />
</BaseLayout>

View file

@ -1,22 +0,0 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostLayout from '../../layouts/PostLayout.astro';
import ObservationCard from '../../components/ObservationCard';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../lib/posts';
export async function getStaticPaths() {
const posts = await getPostsByLang('en');
return posts.map((post) => ({ params: { slug: slugOf(post) }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout title={`${post.data.title} — gabeefranco`} description={post.data.description} lang="en" image={`/social-cards/${post.id}.png`}>
<PostLayout post={post} lang="en" readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -1,13 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostsContent from '../../components/PostsContent.astro';
import { useTranslations } from '../../lib/i18n';
import { PAGES, pageCardPath } from '../../lib/pages';
const t = useTranslations('en');
const page = PAGES.en.posts;
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="en" image={pageCardPath('en', 'posts')}>
<PostsContent lang="en" />
</BaseLayout>

View file

@ -1,42 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { useTranslations } from '../../lib/i18n';
import { PAGES, pageCardPath } from '../../lib/pages';
const t = useTranslations('pt');
const page = PAGES.pt.about;
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="pt" image={pageCardPath('pt', 'about')}>
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('about.title')}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
<p>
Olá, eu sou o Gabe — Gabriel Franco. Sou estudante de ciência da computação na PUCRS
(Pontifícia Universidade Católica do Rio Grande do Sul), e este blog é onde registro as
coisas que vou aprendendo e pensando pelo caminho.
</p>
<p>
Eu me importo bastante com ciência da computação como área, não só como um conjunto de
ferramentas para lançar produtos. Tenho um jeito bem específico de fazer as coisas:
prefiro entender um sistema de verdade a decorar os passos para fazê-lo funcionar, e
costumo desacelerar de propósito para descobrir o que realmente está acontecendo por
baixo dos panos — em um compilador, em um sistema operacional, no runtime de uma
linguagem, seja lá o que for. Esse hábito molda a maior parte do que acaba virando post
aqui.
</p>
<p>
Também tenho um interesse genuíno pela história do movimento open source: as pessoas, os
debates e as decisões que moldaram o software do qual todos dependemos. Figuras como
Linus Torvalds e projetos como o GNU aparecem com frequência no que leio e escrevo, não
como curiosidade, mas porque entender de onde uma ferramenta veio costuma explicar
bastante sobre como e por que ela funciona do jeito que funciona hoje.
</p>
<p>
Este site é bilíngue, em inglês e em português, e todo post existe nos dois idiomas.
Obrigado pela visita.
</p>
</div>
</section>
</BaseLayout>

View file

@ -1,18 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeContent from '../../components/HomeContent.astro';
import { useTranslations } from '../../lib/i18n';
import { PAGES, pageCardPath } from '../../lib/pages';
const t = useTranslations('pt');
const page = PAGES.pt.home;
---
<BaseLayout
title={`${t('site.name')} — ${t('hero.kicker')}`}
description={page.description}
lang="pt"
image={pageCardPath('pt', 'home')}
>
<HomeContent lang="pt" />
</BaseLayout>

View file

@ -1,22 +0,0 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostLayout from '../../../layouts/PostLayout.astro';
import ObservationCard from '../../../components/ObservationCard';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../../lib/posts';
export async function getStaticPaths() {
const posts = await getPostsByLang('pt');
return posts.map((post) => ({ params: { slug: slugOf(post) }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout title={`${post.data.title} — gabeefranco`} description={post.data.description} lang="pt" image={`/social-cards/${post.id}.png`}>
<PostLayout post={post} lang="pt" readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -1,13 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { useTranslations } from '../../../lib/i18n';
import { PAGES, pageCardPath } from '../../../lib/pages';
const t = useTranslations('pt');
const page = PAGES.pt.posts;
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="pt" image={pageCardPath('pt', 'posts')}>
<PostsContent lang="pt" />
</BaseLayout>

View file

@ -1,20 +0,0 @@
---
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import PostsContent from '../../../../components/PostsContent.astro';
import { getAllTags } from '../../../../lib/posts';
import { useTranslations } from '../../../../lib/i18n';
import { tagPage, tagCardPath } from '../../../../lib/pages';
export async function getStaticPaths() {
const tags = await getAllTags('pt');
return tags.map((tag) => ({ params: { tag } }));
}
const { tag } = Astro.params;
const t = useTranslations('pt');
const page = tagPage('pt', tag!);
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="pt" image={tagCardPath('pt', tag!)}>
<PostsContent lang="pt" tag={tag} />
</BaseLayout>

View file

@ -1,20 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { getAllTags } from '../../../lib/posts';
import { useTranslations } from '../../../lib/i18n';
import { tagPage, tagCardPath } from '../../../lib/pages';
export async function getStaticPaths() {
const tags = await getAllTags('en');
return tags.map((tag) => ({ params: { tag } }));
}
const { tag } = Astro.params;
const t = useTranslations('en');
const page = tagPage('en', tag!);
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang="en" image={tagCardPath('en', tag!)}>
<PostsContent lang="en" tag={tag} />
</BaseLayout>