diff --git a/.github/workflows/backfill-episodes.yml b/.github/workflows/backfill-episodes.yml new file mode 100644 index 0000000..23b8d81 --- /dev/null +++ b/.github/workflows/backfill-episodes.yml @@ -0,0 +1,41 @@ +name: Backfill Episodes to ATProto + +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "backfill" to confirm publishing all episodes to ATProto' + required: true + type: string + +jobs: + backfill: + runs-on: ubuntu-latest + permissions: + contents: read + if: ${{ github.event.inputs.confirm == 'backfill' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@v4 + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + - name: Backfill all episodes + run: pnpm tsx scripts/publish-episodes.ts --backfill + env: + ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }} + ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }} + STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }} + STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }} diff --git a/.github/workflows/publish-episodes.yml b/.github/workflows/publish-episodes.yml new file mode 100644 index 0000000..2ace86a --- /dev/null +++ b/.github/workflows/publish-episodes.yml @@ -0,0 +1,42 @@ +name: Publish Episodes to ATProto + +on: + # Run after the daily site rebuild to catch new episodes + workflow_run: + workflows: ["Rebuild Astro Site"] + types: [completed] + # Allow manual trigger + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + # Only run if the triggering workflow succeeded (or manual dispatch) + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@v4 + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + - name: Publish new episodes + run: pnpm tsx scripts/publish-episodes.ts + env: + ATPROTO_HANDLE: ${{ secrets.ATPROTO_HANDLE }} + ATPROTO_APP_PASSWORD: ${{ secrets.ATPROTO_APP_PASSWORD }} + STANDARD_SITE_URL: ${{ secrets.STANDARD_SITE_URL }} + STANDARD_SITE_PUBLICATION_RKEY: ${{ secrets.STANDARD_SITE_PUBLICATION_RKEY }} diff --git a/CLAUDE.md b/CLAUDE.md index 7bde5c1..151d6c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,3 +88,13 @@ configured for Preact (`jsxImportSource: "preact"`). - `ASTRO_DB_REMOTE_URL` — Turso/libSQL database URL (e.g., `libsql://your-db.turso.io`). - `ASTRO_DB_APP_TOKEN` — Authentication token for Turso database. +- `STANDARD_SITE_DID` — Your ATProto DID for standard.site verification (e.g., + `did:plc:abc123`). Find yours at https://bsky.app/settings. +- `STANDARD_SITE_PUBLICATION_RKEY` — The publication record key returned when + creating a publication via `scripts/create-publication.ts`. +- `ATPROTO_HANDLE` — Your Bluesky handle (e.g., `you.bsky.social`) for + publishing episodes to ATProto. +- `ATPROTO_APP_PASSWORD` — App password for ATProto API access. Create at + https://bsky.app/settings/app-passwords. +- `STANDARD_SITE_URL` — Your podcast website URL (e.g., `https://whiskey.fm`) + used as the publication site when publishing documents. diff --git a/README.md b/README.md index c3ae676..7516dc5 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,94 @@ environment variable it should work for you. Of course, feel free to customize the code [here](./src/pages/api/contact.ts) to send the data elsewhere as you see fit. +#### standard.site (ATProto Federation) + +Starpod supports [standard.site](https://standard.site/) — a specification that +connects your podcast website to [ATProto](https://atproto.com/) (the protocol +behind Bluesky). Each episode is published as an individual document on the +federated web. Enabling this allows: + +- **Verified ownership** — Cryptographically prove you own your content across + the federated web +- **Cross-platform discovery** — Your podcast appears on ATProto readers like + [Leaflet](https://leaflet.pub/) and [Pckt](https://pckt.blog) +- **Federated engagement** — Comments and interactions from Bluesky and other + ATProto apps can connect back to your site +- **Episode-level publishing** — Each episode is a standalone document in ATProto + +This feature is entirely optional. The site works perfectly without it — the +verification endpoint simply returns a 404 when unconfigured. No changes to +`astro.config.mjs` are needed. + +##### Initial Setup + +1. Create an [app password](https://bsky.app/settings/app-passwords) on Bluesky +2. Create your publication record (run once): + +```bash +ATPROTO_HANDLE=you.bsky.social \ +ATPROTO_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \ +STANDARD_SITE_URL=https://your-podcast.com \ +pnpm tsx scripts/create-publication.ts +``` + +3. Save the output values as environment variables + +##### Environment Variables + +Set these in your `.env` file for local development and as **GitHub Actions +secrets** for automated publishing: + +| Variable | Description | Where to find it | +|----------|-------------|------------------| +| `STANDARD_SITE_DID` | Your ATProto DID (decentralized identifier) | [bsky.app/settings](https://bsky.app/settings) → scroll to "DID" | +| `STANDARD_SITE_PUBLICATION_RKEY` | Record key for your publication | Returned by `scripts/create-publication.ts` | +| `ATPROTO_HANDLE` | Your Bluesky handle (e.g., `you.bsky.social`) | Your Bluesky username | +| `ATPROTO_APP_PASSWORD` | App password for ATProto API access | [bsky.app/settings/app-passwords](https://bsky.app/settings/app-passwords) | +| `STANDARD_SITE_URL` | Your podcast website URL (e.g., `https://whiskey.fm`) | Your deployed site URL | + +##### GitHub Actions Secrets + +Add the following secrets to your repository at **Settings → Secrets and +variables → Actions → New repository secret**: + +- `ATPROTO_HANDLE` +- `ATPROTO_APP_PASSWORD` +- `STANDARD_SITE_URL` +- `STANDARD_SITE_PUBLICATION_RKEY` +- `STANDARD_SITE_DID` + +##### Publishing Episodes + +Episodes are published to ATProto as individual documents automatically: + +- **Automatic** — The `Publish Episodes to ATProto` workflow runs after each + daily site rebuild and publishes any new episodes +- **Manual** — Trigger the workflow manually from the Actions tab +- **Backfill** — Use the `Backfill Episodes to ATProto` workflow (Actions tab → + Run workflow → type "backfill") to publish all existing episodes + +You can also publish locally: + +```bash +# Publish only new episodes +pnpm publish:episodes + +# Backfill all episodes +pnpm publish:episodes:backfill +``` + +##### Verification + +After deploying, verify the well-known endpoint with: + +```bash +curl https://your-site.com/.well-known/site.standard.publication +``` + +For full setup instructions (creating a publication, syncing posts, etc.), see +the [`@bryanguffey/astro-standard-site` README](https://github.com/musicjunkieg/astro-standard-site#readme). + #### Configuring guests We use Turso and Astro DB to setup guests per episode. If you would also like to diff --git a/package.json b/package.json index 3856403..da229ce 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "lint:fix": "eslint . --fix", "preview": "astro preview", "start": "astro dev", + "publish:episodes": "tsx scripts/publish-episodes.ts", + "publish:episodes:backfill": "tsx scripts/publish-episodes.ts --backfill", "test": "concurrently \"pnpm:test:*(!fix)\" --names \"test:\"", "test:e2e": "pnpm exec playwright test", "test:unit": "vitest" @@ -25,6 +27,7 @@ "dependencies": { "@astrojs/preact": "^5.1.4", "@astrojs/vercel": "^10.0.8", + "@bryanguffey/astro-standard-site": "^1.0.3", "@libsql/client": "^0.17.3", "@preact/signals": "^2.9.1", "@vercel/analytics": "^1.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eccea7e..389b06c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@astrojs/vercel': specifier: ^10.0.8 version: 10.0.8(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))(react@19.0.0)(rollup@4.60.4) + '@bryanguffey/astro-standard-site': + specifier: ^1.0.3 + version: 1.0.3(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0)) '@libsql/client': specifier: ^0.17.3 version: 0.17.3 @@ -217,6 +220,33 @@ packages: '@astrojs/yaml2ts@0.2.4': resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@atproto/api@0.15.27': + resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==} + + '@atproto/common-web@0.4.21': + resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} + + '@atproto/lex-data@0.0.15': + resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} + + '@atproto/lex-json@0.0.16': + resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} + + '@atproto/lexicon@0.4.14': + resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} + + '@atproto/lexicon@0.6.2': + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + + '@atproto/syntax@0.4.3': + resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} + + '@atproto/syntax@0.5.4': + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} + + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -314,6 +344,11 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bryanguffey/astro-standard-site@1.0.3': + resolution: {integrity: sha512-+4//yk8rRD3ZfC9uOKY7OHu7Shm5qMZSgFTIcDs079hcoV42yF8mK8O0AI6QMFb7iMCPqN9rO+leP+PMEEmR3A==} + peerDependencies: + astro: ^5.0.0 + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -1959,6 +1994,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} @@ -2896,6 +2934,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3251,6 +3292,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3872,6 +3916,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@7.4.2: resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} @@ -3936,6 +3984,9 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} @@ -3945,6 +3996,9 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4388,6 +4442,9 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4564,6 +4621,65 @@ snapshots: dependencies: yaml: 2.9.0 + '@atproto/api@0.15.27': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/lexicon': 0.4.14 + '@atproto/syntax': 0.4.3 + '@atproto/xrpc': 0.7.7 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.261.0 + zod: 3.25.76 + + '@atproto/common-web@0.4.21': + dependencies: + '@atproto/lex-data': 0.0.15 + '@atproto/lex-json': 0.0.16 + '@atproto/syntax': 0.5.4 + zod: 3.25.76 + + '@atproto/lex-data@0.0.15': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.0.16': + dependencies: + '@atproto/lex-data': 0.0.15 + tslib: 2.8.1 + + '@atproto/lexicon@0.4.14': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/syntax': 0.4.3 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/lexicon@0.6.2': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/syntax': 0.5.4 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/syntax@0.4.3': + dependencies: + tslib: 2.8.1 + + '@atproto/syntax@0.5.4': + dependencies: + tslib: 2.8.1 + + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.2 + zod: 3.25.76 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4695,6 +4811,12 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bryanguffey/astro-standard-site@1.0.3(astro@6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@atproto/api': 0.15.27 + astro: 6.4.2(@types/node@24.12.4)(@vercel/functions@3.6.1)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) + zod: 3.25.76 + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -6069,6 +6191,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + await-lock@2.2.2: {} + axios@1.16.1: dependencies: follow-redirects: 1.16.0 @@ -7076,6 +7200,8 @@ snapshots: isexe@2.0.0: {} + iso-datestring-validator@2.2.2: {} + jiti@2.7.0: {} js-base64@3.7.8: {} @@ -7591,6 +7717,8 @@ snapshots: muggle-string@0.4.1: {} + multiformats@9.9.0: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} @@ -8245,6 +8373,8 @@ snapshots: tinyrainbow@3.1.0: {} + tlds@1.261.0: {} + tldts-core@7.4.2: {} tldts@7.4.2: @@ -8297,12 +8427,18 @@ snapshots: ufo@1.6.4: {} + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + ultrahtml@1.6.0: {} uncrypto@0.1.3: {} undici-types@7.16.0: {} + unicode-segmenter@0.14.5: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8680,6 +8816,8 @@ snapshots: zimmerframe@1.1.4: {} + zod@3.25.76: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/scripts/create-publication.ts b/scripts/create-publication.ts new file mode 100644 index 0000000..948f08e --- /dev/null +++ b/scripts/create-publication.ts @@ -0,0 +1,54 @@ +/** + * Create or update the podcast publication record on ATProto + * + * Run this once to set up your publication. The returned rkey should be + * saved as the STANDARD_SITE_PUBLICATION_RKEY environment variable. + * + * Required environment variables: + * ATPROTO_HANDLE - Your Bluesky handle + * ATPROTO_APP_PASSWORD - An app password + * STANDARD_SITE_URL - Your podcast site URL + * + * Usage: + * ATPROTO_HANDLE=you.bsky.social ATPROTO_APP_PASSWORD=xxxx STANDARD_SITE_URL=https://whiskey.fm pnpm tsx scripts/create-publication.ts + */ + +import { StandardSitePublisher } from '@bryanguffey/astro-standard-site'; +import starpodConfig from '../starpod.config'; + +async function main() { + const identifier = process.env.ATPROTO_HANDLE; + const password = process.env.ATPROTO_APP_PASSWORD; + const siteUrl = process.env.STANDARD_SITE_URL; + + if (!identifier || !password || !siteUrl) { + console.error( + 'Missing required environment variables. Need: ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, STANDARD_SITE_URL' + ); + process.exit(1); + } + + const publisher = new StandardSitePublisher({ + identifier, + password + }); + + await publisher.login(); + console.log(`✅ Logged in as ${publisher.getDid()}`); + + const result = await publisher.publishPublication({ + name: starpodConfig.blurb, + url: siteUrl, + description: starpodConfig.description + }); + + console.log('\n🎉 Publication created!'); + console.log(`AT-URI: ${result.uri}`); + console.log(`\nSave this as STANDARD_SITE_PUBLICATION_RKEY: ${result.uri.split('/').pop()}`); + console.log(`Save this as STANDARD_SITE_DID: ${publisher.getDid()}`); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/publish-episodes.ts b/scripts/publish-episodes.ts new file mode 100644 index 0000000..dc0a62a --- /dev/null +++ b/scripts/publish-episodes.ts @@ -0,0 +1,169 @@ +/** + * Publish podcast episodes to ATProto via standard.site + * + * Each episode is published as an individual document record. + * Supports both incremental publishing (new episodes only) and + * full backfill (all episodes). + * + * Required environment variables: + * ATPROTO_HANDLE - Your Bluesky handle (e.g., your-handle.bsky.social) + * ATPROTO_APP_PASSWORD - An app password from bsky.app/settings/app-passwords + * STANDARD_SITE_URL - Your podcast site URL (e.g., https://whiskey.fm) + * STANDARD_SITE_PUBLICATION_RKEY - The publication record key + * + * Usage: + * pnpm publish:episodes # publish new episodes only + * pnpm publish:episodes:backfill # publish all episodes (backfill) + */ + +import { htmlToText } from 'html-to-text'; +import parseFeed from 'rss-to-json'; +import { array, number, object, optional, parse, string } from 'valibot'; + +import { + StandardSitePublisher, + type PublishDocumentInput +} from '@bryanguffey/astro-standard-site'; + +import starpodConfig from '../starpod.config'; +import { dasherize } from '../src/utils/dasherize'; + +const BACKFILL = process.argv.includes('--backfill'); + +const FeedSchema = object({ + items: array( + object({ + id: string(), + title: string(), + published: number(), + description: string(), + content_encoded: optional(string()), + itunes_duration: number(), + itunes_episode: optional(number()), + itunes_episodeType: optional(string()), + itunes_image: optional(object({ href: optional(string()) })), + enclosures: array( + object({ + url: string(), + type: string() + }) + ) + }) + ) +}); + +async function main() { + const identifier = process.env.ATPROTO_HANDLE; + const password = process.env.ATPROTO_APP_PASSWORD; + const siteUrl = process.env.STANDARD_SITE_URL; + const publicationRkey = process.env.STANDARD_SITE_PUBLICATION_RKEY; + + if (!identifier || !password || !siteUrl || !publicationRkey) { + console.error( + 'Missing required environment variables. Need: ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, STANDARD_SITE_URL, STANDARD_SITE_PUBLICATION_RKEY' + ); + process.exit(1); + } + + console.log( + BACKFILL ? '📚 Backfilling all episodes...' : '🆕 Publishing new episodes...' + ); + + // Fetch episodes from RSS + // @ts-expect-error rss-to-json types don't match runtime API + const feed = await parseFeed.parse(starpodConfig.rssFeed); + const { items } = parse(FeedSchema, feed); + + const episodes = items.filter( + (item) => item.itunes_episodeType !== 'trailer' + ); + + // Initialize publisher + const publisher = new StandardSitePublisher({ + identifier, + password + }); + + await publisher.login(); + console.log(`✅ Logged in as ${publisher.getDid()}`); + + // Get existing documents to avoid duplicates + const existingPaths = new Set(); + let cursor: string | undefined; + + // Paginate through all existing documents (ATProto caps at 100 per request) + do { + const agent = publisher.getAtpAgent(); + const response = await agent.com.atproto.repo.listRecords({ + repo: publisher.getDid(), + collection: 'site.standard.document', + limit: 100, + cursor + }); + + for (const record of response.data.records) { + const doc = record.value as { path?: string }; + if (doc.path) { + existingPaths.add(doc.path); + } + } + + cursor = response.data.cursor; + } while (cursor); + + let published = 0; + let skipped = 0; + + for (const episode of episodes) { + const slug = dasherize(episode.title); + const path = `/${slug}`; + + if (!BACKFILL && existingPaths.has(path)) { + skipped++; + continue; + } + + const description = htmlToText(episode.description, { + wordwrap: false + }).slice(0, 300); + + const textContent = episode.content_encoded + ? htmlToText(episode.content_encoded, { wordwrap: false }) + : description; + + const input: PublishDocumentInput = { + site: siteUrl, + path, + title: episode.title, + description, + publishedAt: new Date(episode.published).toISOString(), + textContent, + tags: ['podcast'] + }; + + try { + const result = await publisher.publishDocument(input); + console.log(` ✅ ${episode.title}`); + console.log(` → ${result.uri}`); + published++; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Skip duplicates gracefully during backfill + if (message.includes('already exists') || message.includes('duplicate')) { + console.log(` ⏭️ ${episode.title} (already exists)`); + skipped++; + } else { + console.error(` ❌ ${episode.title}: ${message}`); + } + } + } + + console.log( + `\n🎉 Done! Published: ${published}, Skipped: ${skipped}, Total episodes: ${episodes.length}` + ); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/pages/.well-known/site.standard.publication.ts b/src/pages/.well-known/site.standard.publication.ts new file mode 100644 index 0000000..985d50d --- /dev/null +++ b/src/pages/.well-known/site.standard.publication.ts @@ -0,0 +1,16 @@ +import type { APIRoute } from 'astro'; +import { generatePublicationWellKnown } from '@bryanguffey/astro-standard-site'; + +export const GET: APIRoute = () => { + const did = import.meta.env.STANDARD_SITE_DID; + const publicationRkey = import.meta.env.STANDARD_SITE_PUBLICATION_RKEY; + + if (!did || !publicationRkey) { + return new Response('standard.site not configured', { status: 404 }); + } + + return new Response( + generatePublicationWellKnown({ did, publicationRkey }), + { headers: { 'Content-Type': 'text/plain' } } + ); +};