{title}
+ + {updatedDate && ( ++ Updated +
+ )} + {originalSource && canonicalURL && ( ++ Originally published on{' '} + + {originalSource} → + +
+ )} +diff --git a/CLAUDE.md b/CLAUDE.md
index 987dc19..32fc7ca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -146,6 +146,20 @@ npm run preview # Preview build
Playwright tests live in `tests/speaking-layout.spec.js`. They validate carousel positioning, spacing, and responsive behavior. **Tests must pass before merging.**
+## Blog Drafts
+
+Posts with `draft: true` in their frontmatter are excluded from `/blog/`, `/rss.xml`, and the sitemap. They build to `/blog/draft/[slug]/` so they can be shared for feedback without going public.
+
+The path `/blog/draft/*` is gated by a Netlify Edge Function (`netlify/edge-functions/draft-auth.js`) that requires HTTP Basic Auth. Credentials are read from `DRAFT_USER` and `DRAFT_PASSWORD` env vars in the Netlify dashboard (Site settings → Environment variables). If either is unset, the gate returns `503` for all draft requests.
+
+Workflow:
+1. Add a `.md` to `src/content/blog/` with `draft: true` in frontmatter.
+2. Push the branch — Netlify deploy preview builds the draft page.
+3. Share the URL with the reviewer; they'll be prompted for Basic Auth credentials.
+4. Flip `draft: false` (and remove or update `pubDate`) to publish.
+
+Locally (`npm run dev`), drafts are accessible at `/blog/draft/[slug]/` without auth, since edge functions only run on Netlify.
+
## AI Behavioral Rules
1. **Prioritize simplicity** — if there's a simpler way, use it
diff --git a/astro.config.mjs b/astro.config.mjs
index e762ba5..03e937c 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,5 +1,19 @@
// @ts-check
import { defineConfig } from 'astro/config';
+import sitemap from '@astrojs/sitemap';
// https://astro.build/config
-export default defineConfig({});
+export default defineConfig({
+ site: 'https://javirubio.net',
+ integrations: [
+ sitemap({
+ filter: (page) => !page.includes('/blog/draft/'),
+ }),
+ ],
+ markdown: {
+ shikiConfig: {
+ theme: 'github-light',
+ wrap: true,
+ },
+ },
+});
diff --git a/netlify/edge-functions/draft-auth.js b/netlify/edge-functions/draft-auth.js
new file mode 100644
index 0000000..bd98413
--- /dev/null
+++ b/netlify/edge-functions/draft-auth.js
@@ -0,0 +1,39 @@
+// Basic Auth gate for /blog/draft/* — runs on Netlify Edge.
+// Configure DRAFT_USER and DRAFT_PASSWORD as environment variables in the
+// Netlify dashboard. If either is unset, all draft requests are denied.
+
+export default async (request, context) => {
+ const user = Netlify.env.get('DRAFT_USER');
+ const password = Netlify.env.get('DRAFT_PASSWORD');
+
+ if (!user || !password) {
+ return new Response('Drafts disabled: credentials not configured.', {
+ status: 503,
+ });
+ }
+
+ const header = request.headers.get('Authorization') ?? '';
+ if (header.startsWith('Basic ')) {
+ const decoded = atob(header.slice(6));
+ const sep = decoded.indexOf(':');
+ if (sep !== -1) {
+ const providedUser = decoded.slice(0, sep);
+ const providedPassword = decoded.slice(sep + 1);
+ if (providedUser === user && providedPassword === password) {
+ return context.next();
+ }
+ }
+ }
+
+ return new Response('Authentication required.', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': 'Basic realm="Drafts", charset="UTF-8"',
+ 'Cache-Control': 'no-store',
+ },
+ });
+};
+
+export const config = {
+ path: '/blog/draft/*',
+};
diff --git a/package-lock.json b/package-lock.json
index 3e1864c..158c03f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,8 @@
"name": "temp-astro",
"version": "0.0.1",
"dependencies": {
+ "@astrojs/rss": "^4.0.18",
+ "@astrojs/sitemap": "^3.7.2",
"astro": "^5.16.15",
"typed.js": "^3.0.0"
},
@@ -68,6 +70,46 @@
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
}
},
+ "node_modules/@astrojs/rss": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz",
+ "integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-xml-parser": "^5.5.7",
+ "piccolore": "^0.1.3",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/rss/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@astrojs/sitemap": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.2.tgz",
+ "integrity": "sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==",
+ "license": "MIT",
+ "dependencies": {
+ "sitemap": "^9.0.0",
+ "stream-replace-string": "^2.0.0",
+ "zod": "^4.3.6"
+ }
+ },
+ "node_modules/@astrojs/sitemap/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@astrojs/telemetry": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz",
@@ -1042,6 +1084,18 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
+ "node_modules/@nodable/entities": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
+ "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -1177,9 +1231,6 @@
"cpu": [
"arm"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1193,9 +1244,6 @@
"cpu": [
"arm"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1209,9 +1257,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1225,9 +1270,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1241,9 +1283,6 @@
"cpu": [
"loong64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1257,9 +1296,6 @@
"cpu": [
"loong64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1273,9 +1309,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1289,9 +1322,6 @@
"cpu": [
"ppc64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1305,9 +1335,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1321,9 +1348,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1337,9 +1361,6 @@
"cpu": [
"s390x"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1353,9 +1374,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1369,9 +1387,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1571,6 +1586,24 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/node": {
+ "version": "24.12.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
+ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -1694,6 +1727,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2393,6 +2432,42 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/fast-xml-builder": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz",
+ "integrity": "sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "path-expression-matcher": "^1.1.3"
+ }
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz",
+ "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nodable/entities": "^2.1.0",
+ "fast-xml-builder": "^1.1.5",
+ "path-expression-matcher": "^1.5.0",
+ "strnum": "^2.2.3"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3841,6 +3916,21 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/path-expression-matcher": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
+ "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -4350,6 +4440,25 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
@@ -4381,6 +4490,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/stream-replace-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz",
+ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==",
+ "license": "MIT"
+ },
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@@ -4427,6 +4542,18 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/strnum": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
+ "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/svgo": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz",
@@ -4580,6 +4707,12 @@
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
diff --git a/package.json b/package.json
index bf0e35e..9e8a56f 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
"test:headed": "playwright test --headed"
},
"dependencies": {
+ "@astrojs/rss": "^4.0.18",
+ "@astrojs/sitemap": "^3.7.2",
"astro": "^5.16.15",
"typed.js": "^3.0.0"
},
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..ac69b05
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,5 @@
+User-agent: *
+Allow: /
+Disallow: /blog/draft/
+
+Sitemap: https://javirubio.net/sitemap-index.xml
diff --git a/src/content.config.js b/src/content.config.js
new file mode 100644
index 0000000..5efbee8
--- /dev/null
+++ b/src/content.config.js
@@ -0,0 +1,22 @@
+import { defineCollection, z } from 'astro:content';
+import { glob } from 'astro/loaders';
+
+const blog = defineCollection({
+ loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
+ schema: ({ image }) =>
+ z.object({
+ title: z.string(),
+ description: z.string(),
+ pubDate: z.coerce.date(),
+ updatedDate: z.coerce.date().optional(),
+ tags: z.array(z.string()).default([]),
+ heroImage: image().optional(),
+ heroImageAlt: z.string().optional(),
+ draft: z.boolean().default(false),
+ showToc: z.boolean().default(false),
+ canonicalURL: z.string().url().optional(),
+ originalSource: z.enum(['Medium', 'LinkedIn']).optional(),
+ }),
+});
+
+export const collections = { blog };
diff --git a/src/content/blog/hello-world.md b/src/content/blog/hello-world.md
new file mode 100644
index 0000000..74158cc
--- /dev/null
+++ b/src/content/blog/hello-world.md
@@ -0,0 +1,31 @@
+---
+title: "Hello, world"
+description: "First post on the new blog. A short note on why I'm finally writing in my own home on the web."
+pubDate: 2026-05-04
+tags:
+ - meta
+showToc: false
+---
+
+After years of scattering notes across LinkedIn, Medium, and conference talks, I'm consolidating writing here. This is home now.
+
+## Why a blog
+
+Owning the canonical URL of what I write is reason enough. Beyond that:
+
+- I want a single place that ages with me, not with a platform's pivot.
+- RSS, no tracking, no algorithmic feed. If you want to read this, subscribe — that's it.
+- Imports of older articles will land here over time, with `canonical` pointing to the original.
+
+## What to expect
+
+Topics I keep returning to: distributed teams, knowledge work, the cost of meetings, software craft, and the occasional rant. Short posts, no schedule.
+
+```js
+// also: occasionally some code
+function ship() {
+ return "small things, often";
+}
+```
+
+That's it. More soon.
diff --git a/src/layouts/BlogPost.astro b/src/layouts/BlogPost.astro
new file mode 100644
index 0000000..cdced45
--- /dev/null
+++ b/src/layouts/BlogPost.astro
@@ -0,0 +1,133 @@
+---
+import Layout from './Layout.astro';
+import { Image } from 'astro:assets';
+
+const { post, readingTime, headings, isDraft = false } = Astro.props;
+const { data } = post;
+const {
+ title,
+ description,
+ pubDate,
+ updatedDate,
+ tags,
+ heroImage,
+ heroImageAlt,
+ showToc,
+ canonicalURL,
+ originalSource,
+} = data;
+
+const dateFormatter = new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+
+const isoPub = new Date(pubDate).toISOString();
+const isoUpdated = updatedDate ? new Date(updatedDate).toISOString() : undefined;
+
+const postUrl = new URL(Astro.url.pathname, Astro.site).toString();
+const ogImagePath = heroImage?.src;
+
+const tocHeadings = (headings ?? []).filter((h) => h.depth === 2 || h.depth === 3);
+
+const articleSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: title,
+ description,
+ datePublished: isoPub,
+ ...(isoUpdated && { dateModified: isoUpdated }),
+ author: {
+ '@type': 'Person',
+ name: 'Javi Rubio',
+ url: 'https://javirubio.net',
+ },
+ mainEntityOfPage: {
+ '@type': 'WebPage',
+ '@id': postUrl,
+ },
+ ...(canonicalURL && { sameAs: [canonicalURL] }),
+};
+---
+
+
+ Updated
+
+ Originally published on{' '}
+
+ {originalSource} →
+
+ {title}
+
+ {updatedDate && (
+