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] }), +}; +--- + + + {!isDraft && ( +