diff --git a/astro.config.mjs b/astro.config.mjs
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,16 +2,34 @@
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
+import authproto from "@fujocoded/authproto";
// https://astro.build/config
export default defineConfig({
site: "https://atmosphere.community",
base: "/",
- output: "static",
+ output: "server",
server: {
host: true,
},
adapter: node({
mode: "standalone",
}),
+ session: {
+ driver: { entrypoint: "unstorage/drivers/memory" },
+ },
+ integrations: [
+ authproto({
+ applicationName: "Atmosphere.community",
+ applicationDomain: "https://atmosphere.community",
+ scopes: {
+ additionalScopes: [
+ "repo:community.lexicon.calendar.rsvp?action=create&action=update",
+ ],
+ },
+ driver: {
+ name: "memory",
+ },
+ }),
+ ],
});
diff --git a/package-lock.json b/package-lock.json
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,11 +8,70 @@
"name": "atproto-community",
"version": "0.0.1",
"dependencies": {
+ "@astrojs/check": "^0.9.8",
"@astrojs/node": "^10.0.5",
- "astro": "^6.1.7"
+ "@atproto/api": "^0.17.3",
+ "@atproto/identity": "^0.4.8",
+ "@atproto/syntax": "^0.4.3",
+ "@fujocoded/astro-atproto-loader": "^0.2.1",
+ "@fujocoded/authproto": "^0.3.1",
+ "astro": "^6.1.7",
+ "js-yaml": "^4.1.0",
+ "mdast-util-to-string": "^4.0.0",
+ "remark": "^15.0.1",
+ "typescript": "^5.9.3"
+ },
+ "devDependencies": {
+ "@types/js-yaml": "^4.0.9"
},
"engines": {
"node": ">=22.12.0"
+ }
+ },
+ "node_modules/@astrojs/check": {
+ "version": "0.9.9",
+ "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz",
+ "integrity": "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/language-server": "^2.16.7",
+ "chokidar": "^4.0.3",
+ "kleur": "^4.1.5",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "astro-check": "bin/astro-check.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@astrojs/check/node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
}
},
"node_modules/@astrojs/compiler": {
@@ -29,6 +88,53 @@
"dependencies": {
"picomatch": "^4.0.3"
}
+ },
+ "node_modules/@astrojs/language-server": {
+ "version": "2.16.7",
+ "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.7.tgz",
+ "integrity": "sha512-b64bWT74Vq/ORcSqW7TdIjjpB6hcl+Ei/lMANIUaAGlLPiYNtPTRI/j2tzvugT+LoVwfJtE2Ukq/t2OGCyEtfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@astrojs/compiler": "^2.13.1",
+ "@astrojs/yaml2ts": "^0.2.3",
+ "@jridgewell/sourcemap-codec": "^1.5.5",
+ "@volar/kit": "~2.4.28",
+ "@volar/language-core": "~2.4.28",
+ "@volar/language-server": "~2.4.28",
+ "@volar/language-service": "~2.4.28",
+ "muggle-string": "^0.4.1",
+ "tinyglobby": "^0.2.16",
+ "volar-service-css": "0.0.70",
+ "volar-service-emmet": "0.0.70",
+ "volar-service-html": "0.0.70",
+ "volar-service-prettier": "0.0.70",
+ "volar-service-typescript": "0.0.70",
+ "volar-service-typescript-twoslash-queries": "0.0.70",
+ "volar-service-yaml": "0.0.70",
+ "vscode-html-languageservice": "^5.6.2",
+ "vscode-uri": "^3.1.0"
+ },
+ "bin": {
+ "astro-ls": "bin/nodeServer.js"
+ },
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "prettier-plugin-astro": ">=0.11.0"
+ },
+ "peerDependenciesMeta": {
+ "prettier": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@astrojs/language-server/node_modules/@astrojs/compiler": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz",
+ "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==",
+ "license": "MIT"
},
"node_modules/@astrojs/markdown-remark": {
"version": "7.1.0",
@@ -101,6 +207,448 @@
},
"engines": {
"node": "18.20.8 || ^20.3.0 || >=22.0.0"
+ }
+ },
+ "node_modules/@astrojs/yaml2ts": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz",
+ "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==",
+ "license": "MIT",
+ "dependencies": {
+ "yaml": "^2.8.2"
+ }
+ },
+ "node_modules/@atproto-labs/did-resolver": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz",
+ "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/fetch": "0.2.3",
+ "@atproto-labs/pipe": "0.1.1",
+ "@atproto-labs/simple-store": "0.3.0",
+ "@atproto-labs/simple-store-memory": "0.1.4",
+ "@atproto/did": "0.3.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto-labs/did-resolver/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto-labs/fetch": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz",
+ "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/pipe": "0.1.1"
+ }
+ },
+ "node_modules/@atproto-labs/fetch-node": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz",
+ "integrity": "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/fetch": "0.2.3",
+ "@atproto-labs/pipe": "0.1.1",
+ "ipaddr.js": "^2.1.0",
+ "undici": "^6.14.1"
+ },
+ "engines": {
+ "node": ">=18.7.0"
+ }
+ },
+ "node_modules/@atproto-labs/handle-resolver": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz",
+ "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/simple-store": "0.3.0",
+ "@atproto-labs/simple-store-memory": "0.1.4",
+ "@atproto/did": "0.3.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto-labs/handle-resolver-node": {
+ "version": "0.1.25",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.25.tgz",
+ "integrity": "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/fetch-node": "0.2.0",
+ "@atproto-labs/handle-resolver": "0.3.6",
+ "@atproto/did": "0.3.0"
+ },
+ "engines": {
+ "node": ">=18.7.0"
+ }
+ },
+ "node_modules/@atproto-labs/handle-resolver/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto-labs/identity-resolver": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz",
+ "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "0.2.6",
+ "@atproto-labs/handle-resolver": "0.3.6"
+ }
+ },
+ "node_modules/@atproto-labs/pipe": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz",
+ "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==",
+ "license": "MIT"
+ },
+ "node_modules/@atproto-labs/simple-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz",
+ "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==",
+ "license": "MIT"
+ },
+ "node_modules/@atproto-labs/simple-store-memory": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz",
+ "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/simple-store": "0.3.0",
+ "lru-cache": "^10.2.0"
+ }
+ },
+ "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/@atproto/api": {
+ "version": "0.17.7",
+ "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.17.7.tgz",
+ "integrity": "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.3",
+ "@atproto/lexicon": "^0.5.1",
+ "@atproto/syntax": "^0.4.1",
+ "@atproto/xrpc": "^0.7.5",
+ "await-lock": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "tlds": "^1.234.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/api/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/common-web": {
+ "version": "0.4.21",
+ "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz",
+ "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.15",
+ "@atproto/lex-json": "^0.0.16",
+ "@atproto/syntax": "^0.5.4",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/common-web/node_modules/@atproto/syntax": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz",
+ "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/common-web/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/crypto": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz",
+ "integrity": "sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^1.7.0",
+ "@noble/hashes": "^1.6.1",
+ "uint8arrays": "3.0.0"
+ },
+ "engines": {
+ "node": ">=18.7.0"
+ }
+ },
+ "node_modules/@atproto/did": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz",
+ "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/did/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/identity": {
+ "version": "0.4.12",
+ "resolved": "https://registry.npmjs.org/@atproto/identity/-/identity-0.4.12.tgz",
+ "integrity": "sha512-P+Jn0HvKhIh1tps5n3xGrCxt+XiFWzp4kdgloyFhFmVLwjDU547DQkWx4r5Vhuiah7fRZGVSlk39R4U6SPrACg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.17",
+ "@atproto/crypto": "^0.4.5"
+ },
+ "engines": {
+ "node": ">=18.7.0"
+ }
+ },
+ "node_modules/@atproto/jwk": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz",
+ "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/jwk-jose": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz",
+ "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/jwk": "0.6.0",
+ "jose": "^5.2.0"
+ }
+ },
+ "node_modules/@atproto/jwk-webcrypto": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz",
+ "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/jwk": "0.6.0",
+ "@atproto/jwk-jose": "0.1.11",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/jwk-webcrypto/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/jwk/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/lex-data": {
+ "version": "0.0.15",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz",
+ "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.9.0",
+ "tslib": "^2.8.1",
+ "uint8arrays": "3.0.0",
+ "unicode-segmenter": "^0.14.0"
+ }
+ },
+ "node_modules/@atproto/lex-json": {
+ "version": "0.0.16",
+ "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz",
+ "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lex-data": "^0.0.15",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/lexicon": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.2.tgz",
+ "integrity": "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.4",
+ "@atproto/syntax": "^0.4.1",
+ "iso-datestring-validator": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/lexicon/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/oauth-client": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.6.1.tgz",
+ "integrity": "sha512-QTLbEFyv7EJuwJf4A8IZnsylK5wwrzrSsxy0INcZf9zktPVQvgckWhSvbfK8alp60M+rwWfQQAlodcCF4WB78A==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "^0.2.6",
+ "@atproto-labs/fetch": "^0.2.3",
+ "@atproto-labs/handle-resolver": "^0.3.6",
+ "@atproto-labs/identity-resolver": "^0.3.6",
+ "@atproto-labs/simple-store": "^0.3.0",
+ "@atproto-labs/simple-store-memory": "^0.1.4",
+ "@atproto/did": "^0.3.0",
+ "@atproto/jwk": "^0.6.0",
+ "@atproto/oauth-types": "^0.6.3",
+ "@atproto/xrpc": "^0.7.7",
+ "core-js": "^3",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/oauth-client-node": {
+ "version": "0.3.17",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.17.tgz",
+ "integrity": "sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto-labs/did-resolver": "^0.2.6",
+ "@atproto-labs/handle-resolver-node": "^0.1.25",
+ "@atproto-labs/simple-store": "^0.3.0",
+ "@atproto/did": "^0.3.0",
+ "@atproto/jwk": "^0.6.0",
+ "@atproto/jwk-jose": "^0.1.11",
+ "@atproto/jwk-webcrypto": "^0.2.0",
+ "@atproto/oauth-client": "^0.6.0",
+ "@atproto/oauth-types": "^0.6.3"
+ },
+ "engines": {
+ "node": ">=18.7.0"
+ }
+ },
+ "node_modules/@atproto/oauth-client/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/oauth-types": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.3.tgz",
+ "integrity": "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/did": "^0.3.0",
+ "@atproto/jwk": "^0.6.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/oauth-types/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/@atproto/syntax": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz",
+ "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/xrpc": {
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz",
+ "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/lexicon": "^0.6.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/xrpc/node_modules/@atproto/lexicon": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz",
+ "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/common-web": "^0.4.18",
+ "@atproto/syntax": "^0.5.0",
+ "iso-datestring-validator": "^2.2.2",
+ "multiformats": "^9.9.0",
+ "zod": "^3.23.8"
+ }
+ },
+ "node_modules/@atproto/xrpc/node_modules/@atproto/syntax": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz",
+ "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@atproto/xrpc/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -182,6 +730,61 @@
"fast-wrap-ansi": "^0.1.3",
"sisteransi": "^1.0.5"
}
+ },
+ "node_modules/@emmetio/abbreviation": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
+ "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-abbreviation": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz",
+ "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==",
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.4"
+ }
+ },
+ "node_modules/@emmetio/css-parser": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz",
+ "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/stream-reader": "^2.2.0",
+ "@emmetio/stream-reader-utils": "^0.1.0"
+ }
+ },
+ "node_modules/@emmetio/html-matcher": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz",
+ "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@emmetio/scanner": "^1.0.0"
+ }
+ },
+ "node_modules/@emmetio/scanner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz",
+ "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==",
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz",
+ "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==",
+ "license": "MIT"
+ },
+ "node_modules/@emmetio/stream-reader-utils": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz",
+ "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==",
+ "license": "MIT"
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
@@ -607,6 +1210,47 @@
],
"engines": {
"node": ">=18"
+ }
+ },
+ "node_modules/@fujocoded/astro-atproto-loader": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@fujocoded/astro-atproto-loader/-/astro-atproto-loader-0.2.1.tgz",
+ "integrity": "sha512-ZfmIjHgn5WxQSsnsrx3BiGV90F+U4cxDBBGehfsfJXenpgQVHJNiSoWyUbi5DF+bBZX5deli3ZekrA4fVzzOOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.17.3",
+ "@atproto/identity": "^0.4.8",
+ "@atproto/syntax": "^0.4.2"
+ },
+ "peerDependencies": {
+ "astro": "^5.13.0 || ^6.0.0"
+ }
+ },
+ "node_modules/@fujocoded/authproto": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@fujocoded/authproto/-/authproto-0.3.1.tgz",
+ "integrity": "sha512-Swa67n0zi7haalyDcdjbvabs9hTBvwI/zhE4Hodj98zUXK2YOgwF0SZI96C7G0xth6QxuD8N+U8If6Ljha3Uow==",
+ "license": "MIT",
+ "dependencies": {
+ "@atproto/api": "^0.17.3",
+ "@atproto/identity": "^0.4.8",
+ "@atproto/jwk-jose": "^0.1.4",
+ "@atproto/oauth-client-node": "^0.3.8",
+ "astro-integration-kit": "^0.20.0",
+ "unstorage": "^1.16.1"
+ },
+ "peerDependencies": {
+ "@astrojs/db": "^0.17.1 || ^0.20.0",
+ "astro": "^5.13.0 || ^6.0.0",
+ "drizzle-orm": "^0.42.0"
+ },
+ "peerDependenciesMeta": {
+ "@astrojs/db": {
+ "optional": true
+ },
+ "drizzle-orm": {
+ "optional": true
+ }
}
},
"node_modules/@img/colour": {
@@ -1080,6 +1724,33 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.7",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
+ "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
@@ -1558,6 +2229,13 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -1593,6 +2271,150 @@
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
+ },
+ "node_modules/@volar/kit": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
+ "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "typesafe-path": "^0.2.2",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/language-server": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz",
+ "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@volar/language-service": "2.4.28",
+ "@volar/typescript": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "request-light": "^0.7.0",
+ "vscode-languageserver": "^9.0.1",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/language-service": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz",
+ "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "vscode-languageserver-protocol": "^3.17.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/emmet-helper": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
+ "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==",
+ "license": "MIT",
+ "dependencies": {
+ "emmet": "^2.4.3",
+ "jsonc-parser": "^2.3.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.15.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vscode/l10n": {
+ "version": "0.0.18",
+ "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
+ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
+ "license": "MIT"
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
},
"node_modules/anymatch": {
"version": "3.1.3",
@@ -1721,6 +2543,25 @@
"sharp": "^0.34.0"
}
},
+ "node_modules/astro-integration-kit": {
+ "version": "0.20.0",
+ "resolved": "https://registry.npmjs.org/astro-integration-kit/-/astro-integration-kit-0.20.0.tgz",
+ "integrity": "sha512-VQQs7hS9M3XrJtxSLHVDhvHzgqwZuhot3sMoYImBJSvL7fMnleSVgkZhoEeRJd1+Qh96eb469nRnn5mdjIJJMQ==",
+ "deprecated": "Check the migration guide: https://astro-integration-kit.netlify.app/migration-guide/",
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ },
+ "peerDependencies": {
+ "astro": "^4.14.0 || ^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/await-lock": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
+ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
+ "license": "MIT"
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1816,6 +2657,20 @@
"node": ">=8"
}
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1824,6 +2679,24 @@
"engines": {
"node": ">=6"
}
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
@@ -1871,6 +2744,17 @@
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz",
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
+ },
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
},
"node_modules/crossws": {
"version": "0.3.5",
@@ -2141,6 +3025,28 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emmet": {
+ "version": "2.4.11",
+ "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz",
+ "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/scanner",
+ "./packages/abbreviation",
+ "./packages/css-abbreviation",
+ "./"
+ ],
+ "dependencies": {
+ "@emmetio/abbreviation": "^2.3.3",
+ "@emmetio/css-abbreviation": "^2.1.8"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -2209,6 +3115,15 @@
"@esbuild/win32-x64": "0.27.7"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2254,6 +3169,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
"node_modules/fast-string-truncated-width": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
@@ -2268,6 +3189,22 @@
"dependencies": {
"fast-string-truncated-width": "^1.2.0"
}
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+ "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
},
"node_modules/fast-wrap-ansi": {
"version": "0.1.6",
@@ -2346,6 +3283,15 @@
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/github-slugger": {
@@ -2596,6 +3542,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ipaddr.js": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
+ "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/iron-webcrypto": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
@@ -2618,6 +3573,15 @@
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/is-inside-container": {
@@ -2665,6 +3629,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/iso-datestring-validator": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
+ "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==",
+ "license": "MIT"
+ },
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2675,6 +3654,27 @@
},
"bin": {
"js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonc-parser": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
+ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
+ "license": "MIT"
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/longest-streak": {
@@ -3560,6 +4560,18 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "license": "MIT"
+ },
+ "node_modules/multiformats": {
+ "version": "9.9.0",
+ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
+ "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
+ "license": "(Apache-2.0 AND MIT)"
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -3768,6 +4780,18 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "license": "MIT"
+ },
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -3818,6 +4842,21 @@
},
"engines": {
"node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prismjs": {
@@ -3952,6 +4991,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark": {
+ "version": "15.0.1",
+ "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
+ "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -4031,6 +5086,30 @@
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/request-light": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz",
+ "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==",
+ "license": "MIT"
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
}
},
"node_modules/retext": {
@@ -4307,6 +5386,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4319,6 +5412,18 @@
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/svgo": {
@@ -4386,6 +5491,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tlds": {
+ "version": "1.261.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
+ "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
+ "license": "MIT",
+ "bin": {
+ "tlds": "bin.js"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -4439,14 +5553,50 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
+ },
+ "node_modules/typesafe-path": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
+ "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==",
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-auto-import-cache": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz",
+ "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.8"
+ }
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
+ },
+ "node_modules/uint8arrays": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
+ "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
+ "license": "MIT",
+ "dependencies": {
+ "multiformats": "^9.4.2"
+ }
},
"node_modules/ultrahtml": {
"version": "1.6.0",
@@ -4458,6 +5608,21 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
+ "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/unicode-segmenter": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz",
+ "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==",
"license": "MIT"
},
"node_modules/unified": {
@@ -4844,6 +6009,243 @@
}
}
},
+ "node_modules/volar-service-css": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
+ "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-css-languageservice": "^6.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-emmet": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz",
+ "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@emmetio/css-parser": "^0.4.1",
+ "@emmetio/html-matcher": "^1.3.0",
+ "@vscode/emmet-helper": "^2.9.3",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-html": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz",
+ "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-html-languageservice": "^5.3.0",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-prettier": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz",
+ "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0",
+ "prettier": "^2.2 || ^3.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ },
+ "prettier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz",
+ "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==",
+ "license": "MIT",
+ "dependencies": {
+ "path-browserify": "^1.0.1",
+ "semver": "^7.6.2",
+ "typescript-auto-import-cache": "^0.3.5",
+ "vscode-languageserver-textdocument": "^1.0.11",
+ "vscode-nls": "^5.2.0",
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-typescript-twoslash-queries": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz",
+ "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/volar-service-yaml": {
+ "version": "0.0.70",
+ "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz",
+ "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-uri": "^3.0.8",
+ "yaml-language-server": "~1.20.0"
+ },
+ "peerDependencies": {
+ "@volar/language-service": "~2.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@volar/language-service": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-css-languageservice": {
+ "version": "6.3.10",
+ "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz",
+ "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-html-languageservice": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz",
+ "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "vscode-languageserver-textdocument": "^1.0.12",
+ "vscode-languageserver-types": "^3.17.5",
+ "vscode-uri": "^3.1.0"
+ }
+ },
+ "node_modules/vscode-json-languageservice": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz",
+ "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonc-parser": "^3.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-nls": "^5.0.0",
+ "vscode-uri": "^3.0.2"
+ },
+ "engines": {
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/vscode-json-languageservice/node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "bin": {
+ "installServerIntoExtension": "bin/installServerIntoExtension"
+ }
+ },
+ "node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/vscode-languageserver-textdocument": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
+ "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-nls": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz",
+ "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==",
+ "license": "MIT"
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "license": "MIT"
+ },
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
@@ -4863,11 +6265,110 @@
"node": ">=4"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
"integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
"license": "MIT"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
+ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yaml-language-server": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz",
+ "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vscode/l10n": "^0.0.18",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "prettier": "^3.5.0",
+ "request-light": "^0.5.7",
+ "vscode-json-languageservice": "4.1.8",
+ "vscode-languageserver": "^9.0.0",
+ "vscode-languageserver-textdocument": "^1.0.1",
+ "vscode-languageserver-types": "^3.16.0",
+ "vscode-uri": "^3.0.2",
+ "yaml": "2.7.1"
+ },
+ "bin": {
+ "yaml-language-server": "bin/yaml-language-server"
+ }
+ },
+ "node_modules/yaml-language-server/node_modules/request-light": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz",
+ "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==",
+ "license": "MIT"
+ },
+ "node_modules/yaml-language-server/node_modules/yaml": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
+ "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
},
"node_modules/yargs-parser": {
"version": "22.0.0",
@@ -4876,6 +6377,15 @@
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
}
},
"node_modules/yocto-queue": {
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -13,7 +13,20 @@
"astro": "astro"
},
"dependencies": {
+ "@astrojs/check": "^0.9.8",
"@astrojs/node": "^10.0.5",
- "astro": "^6.1.7"
+ "@atproto/api": "^0.17.3",
+ "@atproto/identity": "^0.4.8",
+ "@atproto/syntax": "^0.4.3",
+ "@fujocoded/astro-atproto-loader": "^0.2.1",
+ "@fujocoded/authproto": "^0.3.1",
+ "astro": "^6.1.7",
+ "js-yaml": "^4.1.0",
+ "mdast-util-to-string": "^4.0.0",
+ "remark": "^15.0.1",
+ "typescript": "^5.9.3"
+ },
+ "devDependencies": {
+ "@types/js-yaml": "^4.0.9"
}
}
diff --git a/public/atmosphere-logo.svg b/public/atmosphere-logo.svg
new file mode 100644
--- /dev/null
+++ b/public/atmosphere-logo.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/live.config.ts b/src/live.config.ts
new file mode 100644
--- /dev/null
+++ b/src/live.config.ts
@@ -0,0 +1,238 @@
+import { defineLiveCollection } from "astro:content";
+import { z } from "astro/zod";
+import { defineAtProtoLiveCollection } from "@fujocoded/astro-atproto-loader";
+import { getProfile, prefetchSourceProfiles } from "./lib/community/index.js";
+import yaml from "js-yaml";
+import communitiesRaw from "./data/communities.yml?raw";
+import {
+ eventFilters,
+ eventGroupKey,
+ eventsOutputSchema,
+ feedFilters,
+ feedOutputSchema,
+ feedTransformers,
+ transformEventGroup,
+ type LoaderArgs,
+} from "./lib/live-handlers";
+
+interface CommunityDefinition {
+ name: string;
+ handle: string;
+ location: string;
+ region?: string;
+ description?: string;
+ bluesky?: string;
+ website?: string;
+}
+
+type AvatarSource =
+ | { url: string }
+ | { initials: string; color: string };
+
+interface CommunityCardData
+ extends CommunityDefinition, Record {
+ avatar: AvatarSource;
+}
+
+function communityAvatar(handle: string, url?: string): AvatarSource {
+ if (url) return { url };
+ return { initials: avatarInitials(handle), color: avatarColor(handle) };
+}
+
+// Strip well-known atproto/bluesky suffixes so the abbreviation reflects the
+// distinctive part of a handle (e.g. "@nyc.atproto.camp" → "NYC").
+const HANDLE_SUFFIXES =
+ /\.(bsky\.social|atproto\.camp|atprotocol\.community|atprotocol\.space)$/;
+function avatarInitials(handle: string): string {
+ return handle
+ .replace(HANDLE_SUFFIXES, "")
+ .replace(/^atproto\.?/, "")
+ .slice(0, 3)
+ .toUpperCase();
+}
+function avatarColor(handle: string): string {
+ let hash = 0;
+ for (const ch of handle) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
+ const hue = ((hash % 360) + 360) % 360;
+ return `oklch(50% 0.13 ${hue})`;
+}
+
+// Per-author avatar fallback derivation for the feed: takes a label that may be
+// either a displayName ("Jane Doe" → "JD") or a handle ("@nyc.atproto.camp" → "NA").
+// Splits on the separators commonly found in handles so the initials track the
+// distinctive parts of the label.
+function authorInitials(label: string): string {
+ return label
+ .replace(/^@/, "")
+ .split(/[.\-_ ]/)
+ .slice(0, 2)
+ .map((s) => s[0]?.toUpperCase() ?? "")
+ .join("");
+}
+function authorColor(name: string): string {
+ let hash = 0;
+ for (const ch of name) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
+ const hue = ((hash % 360) + 360) % 360;
+ return `oklch(55% 0.12 ${hue})`;
+}
+function enrichAuthor(author: {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar?: string;
+}) {
+ const label = author.displayName || `@${author.handle}`;
+ return {
+ did: author.did,
+ handle: author.handle,
+ displayName: author.displayName,
+ avatar: author.avatar
+ ? { url: author.avatar }
+ : { initials: authorInitials(label), color: authorColor(label) },
+ };
+}
+
+const communityDefinitions = yaml.load(communitiesRaw) as CommunityDefinition[];
+// atmosphere.community is the site's own account — kept separate from the YAML-defined
+// communities because it publishes a different lexicon (site.standard.document, see feed below)
+// and isn't itself a "community" in the listing sense.
+const communityAccounts = [
+ "atmosphere.community",
+ ...communityDefinitions.map((community) => community.handle),
+];
+
+// Warm the source-profile cache once at server boot. Per-record transformers
+// in lib/live-handlers (e.g. site.standard.document → getProfile('atmosphere.community'),
+// shared-content → getProfile(author handle)) become cache hits for our curated set.
+// External authors referenced from shared-content fall through to the regular
+// profile cache. The source cache is never evicted; the general one may be later.
+await prefetchSourceProfiles(communityAccounts);
+
+const feed = defineAtProtoLiveCollection({
+ outputSchema: feedOutputSchema,
+ cacheTtl: 300,
+ sources: [
+ // The site's own curated posts use site.standard.document; community accounts share
+ // links via community.opensocial.sharedContent. Both feed into the same output schema
+ // with per-collection filter/transform dispatch below.
+ {
+ repo: "atmosphere.community",
+ collection: "site.standard.document" as const,
+ limit: 200,
+ },
+ ...communityAccounts.map((repo) => ({
+ repo,
+ collection: "community.opensocial.sharedContent" as const,
+ limit: 200,
+ })),
+ ],
+ // A single broken/unreachable repo shouldn't blank out the whole feed during build.
+ onSourceError: "skip",
+ filter: (ctx) => feedFilters[ctx.collection](ctx as LoaderArgs),
+ // Enrich the author with avatar-fallback fields (initials + color) here in the
+ // live config so components consume ready-to-render data instead of recomputing.
+ transform: async (ctx) => {
+ const result = await feedTransformers[ctx.collection](ctx as LoaderArgs);
+ if (!result) return null;
+ return {
+ ...result,
+ data: { ...result.data, author: enrichAuthor(result.data.author) },
+ };
+ },
+});
+
+const events = defineAtProtoLiveCollection({
+ outputSchema: eventsOutputSchema,
+ cacheTtl: 300,
+ sources: [
+ // Two paths into the events list:
+ // 1. Native calendar records authored by the community itself.
+ // 2. Shared-content records pointing at someone else's event — re-resolved by the
+ // grouped transformer so the listing surfaces events a community is *boosting*.
+ // Natives are listed first so that, for any group that contains both a native and
+ // its reshares, the native arrives at index 0 of `records` and wins as canonical.
+ ...communityAccounts.map((repo) => ({
+ repo,
+ collection: "community.lexicon.calendar.event" as const,
+ limit: 200,
+ })),
+ ...communityAccounts.map((repo) => ({
+ repo,
+ collection: "community.opensocial.sharedContent" as const,
+ limit: 200,
+ })),
+ ],
+ onSourceError: "skip",
+ filter: (ctx) => eventFilters[ctx.collection](ctx as LoaderArgs),
+ // groupBy collapses native + reshares of the same canonical at-URI into one entry;
+ // the grouped transform picks the canonical record and annotates `original`/`sharedBy`.
+ groupBy: (ctx) => eventGroupKey(ctx as LoaderArgs),
+ transform: (args) =>
+ transformEventGroup({
+ key: args.key,
+ records: args.records as LoaderArgs[],
+ fetchRecord: args.fetchRecord,
+ }),
+});
+
+// The community list itself is YAML-driven (curated, not network-discovered). The loader's
+// only job is to enrich each static entry with the live ATProto profile avatar.
+const communities = defineLiveCollection({
+ loader: {
+ name: "communities-live",
+ async loadCollection() {
+ // allSettled, not all: a single unreachable PDS shouldn't blank out the whole listing.
+ // Failed profile lookups simply fall back to no avatar.
+ const profileResults = await Promise.allSettled(
+ communityDefinitions.map((community) => getProfile(community.handle)),
+ );
+
+ return {
+ entries: communityDefinitions.map((community, index) => {
+ const result = profileResults[index];
+ const profileAvatar =
+ result?.status === "fulfilled" ? result.value.avatar : undefined;
+ return {
+ id: community.handle,
+ data: {
+ ...community,
+ avatar: communityAvatar(community.handle, profileAvatar),
+ } satisfies CommunityCardData,
+ };
+ }),
+ };
+ },
+ // Single-entry path used by per-community detail pages — avoids fetching every profile
+ // when only one is needed.
+ async loadEntry({ filter }: { filter: { id: string } }) {
+ const community = communityDefinitions.find(
+ (entry) => entry.handle === filter.id,
+ );
+ if (!community) return undefined;
+
+ const profile = await getProfile(community.handle).catch(() => undefined);
+ return {
+ id: community.handle,
+ data: {
+ ...community,
+ avatar: communityAvatar(community.handle, profile?.avatar),
+ } satisfies CommunityCardData,
+ };
+ },
+ },
+ schema: z.object({
+ name: z.string(),
+ handle: z.string(),
+ location: z.string(),
+ region: z.string().optional(),
+ description: z.string().optional(),
+ bluesky: z.string().optional(),
+ website: z.string().optional(),
+ avatar: z.union([
+ z.object({ url: z.string() }),
+ z.object({ initials: z.string(), color: z.string() }),
+ ]),
+ }),
+});
+
+export const collections = { feed, events, communities };
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,13 @@
+import { defineMiddleware } from "astro:middleware";
+
+// Keep dev traffic on 127.0.0.1: ATProto OAuth callbacks always come back on
+// 127.0.0.1, so a stray localhost session ends up with cookies on a different
+// origin than the post-login session — login appears to silently fail.
+export const onRequest = defineMiddleware((context, next) => {
+ if (import.meta.env.DEV && context.url.hostname === "localhost") {
+ const url = new URL(context.url);
+ url.hostname = "127.0.0.1";
+ return context.redirect(url.toString(), 307);
+ }
+ return next();
+});
diff --git a/src/actions/index.ts b/src/actions/index.ts
new file mode 100644
--- /dev/null
+++ b/src/actions/index.ts
@@ -0,0 +1,96 @@
+// TODO: refactor onto @fujocoded/astro-smooth-action once it's officially out.
+// Drops cleanRedirect / redirectWithStatus / isPermissionError / redirectUrl return shape:
+// throw ActionError for failures, return { status, eventName }, let middleware do PRG via
+// session storage. Also remove the `redirect` form field and the `?rsvp=&event=` query-param
+// channel in events.astro + EventCard.astro.
+
+import { defineAction } from "astro:actions";
+import { z } from "astro/zod";
+import { AtUri } from "@atproto/api";
+
+import {
+ RSVP_STATUS_GOING,
+ RSVP_STATUS_NOT_GOING,
+ setRsvpStatus,
+ type RsvpStatus,
+} from "../lib/rsvps";
+
+const EVENT_COLLECTION = "community.lexicon.calendar.event";
+
+function isValidEventUri(uri: string): boolean {
+ try {
+ const parsed = new AtUri(uri);
+ return (
+ parsed.host.startsWith("did:") &&
+ parsed.collection === EVENT_COLLECTION &&
+ parsed.rkey.length > 0
+ );
+ } catch {
+ return false;
+ }
+}
+
+const FORM_STATUS_TO_RSVP_STATUS: Record<"going" | "notgoing", RsvpStatus> = {
+ going: RSVP_STATUS_GOING,
+ notgoing: RSVP_STATUS_NOT_GOING,
+};
+
+function redirectWithStatus(status: string): string {
+ return `/events?rsvp=${encodeURIComponent(status)}`;
+}
+
+function isPermissionError(error: unknown): boolean {
+ const maybeError = error as {
+ status?: number;
+ error?: string;
+ message?: string;
+ };
+ const text = `${maybeError.error ?? ""} ${
+ maybeError.message ?? ""
+ }`.toLowerCase();
+ return (
+ maybeError.status === 401 ||
+ maybeError.status === 403 ||
+ text.includes("scope")
+ );
+}
+
+export const server = {
+ rsvpEvent: defineAction({
+ accept: "form",
+ input: z.object({
+ eventUri: z.string(),
+ eventCid: z.string(),
+ status: z.enum(["going", "notgoing"]),
+ }),
+ handler: async (input, ctx) => {
+ const loggedInUser = ctx.locals.loggedInUser;
+
+ if (!loggedInUser) {
+ return { redirectUrl: redirectWithStatus("error") };
+ }
+
+ if (!isValidEventUri(input.eventUri) || input.eventCid.length === 0) {
+ return { redirectUrl: redirectWithStatus("error") };
+ }
+
+ try {
+ await setRsvpStatus(
+ loggedInUser,
+ { uri: input.eventUri, cid: input.eventCid },
+ FORM_STATUS_TO_RSVP_STATUS[input.status],
+ );
+ } catch (error) {
+ return {
+ redirectUrl: redirectWithStatus(
+ isPermissionError(error) ? "permission" : "error",
+ ),
+ };
+ }
+
+ return {
+ redirectUrl: redirectWithStatus(input.status),
+ };
+ },
+ }),
+};
diff --git a/src/components/Avatar.astro b/src/components/Avatar.astro
new file mode 100644
--- /dev/null
+++ b/src/components/Avatar.astro
@@ -0,0 +1,32 @@
+---
+export type AvatarSource = { url: string } | { initials: string; color: string };
+
+interface Props {
+ avatar: AvatarSource;
+ size: number;
+ imgClass?: string;
+ fallbackClass?: string;
+ alt?: string;
+}
+
+const { avatar, size, imgClass, fallbackClass, alt = "" } = Astro.props;
+---
+{"url" in avatar ? (
+
+) : (
+
+ {avatar.initials}
+
+)}
diff --git a/src/components/CommunityCard.astro b/src/components/CommunityCard.astro
deleted file mode 100644
--- a/src/components/CommunityCard.astro
+++ /dev/null
@@ -1,115 +0,0 @@
----
-interface Props {
- name: string;
- handle: string;
- location: string;
- description?: string;
- bluesky?: string;
- website?: string;
- avatar?: string;
-}
-
-const { name, handle, location, description, bluesky, website, avatar } = Astro.props;
-
-// Generate initials from the handle for the colored circle (fallback when no avatar)
-const abbr = handle
- .replace(/\.(bsky\.social|atproto\.camp|atprotocol\.community|atprotocol\.space)$/, '')
- .replace(/^atproto\.?/, '')
- .slice(0, 3)
- .toUpperCase();
-
-// Deterministic color from handle
-function handleColor(h: string): string {
- let hash = 0;
- for (const ch of h) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
- const hue = ((hash % 360) + 360) % 360;
- return `oklch(50% 0.13 ${hue})`;
-}
----
-
-
-
-
diff --git a/src/components/EventCard.astro b/src/components/EventCard.astro
deleted file mode 100644
--- a/src/components/EventCard.astro
+++ /dev/null
@@ -1,97 +0,0 @@
----
-interface Props {
- name: string;
- date: string;
- endDate?: string;
- location?: string;
- mode?: 'online' | 'in_person' | 'hybrid';
- description?: string;
- href: string;
- source?: string;
-}
-
-const { name, date, endDate, location, mode = 'in_person', description, href, source } = Astro.props;
-
-const eventDate = new Date(date);
-const formattedDate = eventDate.toLocaleDateString('en-US', {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
- timeZone: 'UTC',
-});
-const formattedTime = eventDate.toLocaleTimeString('en-US', {
- hour: 'numeric',
- minute: '2-digit',
- timeZone: 'UTC',
-});
-
-const modeLabel = mode === 'online' ? 'Online' : mode === 'hybrid' ? 'Hybrid' : location ?? 'In Person';
-const badgeClass = mode === 'online' ? 'badge--online' : mode === 'hybrid' ? 'badge--online' : 'badge--location';
-
-// Format source handle for display
-const sourceLabel = source ? `@${source.replace(/\.bsky\.social$/, '')}` : undefined;
----
-
-
-
-
- {formattedDate} · {formattedTime} UTC
-
- {name}
- {description && {description}
}
-
-
-
-
-
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
deleted file mode 100644
--- a/src/components/Footer.astro
+++ /dev/null
@@ -1,78 +0,0 @@
----
-const year = new Date().getFullYear();
----
-
-
-
-
diff --git a/src/components/Header.astro b/src/components/Header.astro
deleted file mode 100644
--- a/src/components/Header.astro
+++ /dev/null
@@ -1,184 +0,0 @@
----
-const navLinks = [
- { href: '/', label: 'Home' },
- { href: '/about', label: 'About' },
- { href: '/community-content', label: 'Content' },
- { href: '/events', label: 'Events' },
- { href: '/communities', label: 'Communities' },
-];
-
-const currentPath = Astro.url.pathname;
----
-
-
-
-
diff --git a/src/components/Hero.astro b/src/components/Hero.astro
deleted file mode 100644
--- a/src/components/Hero.astro
+++ /dev/null
@@ -1,225 +0,0 @@
----
-interface Props {
- subscriberCount?: number;
- groupCount?: number;
-}
-
-const { subscriberCount = 1000, groupCount = 10 } = Astro.props;
----
-
-
-
-
-
-
The community hub for the Atmosphere
-
- Connecting people and projects building on AT Protocol.
- Blog posts, events, and local communities — all in one place.
-
-
-
-
-
-
- {subscriberCount.toLocaleString()}+
- Subscribers
-
-
- {groupCount}
- Regional Groups
-
-
-
-
-
-
diff --git a/src/components/PostCard.astro b/src/components/PostCard.astro
deleted file mode 100644
--- a/src/components/PostCard.astro
+++ /dev/null
@@ -1,140 +0,0 @@
----
-interface Props {
- title: string;
- excerpt: string;
- author: string;
- date: string;
- href: string;
- tag?: string;
- avatarUrl?: string;
- community?: string;
-}
-
-const { title, excerpt, author, date, href, tag, avatarUrl, community } = Astro.props;
-
-// Generate a deterministic color from the author handle
-function authorColor(name: string): string {
- let hash = 0;
- for (const ch of name) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
- const hue = ((hash % 360) + 360) % 360;
- return `oklch(55% 0.12 ${hue})`;
-}
-
-const initials = author
- .replace(/^@/, '')
- .split(/[.\-_ ]/)
- .slice(0, 2)
- .map(s => s[0]?.toUpperCase() ?? '')
- .join('');
----
-
-
-
- {avatarUrl ? (
-
- ) : (
-
- {initials}
-
- )}
-
-
{title}
-
{excerpt}
-
- {tag && {tag} }
- {community && {community} }
- {author}
-
- {new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
-
-
-
-
-
-
-
diff --git a/src/data/communities.yml b/src/data/communities.yml
--- a/src/data/communities.yml
+++ b/src/data/communities.yml
@@ -1,125 +1,146 @@
- name: ATProto Amsterdam
handle: atproto.amsterdam
location: Amsterdam, NL
+ region: Europe
description: Amsterdam ATProtocol user group
bluesky: https://bsky.app/profile/atproto.amsterdam
- name: ATProto Barcelona
handle: atproto.barcelona
location: Barcelona, ES
+ region: Europe
description: Barcelona ATProtocol user group
bluesky: https://bsky.app/profile/atproto.barcelona
- name: ATProto Bay Area
handle: bayarea.atproto.camp
location: Bay Area, CA
+ region: North America
description: Bay Area ATProtocol meetup group
bluesky: https://bsky.app/profile/bayarea.atproto.camp
- name: ATProto Berlin
handle: atproto.berlin
location: Berlin, DE
+ region: Europe
description: Berlin ATProtocol user group
bluesky: https://bsky.app/profile/atproto.berlin
- name: ATProto Boston
handle: atproto.boston
location: Boston, MA
+ region: North America
description: Boston ATProtocol user group
bluesky: https://bsky.app/profile/atproto.boston
- name: ATProto Colorado
handle: colorado.atprotocol.space
location: Colorado, US
+ region: North America
description: Colorado ATProtocol user group
bluesky: https://bsky.app/profile/colorado.atprotocol.space
- name: ATProto Brazil
handle: atproto.com.br
location: Brazil
+ region: South America
description: Brazil ATProtocol user group
bluesky: https://bsky.app/profile/atproto.com.br
- name: ATProtocol Devs
handle: atprotocol.dev
location: Online
+ region: Online
description: Online ATProtocol developer community
bluesky: https://bsky.app/profile/atprotocol.dev
- name: ATProto France
handle: atproto.fr
location: France
+ region: Europe
description: France ATProtocol user group
bluesky: https://bsky.app/profile/atproto.fr
- name: ATProto LA
handle: atproto.la
location: Los Angeles, CA
+ region: North America
description: Los Angeles ATProtocol user group
bluesky: https://bsky.app/profile/atproto.la
- name: ATProto Lille
handle: lille.atproto.fr
location: Lille, FR
+ region: Europe
description: Lille ATProtocol user group
bluesky: https://bsky.app/profile/lille.atproto.fr
- name: ATProto London
handle: atproto.london
location: London, UK
+ region: Europe
description: London ATProtocol user group
bluesky: https://bsky.app/profile/atproto.london
- name: ATProto Montreal
handle: montreal.atproto.camp
location: Montreal, QC
+ region: North America
description: Montreal ATProtocol user group
bluesky: https://bsky.app/profile/montreal.atproto.camp
- name: Nashproto
handle: nashproto.com
location: Nashville, TN
+ region: North America
description: Nashville ATProtocol user group
bluesky: https://bsky.app/profile/nashproto.com
- name: ATProto Norway
handle: atproto.no
location: Norway
+ region: Europe
description: Norway ATProtocol user group
bluesky: https://bsky.app/profile/atproto.no
- name: ATProto NYC
handle: atproto.nyc
location: New York City, NY
+ region: North America
description: New York City ATProtocol user group
bluesky: https://bsky.app/profile/atproto.nyc
- name: ATProto PDX
handle: pdx.atproto.camp
location: Portland, OR
+ region: North America
description: Portland ATProtocol user group
bluesky: https://bsky.app/profile/pdx.atproto.camp
- name: Richmond AT://Community
- handle: richmond.atprotocol.community
+ handle: richmond.atmosphere.community
location: Richmond, VA, US
+ region: North America
description: An ATProto Community for Richmond, VA
- bluesky: https://bsky.app/profile/richmond.atprotocol.community
+ bluesky: https://bsky.app/profile/richmond.atmosphere.community
- name: ATProto Seattle
handle: seattle.atprotocol.community
location: Seattle, WA
+ region: North America
description: Seattle ATProtocol user group
bluesky: https://bsky.app/profile/seattle.atprotocol.community
- name: ATProto Toronto
handle: atproto.to
location: Toronto, ON
+ region: North America
description: Toronto ATProtocol user group
bluesky: https://bsky.app/profile/atproto.to
- name: ATProto Vancouver
handle: vancouver.atprotocol.community
location: Vancouver, BC
+ region: North America
description: Vancouver ATProtocol user group
bluesky: https://bsky.app/profile/vancouver.atprotocol.community
diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts
deleted file mode 100644
--- a/src/lib/atproto.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * ATProto helpers for resolving profiles at build time.
- * Uses public Bluesky API — no authentication needed.
- */
-
-const PUBLIC_API = 'https://public.api.bsky.app';
-
-export interface AtProfile {
- did: string;
- handle: string;
- displayName?: string;
- avatar?: string;
-}
-
-const profileCache = new Map();
-
-/**
- * Resolve an ATProto handle or DID to a profile (name + avatar).
- * Results are cached for the duration of the build.
- */
-export async function getProfile(handleOrDid: string): Promise {
- const cached = profileCache.get(handleOrDid);
- if (cached) return cached;
-
- try {
- const url = `${PUBLIC_API}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`;
- const res = await fetch(url);
- if (!res.ok) {
- throw new Error(`Profile fetch failed: ${res.status}`);
- }
- const data = await res.json();
- const profile: AtProfile = {
- did: data.did,
- handle: data.handle,
- displayName: data.displayName || undefined,
- avatar: data.avatar || undefined,
- };
- profileCache.set(handleOrDid, profile);
- profileCache.set(profile.did, profile);
- profileCache.set(profile.handle, profile);
- return profile;
- } catch {
- // Return a minimal fallback profile
- const fallback: AtProfile = {
- did: handleOrDid,
- handle: handleOrDid,
- };
- profileCache.set(handleOrDid, fallback);
- return fallback;
- }
-}
-
-/**
- * Extract an excerpt from text content.
- */
-export function excerpt(text: string | undefined, maxLen = 150): string {
- if (!text) return '';
- const cleaned = text
- .replace(/^#{1,6}\s+/gm, '') // heading markers
- .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
- .replace(/\*([^*]+)\*/g, '$1') // italic
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
- .replace(/[`~]/g, '') // code/strikethrough markers
- .replace(/^[-*>]\s+/gm, '') // list items, blockquotes
- .replace(/!\[[^\]]*\]\([^)]+\)/g, '') // images
- .replace(/\s+/g, ' ')
- .trim();
- if (cleaned.length <= maxLen) return cleaned;
- return cleaned.slice(0, maxLen).replace(/\s+\S*$/, '') + '…';
-}
diff --git a/src/lib/blog.ts b/src/lib/blog.ts
deleted file mode 100644
--- a/src/lib/blog.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * Fetch standard.site documents directly from ATProto via XRPC.
- * This bypasses the astro-standard-site loader which has strict schema
- * validation that rejects documents with timezone offsets like +00:00.
- */
-
-export interface BlogPost {
- id: string;
- uri: string;
- title: string;
- site: string;
- publishedAt: Date;
- path?: string;
- url?: string;
- description?: string;
- textContent?: string;
- tags: string[];
-}
-
-const COLLECTION = 'site.standard.document';
-
-interface RawDocument {
- $type: string;
- title?: string;
- site?: string;
- publishedAt?: string;
- updatedAt?: string;
- path?: string;
- description?: string;
- textContent?: string;
- tags?: string[];
- content?: unknown;
- [key: string]: unknown;
-}
-
-/**
- * Fetch blog posts from an ATProto account's PDS.
- */
-export async function fetchBlogPosts(config: {
- handle: string;
- pds: string;
- publication?: string;
- limit?: number;
-}): Promise {
- const { handle, pds, publication, limit = 100 } = config;
-
- // Resolve handle to DID
- let did = handle;
- if (!did.startsWith('did:')) {
- const res = await fetch(
- `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
- );
- if (!res.ok) throw new Error(`Failed to resolve handle ${handle}: ${res.status}`);
- const data = await res.json();
- did = data.did;
- }
-
- // Fetch all documents
- const posts: BlogPost[] = [];
- let cursor: string | undefined;
-
- do {
- const params = new URLSearchParams({
- repo: did,
- collection: COLLECTION,
- limit: String(Math.min(limit, 100)),
- });
- if (cursor) params.set('cursor', cursor);
-
- const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`);
- if (!res.ok) throw new Error(`listRecords failed: ${res.status}`);
-
- const data = await res.json();
-
- for (const record of data.records as Array<{ uri: string; cid: string; value: RawDocument }>) {
- const v = record.value;
-
- // Skip if no title
- if (!v.title) continue;
-
- // Filter to specific publication if requested
- if (publication && v.site !== publication) continue;
-
- // Parse the date tolerantly (handles +00:00, Z, and other formats)
- let publishedAt: Date;
- try {
- publishedAt = new Date(v.publishedAt ?? '');
- if (isNaN(publishedAt.getTime())) continue;
- } catch {
- continue;
- }
-
- const rkey = record.uri.split('/').pop() ?? '';
-
- posts.push({
- id: rkey,
- uri: record.uri,
- title: v.title,
- site: v.site ?? '',
- publishedAt,
- path: v.path,
- url: v.path ? `https://blog.atmosphere.community${v.path}` : undefined,
- description: v.description,
- textContent: v.textContent,
- tags: v.tags ?? [],
- });
- }
-
- cursor = data.cursor;
- if (posts.length >= limit) break;
- } while (cursor);
-
- return posts;
-}
diff --git a/src/lib/events.ts b/src/lib/events.ts
deleted file mode 100644
--- a/src/lib/events.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-/**
- * Fetch community.lexicon.calendar.event records from ATProto at build time.
- * Designed to accept multiple account DIDs for future multi-account support.
- */
-
-const COLLECTION = 'community.lexicon.calendar.event';
-
-/** Parsed event ready for rendering */
-export interface CommunityEvent {
- name: string;
- date: string; // ISO string for startsAt
- endDate?: string; // ISO string for endsAt
- location?: string; // Human-readable location
- mode: 'online' | 'in_person' | 'hybrid';
- description?: string; // Plain text (facets stripped)
- href: string; // Link to RSVP or event page
- source: string; // Handle/DID of the account
-}
-
-interface RawLocation {
- $type: string;
- name?: string;
- locality?: string;
- region?: string;
- country?: string;
- [key: string]: unknown;
-}
-
-interface RawUri {
- uri: string;
- name?: string;
- $type: string;
- source?: string;
-}
-
-export interface RawEvent {
- $type: string;
- name: string;
- startsAt: string;
- endsAt?: string;
- description?: string;
- mode?: string;
- status?: string;
- locations?: RawLocation[];
- uris?: RawUri[];
- createdAt?: string;
- [key: string]: unknown;
-}
-
-/**
- * Resolve a handle to a DID using the public API.
- */
-async function resolveHandle(handle: string): Promise {
- if (handle.startsWith('did:')) return handle;
- const res = await fetch(
- `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
- );
- if (!res.ok) throw new Error(`Failed to resolve handle ${handle}: ${res.status}`);
- const data = await res.json();
- return data.did;
-}
-
-/**
- * Look up the PDS endpoint for a DID.
- * Handles both did:plc (via PLC directory) and did:web (via .well-known).
- */
-async function resolvePds(did: string): Promise {
- if (did.startsWith('did:web:')) {
- // did:web resolution: fetch the DID document from the domain
- const domain = did.slice('did:web:'.length).replace(/%3A/g, ':');
- const didDocUrl = `https://${domain}/.well-known/did.json`;
- const res = await fetch(didDocUrl);
- if (!res.ok) throw new Error(`Failed to resolve did:web ${did}: ${res.status}`);
- const doc = await res.json();
- const service = doc.service?.find(
- (s: { id: string; type: string; serviceEndpoint: string }) =>
- s.id === '#atproto_pds' || s.id === `${did}#atproto_pds`
- );
- if (!service) throw new Error(`No PDS found for ${did}`);
- return service.serviceEndpoint;
- }
-
- // did:plc resolution via PLC directory
- const res = await fetch(`https://plc.directory/${did}`);
- if (!res.ok) throw new Error(`Failed to resolve PDS for ${did}: ${res.status}`);
- const doc = await res.json();
- const service = doc.service?.find(
- (s: { id: string; type: string; serviceEndpoint: string }) =>
- s.id === '#atproto_pds'
- );
- if (!service) throw new Error(`No PDS found for ${did}`);
- return service.serviceEndpoint;
-}
-
-/**
- * Fetch all event records from a single account.
- */
-async function fetchEventsFromAccount(
- handleOrDid: string
-): Promise {
- const did = await resolveHandle(handleOrDid);
- const pds = await resolvePds(did);
-
- const events: CommunityEvent[] = [];
- let cursor: string | undefined;
-
- do {
- const params = new URLSearchParams({
- repo: did,
- collection: COLLECTION,
- limit: '100',
- });
- if (cursor) params.set('cursor', cursor);
-
- const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`);
- if (!res.ok) throw new Error(`listRecords failed: ${res.status}`);
-
- const data = await res.json();
- const records = data.records as Array<{ uri: string; value: RawEvent }>;
-
- for (const record of records) {
- const v = record.value;
- events.push(parseEvent(v, handleOrDid, record.uri));
- }
-
- cursor = data.cursor;
- } while (cursor);
-
- return events;
-}
-
-/**
- * Simplify a full street address to city, state/region, country.
- * e.g. "1551 Southeast Poplar Avenue, Portland, Multnomah County, Oregon, 97214, United States"
- * → "Portland, Oregon, US"
- */
-function simplifyAddress(raw: string): string {
- const parts = raw.split(',').map(p => p.trim());
- if (parts.length <= 3) return raw; // already short enough
-
- // Filter out: street numbers/addresses, county names, postal codes
- const filtered = parts.filter(p => {
- if (/^\d/.test(p)) return false; // starts with number (street addr or zip)
- if (/county$/i.test(p)) return false; // "Multnomah County"
- if (/^\d{4,}/.test(p.replace(/\s/g, ''))) return false; // postal codes
- return true;
- });
-
- // Take the last 3 meaningful parts (typically city, state, country)
- const meaningful = filtered.slice(-3);
- return meaningful.join(', ') || raw;
-}
-
-/**
- * Parse a raw event record into our clean format.
- */
-export function parseEvent(v: RawEvent, source: string, uri: string): CommunityEvent {
- // Parse mode
- let mode: CommunityEvent['mode'] = 'in_person';
- if (v.mode?.includes('#virtual')) mode = 'online';
- else if (v.mode?.includes('#hybrid')) mode = 'hybrid';
-
- // Extract location from the first address-type location
- let location: string | undefined;
- if (v.locations?.length) {
- const addr = v.locations.find(l => l.$type?.includes('address'));
- if (addr) {
- const parts = [addr.locality, addr.region, addr.country].filter(Boolean);
- location = parts.join(', ') || addr.name;
- } else {
- // For geo/other location types, the name may be a full street address.
- // Try to extract city, state, country from a comma-separated address string.
- const raw = v.locations[0]?.name;
- if (raw) {
- location = simplifyAddress(raw);
- }
- }
- }
-
- // Find the best RSVP/event link
- // Priority: "OpenMeet Event" named link > first non-image URI > fallback to Smoke Signal
- let href = '';
- if (v.uris?.length) {
- const openMeetLink = v.uris.find(u => u.name === 'OpenMeet Event');
- if (openMeetLink) {
- href = openMeetLink.uri;
- } else {
- // Skip image URIs (common in OpenMeet records)
- const nonImageUri = v.uris.find(u =>
- u.name !== 'Event Image' && !u.uri.match(/\.(jpg|jpeg|png|gif|webp)$/i)
- );
- href = nonImageUri?.uri ?? v.uris[0].uri;
- }
- }
- // Fallback to Smoke Signal event page using the AT URI
- if (!href) {
- const parts = uri.match(/at:\/\/([^/]+)\/[^/]+\/(.+)/);
- if (parts) {
- href = `https://smokesignal.events/${parts[1]}/${parts[2]}`;
- }
- }
-
- // Strip facets from description — just use plain text
- const description = v.description?.replace(/\n+/g, ' ').trim();
-
- return {
- name: v.name,
- date: v.startsAt,
- endDate: v.endsAt,
- location,
- mode,
- description,
- href,
- source,
- };
-}
-
-/**
- * Fetch events from one or more ATProto accounts.
- * Returns upcoming events sorted by date ascending.
- */
-export async function fetchEvents(
- accounts: string[]
-): Promise {
- // Fetch all accounts in parallel for faster builds
- const results = await Promise.allSettled(
- accounts.map(account => fetchEventsFromAccount(account))
- );
-
- const allEvents: CommunityEvent[] = [];
- for (let i = 0; i < results.length; i++) {
- const result = results[i];
- if (result.status === 'fulfilled') {
- allEvents.push(...result.value);
- } else {
- console.warn(`Failed to fetch events from ${accounts[i]}:`, result.reason);
- }
- }
-
- // Filter to upcoming events, deduplicate, and sort by date
- const now = new Date();
- const upcoming = allEvents
- .filter(e => new Date(e.date) >= now)
- .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
-
- // Deduplicate by name + start time (same event posted by multiple accounts)
- const seen = new Set();
- return upcoming.filter(e => {
- const key = `${e.name.toLowerCase().trim()}|${e.date}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
diff --git a/src/lib/live-handlers.ts b/src/lib/live-handlers.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/live-handlers.ts
@@ -0,0 +1,311 @@
+import type {
+ AtProtoRecordCallbackArgs,
+ FetchRecord,
+} from '@fujocoded/astro-atproto-loader';
+import { z } from 'astro/zod';
+import { remark } from 'remark';
+import { toString as mdastToString } from 'mdast-util-to-string';
+import { AtUri } from '@atproto/api';
+import { DidResolver, MemoryCache, getPds } from '@atproto/identity';
+import type { CommunityEvent } from './community/types.js';
+
+const cidDidCache = new MemoryCache();
+const cidDidResolver = new DidResolver({ didCache: cidDidCache });
+
+// The grouped event transformer needs a CID for reshared events so users can RSVP.
+// The loader's fetchRecord doesn't surface CIDs from external repos, so we resolve
+// the canonical record's PDS ourselves and read it directly. Best-effort: any failure
+// just means RSVP stays disabled for that card.
+async function fetchCanonicalCid(atUri: string): Promise {
+ try {
+ const parsed = new AtUri(atUri);
+ const doc = await cidDidResolver.resolve(parsed.host);
+ const pds = doc ? getPds(doc) : undefined;
+ if (!pds) return undefined;
+ const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.host)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`;
+ const res = await fetch(url);
+ if (!res.ok) return undefined;
+ const body = (await res.json()) as { cid?: string };
+ return body.cid;
+ } catch {
+ return undefined;
+ }
+}
+import {
+ getProfile,
+ hydrateBlogPost,
+ hydrateSharedDocument,
+ hydrateSharedEvent,
+ parseBlogPostRef,
+ parseEventRecord,
+ parseSharedDocumentRef,
+ parseSharedEventRef,
+} from './community/index.js';
+
+const OFFPRINT_PUB =
+ 'at://did:plc:lehcqqkwzcwvjvw66uthu5oq/site.standard.publication/3mjnpilwnrp2v';
+const ATMOSPHERE_BLOG_URL = 'https://blog.atmosphere.community';
+
+const markdownParser = remark();
+const EXCERPT_MAX_LENGTH = 150;
+const EXCERPT_CACHE_LIMIT = 500;
+const excerptCache = new Map();
+
+// Pulls the first paragraph (the typical lede) out of a markdown body and
+// trims it to a card-friendly snippet. Falls back to flattening the whole tree
+// if the doc has no paragraph node (e.g. starts with a heading or list).
+// Cached because the same posts re-render across requests in server output.
+function excerpt(text: string): string {
+ const cached = excerptCache.get(text);
+ if (cached !== undefined) return cached;
+
+ const tree = markdownParser.parse(text);
+ const firstParagraph = tree.children.find((node) => node.type === 'paragraph');
+ const plain = mdastToString(firstParagraph ?? tree).replace(/\s+/g, ' ').trim();
+
+ let result: string;
+ if (plain.length <= EXCERPT_MAX_LENGTH) {
+ result = plain;
+ } else {
+ const truncated = plain.slice(0, EXCERPT_MAX_LENGTH);
+ const lastSpace = truncated.lastIndexOf(' ');
+ const cutPoint =
+ lastSpace > EXCERPT_MAX_LENGTH * 0.5 ? lastSpace : EXCERPT_MAX_LENGTH;
+ result = truncated.slice(0, cutPoint) + '…';
+ }
+
+ if (excerptCache.size >= EXCERPT_CACHE_LIMIT) {
+ excerptCache.delete(excerptCache.keys().next().value!);
+ }
+ excerptCache.set(text, result);
+ return result;
+}
+
+// Avatar is a discriminated union: a real CDN URL when the profile has one,
+// otherwise a deterministic initials+color pair the consumer renders as a tile.
+// Components branch once on shape rather than juggling avatar / fallback fields.
+const authorSchema = z.object({
+ did: z.string(),
+ handle: z.string(),
+ displayName: z.string().optional(),
+ avatar: z.union([
+ z.object({ url: z.string() }),
+ z.object({ initials: z.string(), color: z.string() }),
+ ]),
+});
+
+export const feedOutputSchema = z.object({
+ title: z.string(),
+ url: z.string(),
+ excerpt: z.string().optional(),
+ publishedAt: z.coerce.date().optional(),
+ sharedAt: z.coerce.date(),
+ author: authorSchema,
+ source: z.string(),
+ documentUri: z.string(),
+ tags: z.array(z.string()).optional(),
+});
+
+export const eventsOutputSchema = z.object({
+ name: z.string(),
+ startsAt: z.coerce.date(),
+ endsAt: z.coerce.date().optional(),
+ description: z.string().optional(),
+ mode: z.enum(['inperson', 'virtual', 'hybrid']),
+ location: z.string().optional(),
+ uri: z.string(),
+ atUri: z.string(),
+ source: z.string(),
+ rkey: z.string().optional(),
+ did: z.string().optional(),
+ cid: z.string().optional(),
+ // Provenance: which community account hosts the canonical calendar event,
+ // and which others reshared it. `original` is undefined when the host isn't
+ // one of our tracked accounts (event surfaces only via reshares).
+ original: z.string().optional(),
+ sharedBy: z.array(z.string()).default([]),
+ isPast: z.boolean(),
+ // Avatar of the hosting community (event.source). Resolved via the warmed
+ // source-profile cache in live.config.ts so this is effectively free per-record.
+ sourceAvatar: z.string().optional(),
+});
+
+type FeedEntry = z.input;
+type EventEntry = z.input;
+
+// Pre-enrichment shape returned by per-collection feed transformers. They emit
+// the raw AtProto profile (avatar as plain URL string); the wrapper in
+// live.config.ts collapses that into the schema's discriminated union.
+export type RawFeedEntry = Omit & {
+ author: {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar?: string;
+ };
+};
+
+export type LoaderArgs = AtProtoRecordCallbackArgs;
+
+type FilterFn = (ctx: LoaderArgs) => boolean;
+type TransformFn = (
+ ctx: LoaderArgs,
+) => Promise<{ id: string; data: Entry } | null>;
+
+const sourceLabel = (ctx: LoaderArgs): string =>
+ ctx.repo.handle ?? ctx.repo.did;
+
+export const feedFilters: Record = {
+ 'site.standard.document': (ctx) => {
+ const ref = parseBlogPostRef(ctx.value as Record, {
+ did: ctx.repo.did,
+ rkey: ctx.rkey,
+ });
+ return !!ref && (ctx.value as { site?: unknown }).site === OFFPRINT_PUB;
+ },
+ 'community.opensocial.sharedContent': (ctx) => {
+ const ref = parseSharedDocumentRef(ctx.value as Record, {
+ source: sourceLabel(ctx),
+ });
+ return !!ref && ref.documentUri.startsWith('at://');
+ },
+};
+
+export const feedTransformers: Record> = {
+ 'site.standard.document': async (ctx) => {
+ const ref = parseBlogPostRef(ctx.value as Record, {
+ did: ctx.repo.did,
+ rkey: ctx.rkey,
+ });
+ if (!ref) return null;
+ const author = await getProfile('atmosphere.community');
+ const post = hydrateBlogPost(ref, {
+ author,
+ baseUrl: ATMOSPHERE_BLOG_URL,
+ });
+ const { textContent, ...rest } = post;
+ return {
+ id: ctx.uri,
+ data: {
+ ...rest,
+ excerpt: textContent ? excerpt(textContent) : undefined,
+ sharedAt: post.publishedAt,
+ source: 'atmosphere.community',
+ documentUri: ctx.uri,
+ },
+ };
+ },
+ 'community.opensocial.sharedContent': async (ctx) => {
+ const ref = parseSharedDocumentRef(ctx.value as Record, {
+ source: sourceLabel(ctx),
+ });
+ if (!ref) return null;
+ const post = await hydrateSharedDocument(ref, {
+ fetchRecord: async (atUri: string) => {
+ const result = await ctx.fetchRecord({ atUri });
+ return result?.value ?? null;
+ },
+ getProfile,
+ baseUrl: ATMOSPHERE_BLOG_URL,
+ });
+ if (!post) return null;
+ const { textContent, ...rest } = post;
+ return {
+ id: ref.documentUri,
+ data: {
+ ...rest,
+ excerpt: textContent ? excerpt(textContent) : undefined,
+ },
+ };
+ },
+};
+
+export const eventFilters: Record = {
+ 'community.lexicon.calendar.event': (ctx) =>
+ !!parseEventRecord(ctx.value as Record, {
+ did: ctx.repo.did,
+ rkey: ctx.rkey,
+ source: sourceLabel(ctx),
+ }),
+ 'community.opensocial.sharedContent': (ctx) => {
+ const ref = parseSharedEventRef(ctx.value as Record, {
+ source: sourceLabel(ctx),
+ });
+ return !!ref && ref.documentUri.startsWith('at://');
+ },
+};
+
+// Group key for event records: collapse a native calendar event and any reshares
+// of it onto the same canonical at-URI. Reshares carry the host's URI in their
+// `subject` (parsed as `documentUri`); a reshare that fails to parse falls back
+// to its own URI so it still produces a usable single-record group.
+export function eventGroupKey(ctx: LoaderArgs): string {
+ if (ctx.collection === 'community.lexicon.calendar.event') return ctx.uri;
+ if (ctx.collection === 'community.opensocial.sharedContent') {
+ const ref = parseSharedEventRef(ctx.value as Record, {
+ source: '',
+ });
+ return ref?.documentUri ?? ctx.uri;
+ }
+ return ctx.uri;
+}
+
+// Pick canonical record for a grouped event and annotate provenance.
+// Native record (if any) wins as canonical — sources are declared natives-first
+// so `records[0]` is the native when one exists. Reshares contribute `sharedBy`.
+export async function transformEventGroup(args: {
+ key: string;
+ records: LoaderArgs[];
+ fetchRecord: FetchRecord;
+}): Promise<{ id: string; data: EventEntry } | null> {
+ const native = args.records.find(
+ (r) => r.collection === 'community.lexicon.calendar.event',
+ );
+ const reshares = args.records.filter(
+ (r) => r.collection === 'community.opensocial.sharedContent',
+ );
+
+ let event: CommunityEvent | null = null;
+ let cid: string | undefined;
+
+ if (native) {
+ event = parseEventRecord(native.value as Record, {
+ did: native.repo.did,
+ rkey: native.rkey,
+ source: sourceLabel(native),
+ });
+ cid = native.cid;
+ } else {
+ for (const reshare of reshares) {
+ const ref = parseSharedEventRef(reshare.value as Record, {
+ source: sourceLabel(reshare),
+ });
+ if (!ref) continue;
+ event = await hydrateSharedEvent(ref, {
+ fetchRecord: async (atUri: string) => {
+ const result = await args.fetchRecord({ atUri });
+ return result?.value ?? null;
+ },
+ });
+ if (event) {
+ cid = await fetchCanonicalCid(event.atUri);
+ break;
+ }
+ }
+ }
+
+ if (!event) return null;
+
+ const original = native ? sourceLabel(native) : undefined;
+ const sharedBy = [...new Set(reshares.map(sourceLabel))];
+ const endTime = (event.endsAt ?? event.startsAt).getTime();
+ const isPast = endTime < Date.now();
+ const sourceAvatar = event.source
+ ? (await getProfile(event.source).catch(() => null))?.avatar
+ : undefined;
+
+ return {
+ id: args.key,
+ data: { ...event, cid, original, sharedBy, isPast, sourceAvatar },
+ };
+}
diff --git a/src/lib/preflight.ts b/src/lib/preflight.ts
deleted file mode 100644
--- a/src/lib/preflight.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Preflight checks for build-time data fetching.
- * Verifies core ATProto services are reachable before building.
- * If services are down, the build should fail rather than deploy
- * a site with missing data.
- */
-
-const CHECKS = [
- {
- name: 'Bluesky Public API',
- url: 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=atmosphere.community',
- },
- {
- name: 'atmosphere.community PDS',
- url: 'https://hydnum.us-west.host.bsky.network/xrpc/com.atproto.repo.describeRepo?repo=did:plc:lehcqqkwzcwvjvw66uthu5oq',
- },
-];
-
-/**
- * Run preflight checks. Throws if any core service is unreachable.
- * Call this early in page rendering to fail the build fast.
- */
-export async function preflight(): Promise {
- const failures: string[] = [];
-
- for (const check of CHECKS) {
- try {
- const res = await fetch(check.url, { signal: AbortSignal.timeout(10_000) });
- if (!res.ok) {
- failures.push(`${check.name}: HTTP ${res.status}`);
- }
- } catch (err) {
- failures.push(`${check.name}: ${err instanceof Error ? err.message : 'unreachable'}`);
- }
- }
-
- if (failures.length > 0) {
- const msg = [
- 'Preflight check failed — core ATProto services are unreachable.',
- 'Build aborted to avoid deploying a site with missing data.',
- 'The next scheduled build will retry automatically.',
- '',
- ...failures.map(f => ` ✗ ${f}`),
- ].join('\n');
- throw new Error(msg);
- }
-}
diff --git a/src/lib/rsvps.ts b/src/lib/rsvps.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/rsvps.ts
@@ -0,0 +1,126 @@
+import { TID } from '@atproto/common-web';
+import { AtUri } from '@atproto/api';
+import { getLoggedInAgent } from '@fujocoded/authproto/helpers';
+
+export const RSVP_COLLECTION = 'community.lexicon.calendar.rsvp';
+export const RSVP_STATUS_GOING = 'community.lexicon.calendar.rsvp#going';
+export const RSVP_STATUS_INTERESTED = 'community.lexicon.calendar.rsvp#interested';
+export const RSVP_STATUS_NOT_GOING = 'community.lexicon.calendar.rsvp#notgoing';
+
+export type RsvpStatus =
+ | typeof RSVP_STATUS_GOING
+ | typeof RSVP_STATUS_INTERESTED
+ | typeof RSVP_STATUS_NOT_GOING;
+
+export interface CalendarRsvpRecord {
+ $type: 'community.lexicon.calendar.rsvp';
+ subject: { uri: string; cid: string };
+ status: RsvpStatus;
+ createdAt: string;
+}
+
+export interface CalendarRsvp {
+ uri: string;
+ cid: string;
+ rkey: string;
+ record: CalendarRsvpRecord;
+}
+
+export type LoggedInUser = NonNullable;
+
+interface EventLike {
+ uri: string;
+}
+
+function preferLatest(
+ current: CalendarRsvp | undefined,
+ candidate: CalendarRsvp,
+): CalendarRsvp {
+ if (!current) return candidate;
+ const a = Date.parse(current.record.createdAt ?? '');
+ const b = Date.parse(candidate.record.createdAt ?? '');
+ if (!Number.isNaN(b) && (Number.isNaN(a) || b > a)) return candidate;
+ if (!Number.isNaN(a) && (Number.isNaN(b) || b <= a)) return current;
+ return candidate.uri > current.uri ? candidate : current;
+}
+
+async function listAllRsvps(
+ loggedInUser: LoggedInUser,
+): Promise {
+ const agent = await getLoggedInAgent(loggedInUser);
+ if (!agent) return [];
+
+ const results: CalendarRsvp[] = [];
+ let cursor: string | undefined;
+
+ do {
+ const response = await agent.com.atproto.repo.listRecords({
+ repo: loggedInUser.did,
+ collection: RSVP_COLLECTION,
+ limit: 100,
+ cursor,
+ });
+
+ for (const record of response.data.records) {
+ const value = record.value as Partial;
+ if (!value || !value.subject?.uri || !value.status) continue;
+ results.push({
+ uri: record.uri,
+ cid: record.cid,
+ rkey: new AtUri(record.uri).rkey,
+ record: value as CalendarRsvpRecord,
+ });
+ }
+
+ cursor = response.data.cursor;
+ } while (cursor);
+
+ return results;
+}
+
+export async function getRsvpsForEvents(
+ loggedInUser: LoggedInUser,
+ events: EventLike[],
+): Promise> {
+ const subjectUris = new Set(events.map((event) => event.uri));
+ const all = await listAllRsvps(loggedInUser);
+
+ const bySubject = new Map();
+ for (const entry of all) {
+ const subjectUri = entry.record.subject.uri;
+ if (!subjectUris.has(subjectUri)) continue;
+ bySubject.set(subjectUri, preferLatest(bySubject.get(subjectUri), entry));
+ }
+ return bySubject;
+}
+
+export async function setRsvpStatus(
+ loggedInUser: LoggedInUser,
+ subject: { uri: string; cid: string },
+ status: RsvpStatus,
+): Promise {
+ const agent = await getLoggedInAgent(loggedInUser);
+ if (!agent) {
+ throw new Error('Not logged in');
+ }
+
+ const all = await listAllRsvps(loggedInUser);
+ const existing = all.find(
+ (entry) => entry.record.subject.uri === subject.uri,
+ );
+
+ const record: CalendarRsvpRecord = {
+ $type: 'community.lexicon.calendar.rsvp',
+ subject,
+ status,
+ createdAt: new Date().toISOString(),
+ };
+
+ await agent.com.atproto.repo.putRecord({
+ repo: loggedInUser.did,
+ collection: RSVP_COLLECTION,
+ rkey: existing?.rkey ?? TID.nextStr(),
+ record: record as unknown as Record,
+ validate: false,
+ });
+}
diff --git a/src/lib/shared-content.ts b/src/lib/shared-content.ts
deleted file mode 100644
--- a/src/lib/shared-content.ts
+++ /dev/null
@@ -1,360 +0,0 @@
-/**
- * Fetch community.opensocial.sharedContent records from ATProto accounts.
- * For each shared document, fetches the full standard.site document and
- * resolves the author's Bluesky profile.
- */
-
-import { getProfile, type AtProfile } from './atproto';
-import { parseEvent, type CommunityEvent, type RawEvent } from './events';
-
-const COLLECTION = 'community.opensocial.sharedContent';
-
-export interface SharedPost {
- title: string;
- excerpt: string;
- author: AtProfile;
- date: string;
- href: string;
- sharedBy: string;
- community: string; // Handle of the account that shared this content
- tags: string[];
-}
-
-interface RawSharedContent {
- $type: string;
- title: string;
- path?: string;
- type: string;
- sharedAt: string;
- sharedBy: string;
- documentUri: string;
- documentCid: string;
- // Event-specific fields (present when type='event')
- startsAt?: string;
- endsAt?: string;
- location?: string;
- mode?: string;
-}
-
-interface RawDocument {
- title?: string;
- description?: string;
- textContent?: string;
- publishedAt?: string;
- path?: string;
- site?: string;
- tags?: string[];
-}
-
-/**
- * Resolve a handle to DID using the public API.
- */
-async function resolveHandle(handle: string): Promise {
- if (handle.startsWith('did:')) return handle;
- const res = await fetch(
- `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
- );
- if (!res.ok) throw new Error(`Failed to resolve handle ${handle}: ${res.status}`);
- const data = await res.json();
- return data.did;
-}
-
-/**
- * Look up the PDS endpoint for a DID.
- */
-async function resolvePds(did: string): Promise {
- if (did.startsWith('did:web:')) {
- const domain = did.slice('did:web:'.length);
- const res = await fetch(`https://${domain}/.well-known/did.json`);
- if (!res.ok) throw new Error(`Failed to resolve did:web ${did}`);
- const doc = await res.json();
- const svc = doc.service?.find(
- (s: { id: string; serviceEndpoint: string }) =>
- s.id === '#atproto_pds' || s.id === `${did}#atproto_pds`
- );
- if (!svc) throw new Error(`No PDS for ${did}`);
- return svc.serviceEndpoint;
- }
- const res = await fetch(`https://plc.directory/${did}`);
- if (!res.ok) throw new Error(`Failed to resolve PDS for ${did}`);
- const doc = await res.json();
- const svc = doc.service?.find(
- (s: { id: string; serviceEndpoint: string }) => s.id === '#atproto_pds'
- );
- if (!svc) throw new Error(`No PDS for ${did}`);
- return svc.serviceEndpoint;
-}
-
-/**
- * Fetch a single ATProto record by AT-URI.
- */
-async function fetchRecord(atUri: string): Promise {
- const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
- if (!match) return null;
- const [, repo, collection, rkey] = match;
-
- try {
- const pds = await resolvePds(repo);
- const params = new URLSearchParams({ repo, collection, rkey });
- const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`);
- if (!res.ok) return null;
- const data = await res.json();
- return data.value as RawDocument;
- } catch {
- return null;
- }
-}
-
-/**
- * Extract an excerpt from text content.
- */
-function makeExcerpt(text: string | undefined, maxLen = 150): string {
- if (!text) return '';
- const cleaned = text
- .replace(/^#{1,6}\s+/gm, '') // heading markers
- .replace(/\*\*([^*]+)\*\*/g, '$1') // bold
- .replace(/\*([^*]+)\*/g, '$1') // italic
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links
- .replace(/[`~]/g, '') // code/strikethrough markers
- .replace(/^[-*>]\s+/gm, '') // list items, blockquotes
- .replace(/!\[[^\]]*\]\([^)]+\)/g, '') // images
- .replace(/\s+/g, ' ')
- .trim();
- if (cleaned.length <= maxLen) return cleaned;
- return cleaned.slice(0, maxLen).replace(/\s+\S*$/, '') + '…';
-}
-
-/**
- * Fetch shared content records from a single account.
- */
-async function fetchSharedFromAccount(handleOrDid: string): Promise {
- const did = await resolveHandle(handleOrDid);
- const pds = await resolvePds(did);
-
- const records: RawSharedContent[] = [];
- let cursor: string | undefined;
-
- do {
- const params = new URLSearchParams({
- repo: did,
- collection: COLLECTION,
- limit: '100',
- });
- if (cursor) params.set('cursor', cursor);
-
- const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`);
- if (!res.ok) break;
-
- const data = await res.json();
- for (const r of data.records as Array<{ uri: string; value: RawSharedContent }>) {
- if (r.value.documentUri) {
- records.push(r.value);
- }
- }
- cursor = data.cursor;
- } while (cursor);
-
- return records;
-}
-
-/**
- * Build a post URL from a base URL and a document path,
- * avoiding path duplication when the base URL already contains
- * a subpath that overlaps with the start of the document path.
- * e.g. baseUrl="https://rpg.actor/news" + path="/news/slug" → "https://rpg.actor/news/slug"
- */
-function buildPostUrl(baseUrl: string, docPath: string): string {
- const url = new URL(baseUrl);
- const basePath = url.pathname.replace(/\/$/, '');
-
- // If the doc path starts with the base URL's path, don't double it
- if (basePath && basePath !== '/' && docPath.startsWith(basePath)) {
- url.pathname = docPath;
- } else {
- url.pathname = `${basePath}${docPath}`;
- }
- return url.toString().replace(/\/$/, '');
-}
-
-/**
- * Fetch shared content from multiple accounts, resolve documents and authors.
- * Returns posts sorted by sharedAt descending.
- */
-export async function fetchSharedContent(
- accounts: string[]
-): Promise {
- // Fetch shared content records from all accounts in parallel
- const results = await Promise.allSettled(
- accounts.map(a => fetchSharedFromAccount(a))
- );
-
- const allRecords: Array = [];
- for (let i = 0; i < results.length; i++) {
- if (results[i].status === 'fulfilled') {
- const recs = (results[i] as PromiseFulfilledResult).value;
- allRecords.push(...recs.map(r => ({ ...r, _community: accounts[i] })));
- } else {
- console.warn(`Failed to fetch shared content from ${accounts[i]}:`,
- (results[i] as PromiseRejectedResult).reason);
- }
- }
-
- // Deduplicate by documentUri, filter to documents only
- const seen = new Set();
- const unique = allRecords.filter(r => {
- if (r.type !== 'document') return false;
- if (seen.has(r.documentUri)) return false;
- seen.add(r.documentUri);
- return true;
- });
-
- // Fetch documents and author profiles in parallel
- const posts: SharedPost[] = [];
-
- // Batch into chunks to avoid overwhelming PDSes
- const BATCH_SIZE = 10;
- for (let i = 0; i < unique.length; i += BATCH_SIZE) {
- const batch = unique.slice(i, i + BATCH_SIZE);
- const resolved = await Promise.allSettled(
- batch.map(async (record) => {
- const [doc, authorProfile] = await Promise.all([
- fetchRecord(record.documentUri),
- getProfile(record.sharedBy),
- ]);
-
- if (!doc || !doc.title) return null;
-
- // Build the URL for the post
- let href = '';
- if (doc.path && doc.site) {
- if (doc.site.startsWith('https://') || doc.site.startsWith('http://')) {
- // site is already a URL — use it directly as the base
- href = buildPostUrl(doc.site, doc.path);
- } else {
- // site is an AT-URI — try to resolve the publication URL
- const pubDoc = await fetchRecord(doc.site);
- const baseUrl = (pubDoc as unknown as { url?: string })?.url;
- if (baseUrl) {
- href = buildPostUrl(baseUrl, doc.path);
- }
- }
- }
- if (!href && doc.path) {
- href = `https://blog.atmosphere.community${doc.path}`;
- }
- if (!href) {
- // Link to Leaflet as fallback reader
- const match = record.documentUri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/);
- if (match) {
- href = `https://leaflet.pub/profile/${match[1]}/${match[2]}`;
- }
- }
-
- return {
- title: doc.title,
- excerpt: makeExcerpt(doc.textContent ?? doc.description),
- author: authorProfile,
- date: doc.publishedAt ?? record.sharedAt,
- href,
- sharedBy: record.sharedBy,
- community: record._community,
- tags: doc.tags ?? [],
- } satisfies SharedPost;
- })
- );
-
- for (const result of resolved) {
- if (result.status === 'fulfilled' && result.value) {
- posts.push(result.value);
- }
- }
- }
-
- // Sort by date descending (newest first)
- return posts.sort((a, b) => {
- const da = new Date(a.date).getTime() || 0;
- const db = new Date(b.date).getTime() || 0;
- return db - da;
- });
-}
-
-/**
- * Fetch shared events from multiple accounts.
- * Resolves the full event record from each documentUri, then parses it
- * into CommunityEvent objects using the same logic as direct event fetching.
- */
-export async function fetchSharedEvents(
- accounts: string[]
-): Promise {
- const results = await Promise.allSettled(
- accounts.map(a => fetchSharedFromAccount(a))
- );
-
- const allRecords: Array = [];
- for (let i = 0; i < results.length; i++) {
- if (results[i].status === 'fulfilled') {
- const recs = (results[i] as PromiseFulfilledResult).value;
- allRecords.push(...recs.map(r => ({ ...r, _community: accounts[i] })));
- } else {
- console.warn(`Failed to fetch shared events from ${accounts[i]}:`,
- (results[i] as PromiseRejectedResult).reason);
- }
- }
-
- // Deduplicate by documentUri, filter to events only
- const seen = new Set();
- const unique = allRecords.filter(r => {
- if (r.type !== 'event') return false;
- if (seen.has(r.documentUri)) return false;
- seen.add(r.documentUri);
- return true;
- });
-
- const events: CommunityEvent[] = [];
- const BATCH_SIZE = 10;
-
- for (let i = 0; i < unique.length; i += BATCH_SIZE) {
- const batch = unique.slice(i, i + BATCH_SIZE);
- const resolved = await Promise.allSettled(
- batch.map(async (record) => {
- // Fetch the full event record to get uris, description, etc.
- const fullEvent = await fetchRecord(record.documentUri) as RawEvent | null;
-
- if (fullEvent) {
- return parseEvent(fullEvent, record._community, record.documentUri);
- }
-
- // Fallback: build a CommunityEvent from the shared content metadata
- let mode: CommunityEvent['mode'] = 'in_person';
- if (record.mode === 'virtual') mode = 'online';
- else if (record.mode === 'hybrid') mode = 'hybrid';
-
- // Build a fallback href from the documentUri
- let href = '';
- const match = record.documentUri.match(/at:\/\/([^/]+)\/[^/]+\/(.+)/);
- if (match) {
- href = `https://smokesignal.events/${match[1]}/${match[2]}`;
- }
-
- return {
- name: record.title,
- date: record.startsAt ?? record.sharedAt,
- endDate: record.endsAt,
- location: record.location,
- mode,
- description: undefined,
- href,
- source: record._community,
- } satisfies CommunityEvent;
- })
- );
-
- for (const result of resolved) {
- if (result.status === 'fulfilled' && result.value) {
- events.push(result.value);
- }
- }
- }
-
- return events;
-}
diff --git a/src/pages/about.astro b/src/pages/about.astro
--- a/src/pages/about.astro
+++ b/src/pages/about.astro
@@ -1,7 +1,7 @@
---
import Base from "../layouts/Base.astro";
-import Header from "../components/Header.astro";
-import Footer from "../components/Footer.astro";
+import Header from "../components/layout/Header.astro";
+import Footer from "../components/layout/Footer.astro";
---
entry.data) ?? [];
+
+const REGION_ORDER = ["North America", "South America", "Europe", "Online", "Other"];
+const grouped = new Map();
+for (const community of communities) {
+ const region = community.region ?? "Other";
+ if (!grouped.has(region)) grouped.set(region, []);
+ grouped.get(region)!.push(community);
}
-
-const communities = yaml.load(communitiesRaw) as Community[];
-
-// Fetch Bluesky profile avatars for communities
-const communityProfiles = await Promise.allSettled(
- communities.map(c => getProfile(c.handle))
+const regionRank = (r: string) => {
+ const i = REGION_ORDER.indexOf(r);
+ return i === -1 ? REGION_ORDER.length : i;
+};
+const regions = [...grouped.keys()].sort(
+ (a, b) => regionRank(a) - regionRank(b) || a.localeCompare(b),
);
-const communityAvatars = new Map();
-for (let i = 0; i < communities.length; i++) {
- const result = communityProfiles[i];
- if (result.status === 'fulfilled' && result.value.avatar) {
- communityAvatars.set(communities[i].handle, result.value.avatar);
- }
-}
---
@@ -42,14 +35,19 @@
Find one near you or start your own .
-
- {communities.map(c => (
-
+
+ ))}
@@ -68,11 +66,23 @@
line-height: var(--leading-relaxed);
}
+ .region-group {
+ margin-top: var(--space-xl);
+ }
+
+ .region-heading {
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin: 0 0 var(--space-base);
+ }
+
.communities-grid {
display: flex;
flex-direction: column;
gap: var(--space-lg);
- margin-top: var(--space-xl);
}
.community-entry {
diff --git a/src/pages/community-content.astro b/src/pages/community-content.astro
--- a/src/pages/community-content.astro
+++ b/src/pages/community-content.astro
@@ -1,76 +1,16 @@
---
+export const prerender = false;
+
import Base from '../layouts/Base.astro';
-import Header from '../components/Header.astro';
-import Footer from '../components/Footer.astro';
-import PostCard from '../components/PostCard.astro';
-import yaml from 'js-yaml';
-import communitiesRaw from '../data/communities.yml?raw';
-import { getProfile, excerpt } from '../lib/atproto';
-import { fetchBlogPosts } from '../lib/blog';
-import { fetchSharedContent } from '../lib/shared-content';
-import { preflight } from '../lib/preflight';
+import Header from '../components/layout/Header.astro';
+import Footer from '../components/layout/Footer.astro';
+import PostCard from '../components/content/PostCard.astro';
+import { getLiveCollection } from 'astro:content';
-interface Community {
- name: string;
- handle: string;
- location: string;
-}
-
-const communities = yaml.load(communitiesRaw) as Community[];
-
-await preflight();
-
-// Fetch Offprint posts
-const OFFPRINT_PUB = 'at://did:plc:lehcqqkwzcwvjvw66uthu5oq/site.standard.publication/3mjnpilwnrp2v';
-const blogPosts = await fetchBlogPosts({
- handle: 'atmosphere.community',
- pds: 'https://hydnum.us-west.host.bsky.network',
- publication: OFFPRINT_PUB,
-});
-
-const profile = await getProfile('atmosphere.community');
-const authorName = profile.displayName || `@${profile.handle}`;
-
-const offprintPosts = blogPosts.map(post => ({
- title: post.title,
- excerpt: excerpt(post.textContent ?? post.description, 150),
- author: authorName,
- avatarUrl: profile.avatar,
- date: post.publishedAt.toISOString().split('T')[0],
- href: post.url ?? 'https://blog.atmosphere.community',
- tag: post.tags?.[0],
- community: '@atmosphere.community',
-}));
-
-// Fetch shared content from all community accounts
-const sharedContentAccounts = [
- 'atmosphere.community',
- ...communities.map(c => c.handle),
-];
-const sharedPosts = await fetchSharedContent(sharedContentAccounts);
-
-const sharedPostCards = sharedPosts.map(p => ({
- title: p.title,
- excerpt: p.excerpt,
- author: p.author.displayName || `@${p.author.handle}`,
- avatarUrl: p.author.avatar,
- date: new Date(p.date).toISOString().split('T')[0],
- href: p.href,
- tag: p.tags?.[0],
- community: `@${p.community}`,
-}));
-
-// Merge, dedupe by title, sort by date
-const allPosts = [...offprintPosts, ...sharedPostCards];
-const seenTitles = new Set();
-const mergedPosts = allPosts
- .filter(p => {
- const key = p.title.toLowerCase().trim();
- if (seenTitles.has(key)) return false;
- seenTitles.add(key);
- return true;
- })
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+const feedResult = await getLiveCollection('feed');
+const mergedPosts = (feedResult.entries ?? [])
+ .map((entry) => entry.data)
+ .sort((a, b) => (b.publishedAt ?? b.sharedAt).getTime() - (a.publishedAt ?? a.sharedAt).getTime());
---
@@ -81,19 +21,29 @@
Community content
Blog posts and articles shared by community members across the Atmosphere.
- Content is aggregated from the atmosphere.community Offprint
- and community accounts via OpenSocial .
+ Content is aggregated from the atmosphere.community Offprint
+ and community accounts via OpenSocial .
{mergedPosts.length > 0 ? (
- {mergedPosts.map(post => (
-
- ))}
+ {mergedPosts.map((post) =>
)}
) : (
-
No community content yet. Visit the blog or share content via your community account.
+
+ No community content yet. Visit the
+ blog or share content via your community account.
+
)}
diff --git a/src/pages/events.astro b/src/pages/events.astro
--- a/src/pages/events.astro
+++ b/src/pages/events.astro
@@ -1,46 +1,52 @@
---
-import Base from '../layouts/Base.astro';
-import Header from '../components/Header.astro';
-import Footer from '../components/Footer.astro';
-import EventCard from '../components/EventCard.astro';
-import { fetchEvents } from '../lib/events';
-import { fetchSharedEvents } from '../lib/shared-content';
-import { preflight } from '../lib/preflight';
-import yaml from 'js-yaml';
-import communitiesRaw from '../data/communities.yml?raw';
+export const prerender = false;
-interface Community {
- name: string;
- handle: string;
- location: string;
+import { actions } from 'astro:actions';
+import { getLiveCollection } from 'astro:content';
+import Base from '../layouts/Base.astro';
+import Header from '../components/layout/Header.astro';
+import Footer from '../components/layout/Footer.astro';
+import EventCard from '../components/events/EventCard.astro';
+import RsvpNotice from '../components/events/RsvpNotice.astro';
+import { getRsvpsForEvents, type CalendarRsvp } from '../lib/rsvps';
+
+const rsvpResult = Astro.getActionResult(actions.rsvpEvent);
+
+if (rsvpResult?.data?.redirectUrl) {
+ return Astro.redirect(rsvpResult.data.redirectUrl, 303);
}
-const communities = yaml.load(communitiesRaw) as Community[];
+if (rsvpResult?.error) {
+ return Astro.redirect('/events?rsvp=error', 303);
+}
-// Build event account list: atmosphere.community + all community handles
-const eventAccounts = [
- 'atmosphere.community',
- ...communities.map(c => c.handle),
-];
+const eventsResult = await getLiveCollection('events');
+const allEvents = (eventsResult.entries ?? [])
+ .map((entry) => entry.data)
+ .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime());
-// Verify core services are reachable
-await preflight();
+const { upcoming: upcomingEvents = [], past: pastEvents = [] } = Object.groupBy(
+ allEvents,
+ (event) => (event.isPast ? 'past' : 'upcoming'),
+);
+pastEvents.reverse();
-// Fetch events from ATProto (community.lexicon.calendar.event)
-// Individual community failures are tolerated; only total service outage fails the build.
-const [directEvents, sharedEvents] = await Promise.all([
- fetchEvents(eventAccounts),
- fetchSharedEvents(eventAccounts),
-]);
+const loggedInUser = Astro.locals.loggedInUser;
+let rsvpsByEvent = new Map();
+let rsvpReadFailed = false;
-// Merge and deduplicate by name + start time
-const seen = new Set();
-const upcomingEvents = [...directEvents, ...sharedEvents].filter(e => {
- const key = `${e.name.toLowerCase().trim()}|${e.date}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
-}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+if (loggedInUser) {
+ try {
+ rsvpsByEvent = await getRsvpsForEvents(loggedInUser, [
+ ...upcomingEvents.map((event) => ({ uri: event.atUri })),
+ ...pastEvents.map((event) => ({ uri: event.atUri })),
+ ]);
+ } catch {
+ rsvpReadFailed = true;
+ }
+}
+
+const rsvpMessage = Astro.url.searchParams.get('rsvp');
---
@@ -53,17 +59,43 @@
Community meetups, conferences, and hangouts across the Atmosphere.
Events are sourced from Smoke Signal and OpenMeet .
+
+ {rsvpReadFailed && (
+ We couldn't check which events you're going to right now.
+ )}
{upcomingEvents.length > 0 ? (
- {upcomingEvents.map(event => (
-
+ {upcomingEvents.map((event) => (
+
))}
) : (
+ )}
+
+ {pastEvents.length > 0 && (
+
+ Past events
+
+ {pastEvents.map((event) => (
+
+ ))}
+
+
)}
@@ -83,6 +115,25 @@
line-height: var(--leading-relaxed);
}
+ .events-notice {
+ margin-top: var(--space-base);
+ max-width: 55ch;
+ padding: var(--space-sm) var(--space-md);
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ line-height: var(--leading-normal);
+ }
+
+ .events-notice--success {
+ background: var(--teal-100);
+ color: var(--teal-600);
+ }
+
+ .events-notice--error {
+ background: var(--coral-100);
+ color: var(--coral-600);
+ }
+
.events-grid {
margin-top: var(--space-xl);
}
@@ -94,5 +145,18 @@
color: var(--color-text-secondary);
background: var(--neutral-100);
border-radius: var(--radius-md);
+ }
+
+ .events-past {
+ margin-top: var(--space-3xl, var(--space-2xl));
+ }
+ .events-past__heading {
+ font-size: var(--text-lg);
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-base);
+ }
+ .events-past :global(.event-card) {
+ filter: grayscale(0.5);
+ background: var(--neutral-50);
}
diff --git a/src/pages/index.astro b/src/pages/index.astro
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,155 +1,35 @@
---
-import Base from '../layouts/Base.astro';
-import Header from '../components/Header.astro';
-import Footer from '../components/Footer.astro';
-import Hero from '../components/Hero.astro';
-import PostCard from '../components/PostCard.astro';
-import EventCard from '../components/EventCard.astro';
-import CommunityCard from '../components/CommunityCard.astro';
-import yaml from 'js-yaml';
-import communitiesRaw from '../data/communities.yml?raw';
-import { getProfile, excerpt } from '../lib/atproto';
-import { fetchBlogPosts } from '../lib/blog';
-import { fetchEvents } from '../lib/events';
-import { fetchSharedContent, fetchSharedEvents } from '../lib/shared-content';
-import { preflight } from '../lib/preflight';
-
-interface Community {
- name: string;
- handle: string;
- location: string;
- description?: string;
- bluesky?: string;
- website?: string;
-}
-
-const communities = yaml.load(communitiesRaw) as Community[];
-
-// Fetch Bluesky profile avatars for communities
-const communityProfiles = await Promise.allSettled(
- communities.map(c => getProfile(c.handle))
+export const prerender = false;
+import Base from "../layouts/Base.astro";
+import Header from "../components/layout/Header.astro";
+import Footer from "../components/layout/Footer.astro";
+import Hero from "../components/layout/Hero.astro";
+import PostCard from "../components/content/PostCard.astro";
+import EventCard from "../components/events/EventCard.astro";
+import CommunityCard from "../components/communities/CommunityCard.astro";
+import GetInvolved from "../components/layout/GetInvolved.astro";
+import { getLiveCollection } from "astro:content";
+const communitiesResult = await getLiveCollection("communities");
+const communities = (communitiesResult.entries ?? []).map(
+ (entry) => entry.data,
);
-const communityAvatars = new Map();
-for (let i = 0; i < communities.length; i++) {
- const result = communityProfiles[i];
- if (result.status === 'fulfilled' && result.value.avatar) {
- communityAvatars.set(communities[i].handle, result.value.avatar);
- }
-}
-// Verify core ATProto services are reachable before fetching data.
-// If services are down, the build fails to avoid deploying empty content.
-await preflight();
-
-// Fetch blog posts from ATProto
-const OFFPRINT_PUB = 'at://did:plc:lehcqqkwzcwvjvw66uthu5oq/site.standard.publication/3mjnpilwnrp2v';
-const blogPosts = await fetchBlogPosts({
- handle: 'atmosphere.community',
- pds: 'https://hydnum.us-west.host.bsky.network',
- publication: OFFPRINT_PUB,
-});
-
-const sortedPosts = blogPosts
- .sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime())
+const feedResult = await getLiveCollection("feed");
+const mergedPosts = (feedResult.entries ?? [])
+ .map((entry) => entry.data)
+ .sort(
+ (a, b) =>
+ (b.publishedAt ?? b.sharedAt).getTime() -
+ (a.publishedAt ?? a.sharedAt).getTime(),
+ )
.slice(0, 6);
-const profile = await getProfile('atmosphere.community');
-const authorName = profile.displayName || `@${profile.handle}`;
-
-const posts = sortedPosts.map(post => ({
- title: post.title,
- excerpt: excerpt(post.textContent ?? post.description, 150),
- author: authorName,
- avatarUrl: profile.avatar,
- date: post.publishedAt.toISOString().split('T')[0],
- href: post.url ?? 'https://blog.atmosphere.community',
- tag: post.tags?.[0],
- community: '@atmosphere.community',
-}));
-
-// Fetch shared content from atmosphere.community + all community accounts
-const sharedContentAccounts = [
- 'atmosphere.community',
- ...communities.map(c => c.handle),
-];
-const sharedPosts = await fetchSharedContent(sharedContentAccounts);
-
-// Merge Offprint posts + shared content, dedupe by title, sort by date, take first 6
-const allPosts = [
- ...posts.map(p => ({ ...p, isShared: false })),
- ...sharedPosts.map(p => ({
- title: p.title,
- excerpt: p.excerpt,
- author: p.author.displayName || `@${p.author.handle}`,
- avatarUrl: p.author.avatar,
- date: new Date(p.date).toISOString().split('T')[0],
- href: p.href,
- tag: p.tags?.[0],
- community: `@${p.community}`,
- isShared: true,
- })),
-];
-const seenTitles = new Set();
-const mergedPosts = allPosts
- .filter(p => {
- const key = p.title.toLowerCase().trim();
- if (seenTitles.has(key)) return false;
- seenTitles.add(key);
- return true;
- })
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
- .slice(0, 6);
-
-// Build event account list: atmosphere.community + all community handles
-const eventAccounts = [
- 'atmosphere.community',
- ...communities.map(c => c.handle),
-];
-
-// Fetch events from ATProto (community.lexicon.calendar.event)
-// Individual community failures are tolerated; only total failure crashes the build.
-const [directEvents, sharedEventsList] = await Promise.all([
- fetchEvents(eventAccounts),
- fetchSharedEvents(eventAccounts),
-]);
-
-// Merge and deduplicate by name + start time
-const seenEvents = new Set();
-const liveEvents = [...directEvents, ...sharedEventsList].filter(e => {
- const key = `${e.name.toLowerCase().trim()}|${e.date}`;
- if (seenEvents.has(key)) return false;
- seenEvents.add(key);
- return true;
-}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
-const events = liveEvents.slice(0, 4);
-
-// Community highlights (static)
-const highlights = [
- {
- title: 'Discourse Forum',
- description: 'Discuss ideas, proposals, and community projects with fellow builders.',
- href: 'https://discourse.atprotocol.community',
- linkText: 'Join the conversation →',
- },
- {
- title: 'Community Fund',
- description: 'Supporting projects, events, and infrastructure across the Atmosphere.',
- href: 'https://opencollective.com/atprotocoldev/',
- linkText: 'Learn more →',
- },
- {
- title: 'ATmosphereConf',
- description: 'The community conference for people building on AT Protocol.',
- href: 'https://atmosphereconf.org',
- linkText: 'See the schedule →',
- },
- {
- title: 'Discord',
- description: "Chat with community members, ask questions, and share what you're building.",
- href: 'https://discord.atprotocol.dev/',
- linkText: 'Join the Discord →',
- },
-];
+const eventsResult = await getLiveCollection("events");
+const events = (eventsResult.entries ?? [])
+ .map((entry) => entry.data)
+ .filter((event) => !event.isPast)
+ .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime())
+ .slice(0, 4);
---
@@ -161,17 +41,22 @@
- {mergedPosts.length > 0 ? (
- mergedPosts.map(post => (
-
- ))
- ) : (
-
No posts yet. Check back soon or visit the blog .
- )}
+ {
+ mergedPosts.length > 0 ? (
+ mergedPosts.map((post) =>
)
+ ) : (
+
+ No posts yet. Check back soon or{" "}
+ visit the blog .
+
+ )
+ }
@@ -184,9 +69,7 @@
See all events →
- {events.map(event => (
-
- ))}
+ {events.map((event) => )}
@@ -195,32 +78,24 @@
-
-
-
-
Get involved
-
- {highlights.map(h => (
-
- ))}
-
-
-
+
@@ -248,30 +123,6 @@
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
- }
-
- .highlight-card {
- display: flex;
- flex-direction: column;
- gap: var(--space-sm);
- }
- .highlight-card h3 {
- font-family: var(--font-body);
- font-size: var(--text-sm);
- font-weight: 600;
- }
- .highlight-card p {
- font-size: var(--text-xs);
- color: var(--color-text-muted);
- line-height: var(--leading-normal);
- }
- .highlight-card a {
- font-size: var(--text-xs);
- font-weight: 500;
- }
-
- .highlights-grid {
- margin-top: var(--space-xl);
}
.empty-state {
diff --git a/src/styles/global.css b/src/styles/global.css
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -205,6 +205,11 @@
padding: var(--space-base);
}
+/* The HTML `hidden` attribute should always win over author display rules. */
+[hidden] {
+ display: none !important;
+}
+
/* --- Badge / Pill --- */
.badge {
display: inline-flex;
diff --git a/src/components/auth/AuthMenu.astro b/src/components/auth/AuthMenu.astro
new file mode 100644
--- /dev/null
+++ b/src/components/auth/AuthMenu.astro
@@ -0,0 +1,515 @@
+---
+import { getBlueskyAgent } from "@fujocoded/authproto/helpers";
+import Combobox from "./Combobox.astro";
+import AtprotoIcon from "./icons/AtprotoIcon.astro";
+
+const PROFILE_TTL_MS = 60_000;
+type CachedProfile = {
+ expiresAt: number;
+ avatar?: string;
+ displayName?: string;
+};
+const profileCache: Map =
+ (globalThis as any).__authMenuProfileCache ??
+ ((globalThis as any).__authMenuProfileCache = new Map());
+
+async function loadProfile(did: string): Promise {
+ const now = Date.now();
+ const cached = profileCache.get(did);
+ if (cached && cached.expiresAt > now) return cached;
+ try {
+ const agent = await getBlueskyAgent();
+ const profile = await agent?.app.bsky.actor.getProfile({ actor: did });
+ const entry: CachedProfile = {
+ expiresAt: now + PROFILE_TTL_MS,
+ avatar: profile?.data.avatar,
+ displayName: profile?.data.displayName,
+ };
+ profileCache.set(did, entry);
+ return entry;
+ } catch (err) {
+ // Don't poison the cache on transient failures, but surface it server-side
+ // so a degraded appview doesn't disappear silently behind "no avatar".
+ console.warn("[AuthMenu] failed to load profile", did, err);
+ return cached ?? null;
+ }
+}
+
+const loggedInUser = Astro.locals.loggedInUser;
+const authError =
+ Astro.locals.authproto?.errorDescription ?? Astro.locals.authproto?.errorCode;
+
+const profile = loggedInUser ? await loadProfile(loggedInUser.did) : null;
+const displayName = profile?.displayName?.trim() || loggedInUser?.handle || "";
+const avatarUrl = profile?.avatar;
+const avatarInitial = (displayName || "@").charAt(0).toUpperCase();
+---
+
+
+
+
+
+
diff --git a/src/components/auth/Combobox.astro b/src/components/auth/Combobox.astro
new file mode 100644
--- /dev/null
+++ b/src/components/auth/Combobox.astro
@@ -0,0 +1,720 @@
+---
+interface Props {
+ id: string;
+ name: string;
+ label: string;
+ placeholder?: string;
+ inputClass?: string;
+ required?: boolean;
+ minLength?: number;
+ autocomplete?: string;
+ inputmode?: 'text' | 'email' | 'tel' | 'url' | 'search' | 'numeric' | 'decimal' | 'none';
+}
+
+const {
+ id,
+ name,
+ label,
+ placeholder,
+ inputClass,
+ required,
+ minLength = 1,
+ autocomplete = 'off',
+ inputmode,
+} = Astro.props;
+
+const popoverId = `${id}-popover`;
+const listboxId = `${id}-listbox`;
+const statusId = `${id}-status`;
+const hintId = `${id}-hint`;
+---
+
+
+ {label}
+
+
+
+
+
+
+
Couldn't load suggestions. Try again.
+
+
+
+ When suggestions are available, use up and down arrows to review and Enter to select. Touch device users, explore by touch or with swipe gestures.
+
+
+
+
+
+
diff --git a/src/components/communities/CommunityCard.astro b/src/components/communities/CommunityCard.astro
new file mode 100644
--- /dev/null
+++ b/src/components/communities/CommunityCard.astro
@@ -0,0 +1,116 @@
+---
+import Avatar, { type AvatarSource } from "../Avatar.astro";
+import BlueskyIcon from "./icons/BlueskyIcon.astro";
+
+interface Props {
+ name: string;
+ handle: string;
+ location: string;
+ description?: string;
+ bluesky?: string;
+ website?: string;
+ avatar: AvatarSource;
+}
+
+const { name, location, bluesky, website, avatar } = Astro.props;
+const href = bluesky ?? website;
+const Tag = href ? "a" : "div";
+---
+
+
+
+
+ {bluesky && (
+
+ )}
+
+
+
diff --git a/src/components/content/PostCard.astro b/src/components/content/PostCard.astro
new file mode 100644
--- /dev/null
+++ b/src/components/content/PostCard.astro
@@ -0,0 +1,137 @@
+---
+import Avatar, { type AvatarSource } from "../Avatar.astro";
+
+interface Props {
+ title: string;
+ url: string;
+ excerpt?: string;
+ publishedAt?: Date;
+ sharedAt: Date;
+ author: {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar: AvatarSource;
+ };
+ source: string;
+ documentUri?: string;
+ tags?: string[];
+}
+
+const { title, url, excerpt, publishedAt, sharedAt, author, source, tags } = Astro.props;
+
+const date = publishedAt ?? sharedAt;
+const authorLabel = author.displayName || `@${author.handle}`;
+const tag = tags?.[0];
+const community = `@${source}`;
+---
+
+
+
+
+
+
{title}
+ {excerpt &&
{excerpt}
}
+
+ {tag && {tag} }
+ {community}
+ {authorLabel}
+
+ {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+
+
+
+
+
+
+
diff --git a/src/components/events/EventCard.astro b/src/components/events/EventCard.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/EventCard.astro
@@ -0,0 +1,560 @@
+---
+import { actions } from "astro:actions";
+import EventLocation from "./EventLocation.astro";
+import CheckIcon from "./icons/CheckIcon.astro";
+import ExternalLinkIcon from "./icons/ExternalLinkIcon.astro";
+
+interface Props {
+ atUri: string;
+ uri: string;
+ cid?: string;
+ name: string;
+ startsAt: Date;
+ endsAt?: Date;
+ location?: string;
+ mode?: "inperson" | "virtual" | "hybrid";
+ description?: string;
+ source?: string;
+ sourceAvatar?: string;
+ showRsvp?: boolean;
+ isLoggedIn?: boolean;
+ rsvpStatus?: string;
+}
+
+const {
+ atUri,
+ uri,
+ cid,
+ name,
+ startsAt,
+ location,
+ mode = "inperson",
+ description,
+ source,
+ sourceAvatar,
+ showRsvp = false,
+ isLoggedIn = false,
+ rsvpStatus,
+} = Astro.props;
+
+const formattedDate = startsAt.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+});
+const formattedTime = startsAt.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ timeZone: "UTC",
+});
+
+const sourceLabel = source
+ ? `@${source.replace(/\.bsky\.social$/, "")}`
+ : undefined;
+const titleSize = name.length <= 45 ? "lg" : name.length <= 70 ? "md" : "sm";
+const isGoing = rsvpStatus === "community.lexicon.calendar.rsvp#going";
+const isNotGoing = rsvpStatus === "community.lexicon.calendar.rsvp#notgoing";
+const hasPositiveRsvp = Boolean(rsvpStatus && !isNotGoing);
+const nextRsvpStatus = isGoing ? "notgoing" : "going";
+const rsvpButtonLabel = isGoing ? "I'm not going" : "I'm going";
+const rsvpButtonAriaLabel = isGoing
+ ? `Mark yourself as not going to ${name}`
+ : `Mark yourself as going to ${name}`;
+const canRsvp = showRsvp && isLoggedIn && Boolean(cid);
+---
+
+
+ {
+ sourceLabel && (
+
+ )
+ }
+
+ {formattedDate} · {formattedTime} UTC
+
+
+
+ {description ?? "No description provided."}
+
+
+
+ {
+ canRsvp && (
+
+ )
+ }
+
+
+
+
+
diff --git a/src/components/events/EventLocation.astro b/src/components/events/EventLocation.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/EventLocation.astro
@@ -0,0 +1,83 @@
+---
+import GlobeIcon from './icons/GlobeIcon.astro';
+import PinIcon from './icons/PinIcon.astro';
+
+interface Props {
+ mode?: 'inperson' | 'virtual' | 'hybrid';
+ location?: string;
+}
+
+const { mode = 'inperson', location } = Astro.props;
+const isOnline = mode === 'virtual' || mode === 'hybrid';
+const locationLine = location ?? 'In person';
+---
+
+{mode === 'hybrid' ? (
+
+
+
+ Online
+
+
+
+ {locationLine}
+
+
+) : isOnline ? (
+
+
+ Online
+
+) : (
+
+
+ {locationLine}
+
+)}
+
+
diff --git a/src/components/events/RsvpNotice.astro b/src/components/events/RsvpNotice.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/RsvpNotice.astro
@@ -0,0 +1,48 @@
+---
+interface Props {
+ message: string | null;
+ eventName?: string | null;
+}
+
+const { message, eventName } = Astro.props;
+---
+
+{message === 'going' && (
+
+ {eventName ? `You're going to ${eventName}.` : "You're marked as going."}
+
+)}
+{message === 'notgoing' && (
+
+ {eventName ? `You're no longer going to ${eventName}.` : "You're no longer marked as going."}
+
+)}
+{message === 'permission' && (
+ Your login needs RSVP permission. Log out and log back in, then try again.
+)}
+{message === 'error' && (
+
+ {eventName ? `We couldn't save your RSVP for ${eventName}. Please try again.` : "We couldn't save your RSVP. Please try again."}
+
+)}
+
+
diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro
new file mode 100644
--- /dev/null
+++ b/src/components/layout/Footer.astro
@@ -0,0 +1,78 @@
+---
+const year = new Date().getFullYear();
+---
+
+
+
+
diff --git a/src/components/layout/GetInvolved.astro b/src/components/layout/GetInvolved.astro
new file mode 100644
--- /dev/null
+++ b/src/components/layout/GetInvolved.astro
@@ -0,0 +1,80 @@
+---
+const highlights = [
+ {
+ title: "Discourse Forum",
+ description:
+ "Discuss ideas, proposals, and community projects with fellow builders.",
+ href: "https://discourse.atprotocol.community",
+ linkText: "Join the conversation →",
+ },
+ {
+ title: "Community Fund",
+ description:
+ "Supporting projects, events, and infrastructure across the Atmosphere.",
+ href: "https://opencollective.com/atprotocoldev/",
+ linkText: "Learn more →",
+ },
+ {
+ title: "ATmosphereConf",
+ description: "The community conference for people building on AT Protocol.",
+ href: "https://atmosphereconf.org",
+ linkText: "See the schedule →",
+ },
+ {
+ title: "Discord",
+ description:
+ "Chat with community members, ask questions, and share what you're building.",
+ href: "https://discord.atprotocol.dev/",
+ linkText: "Join the Discord →",
+ },
+];
+---
+
+
+
+
Get involved
+
+ {
+ highlights.map((highlight) => (
+
+ ))
+ }
+
+
+
+
+
diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro
new file mode 100644
--- /dev/null
+++ b/src/components/layout/Header.astro
@@ -0,0 +1,203 @@
+---
+import AuthMenu from '../auth/AuthMenu.astro';
+
+const navLinks = [
+ { href: '/', label: 'Home' },
+ { href: '/about', label: 'About' },
+ { href: '/community-content', label: 'Content' },
+ { href: '/events', label: 'Events' },
+ { href: '/communities', label: 'Communities' },
+];
+
+const currentPath = Astro.url.pathname;
+
+const isActive = (href: string) =>
+ currentPath === href || (href !== '/' && currentPath.startsWith(href));
+---
+
+
+
+
diff --git a/src/components/layout/Hero.astro b/src/components/layout/Hero.astro
new file mode 100644
--- /dev/null
+++ b/src/components/layout/Hero.astro
@@ -0,0 +1,225 @@
+---
+interface Props {
+ subscriberCount?: number;
+ groupCount?: number;
+}
+
+const { subscriberCount = 1000, groupCount = 10 } = Astro.props;
+---
+
+
+
+
+
+
The community hub for the Atmosphere
+
+ Connecting people and projects building on AT Protocol.
+ Blog posts, events, and local communities — all in one place.
+
+
+
+
+
+
+ {subscriberCount.toLocaleString()}+
+ Subscribers
+
+
+ {groupCount}
+ Regional Groups
+
+
+
+
+
+
diff --git a/src/lib/community/events.ts b/src/lib/community/events.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/events.ts
@@ -0,0 +1,143 @@
+import type { CommunityEvent, LocationDetail } from './types.js';
+import { AtUri } from '@atproto/api';
+
+const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|avif)$/i;
+
+export function parseEventRecord(
+ value: Record,
+ ctx: { did: string; rkey: string; source: string },
+): CommunityEvent | null {
+ const startsAt = parseDate(value.startsAt as string);
+ if (!startsAt) return null;
+
+ const endsAt = value.endsAt ? parseDate(value.endsAt as string) : undefined;
+
+ return {
+ name: value.name as string,
+ startsAt,
+ endsAt: endsAt ?? undefined,
+ description: (value.description as string) ?? undefined,
+ mode: normalizeEventMode(value.mode),
+ status: (value.status as string) ?? 'scheduled',
+ location: extractLocationString(value.locations as unknown[]),
+ locationDetail: extractLocationDetail(value.locations as unknown[]),
+ uri: selectEventUri(value.uris as unknown[], ctx.did, ctx.rkey),
+ atUri: `at://${ctx.did}/community.lexicon.calendar.event/${ctx.rkey}`,
+ source: ctx.source,
+ rkey: ctx.rkey,
+ did: ctx.did,
+ };
+}
+
+function parseDate(value: string | undefined | null): Date | null {
+ if (!value) return null;
+ const d = new Date(value);
+ return isNaN(d.getTime()) ? null : d;
+}
+
+export function normalizeEventMode(value: unknown): CommunityEvent['mode'] {
+ // Lexicon tokens arrive as either a fragment (`#hybrid`) or a fully-qualified
+ // ref (`community.lexicon.calendar.event#hybrid`). Take whatever follows the
+ // last `#` so both forms collapse to the bare token.
+ const raw = String(value ?? '');
+ const s = raw.includes('#') ? raw.slice(raw.lastIndexOf('#') + 1) : raw;
+ if (s === 'inperson' || s === 'virtual' || s === 'hybrid') return s;
+ return 'virtual';
+}
+
+function selectEventUri(uris: unknown[] | undefined, did: string, rkey: string): string {
+ const smokeFallback = `https://smokesignal.events/${did}/${rkey}`;
+ if (!uris || uris.length === 0) return smokeFallback;
+
+ const openMeet = uris.find((u) => isUriObject(u) && u.name === 'OpenMeet Event');
+ if (openMeet && isUriObject(openMeet)) return openMeet.uri;
+
+ const nonImage = uris.find(
+ (u) =>
+ isUriObject(u) &&
+ u.name !== 'Event Image' &&
+ !IMAGE_EXTENSIONS.test(u.uri),
+ );
+ if (nonImage && isUriObject(nonImage)) return nonImage.uri;
+
+ const first = uris[0];
+ if (isUriObject(first)) return first.uri;
+
+ return smokeFallback;
+}
+
+function isUriObject(u: unknown): u is { uri: string; name?: string } {
+ return typeof u === 'object' && u !== null && 'uri' in u && typeof (u as { uri: unknown }).uri === 'string';
+}
+
+function extractLocationString(locations: unknown[] | undefined): string | undefined {
+ if (!locations || locations.length === 0) return undefined;
+ const loc = locations[0] as Record;
+
+ if (loc.locality || loc.region || loc.country) {
+ return [loc.locality, loc.region, loc.country].filter(Boolean).join(', ');
+ }
+
+ if (loc.latitude !== undefined && loc.longitude !== undefined) {
+ if (typeof loc.name === 'string' && loc.name.length > 0) {
+ return simplifyGeoAddress(loc.name);
+ }
+ return `${loc.latitude}, ${loc.longitude}`;
+ }
+
+ if (typeof loc.name === 'string') return loc.name;
+ return undefined;
+}
+
+function extractLocationDetail(locations: unknown[] | undefined): LocationDetail | undefined {
+ if (!locations || locations.length === 0) return undefined;
+ const loc = locations[0] as Record;
+
+ if (loc.locality || loc.region || loc.country) {
+ return {
+ type: 'address',
+ name: loc.name as string | undefined,
+ locality: loc.locality as string | undefined,
+ region: loc.region as string | undefined,
+ country: loc.country as string | undefined,
+ };
+ }
+
+ if (loc.latitude !== undefined && loc.longitude !== undefined) {
+ return {
+ type: 'geo',
+ name: loc.name as string | undefined,
+ latitude: loc.latitude as number,
+ longitude: loc.longitude as number,
+ };
+ }
+
+ if (loc.$type && String(loc.$type).includes('hthree')) {
+ return {
+ type: 'hthree',
+ name: loc.name as string | undefined,
+ };
+ }
+
+ return undefined;
+}
+
+export function smokeSignalHref(atUri: string): string {
+ try {
+ const parsed = new AtUri(atUri);
+ return `https://smokesignal.events/${parsed.host}/${parsed.rkey}`;
+ } catch {
+ return '';
+ }
+}
+
+export function simplifyGeoAddress(address: string): string {
+ const parts = address.split(',').map((s) => s.trim()).filter(Boolean);
+ const meaningful = parts.filter((part) => {
+ if (/^\d+\s/.test(part)) return false;
+ if (/^\d{4,}$/.test(part.replace(/\s/g, ''))) return false;
+ if (/\bcounty\b/i.test(part)) return false;
+ return true;
+ });
+ return meaningful.slice(-3).join(', ');
+}
diff --git a/src/lib/community/identity.ts b/src/lib/community/identity.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/identity.ts
@@ -0,0 +1,32 @@
+// Remove this file once `@fujocoded/astro-atproto-loader` ships
+// `resolveHandleToDid` (and ideally a `fetchHandle: true` source option that
+// gives a TS-level guarantee `repo.handle` is set). Until then, this is the
+// local copy used by the community directory.
+import { DidResolver, HandleResolver, MemoryCache, getHandle } from '@atproto/identity';
+
+const didCache = new MemoryCache();
+const didResolver = new DidResolver({ didCache });
+const handleResolver = new HandleResolver({});
+
+export async function resolveHandleToDid(handleOrDid: string): Promise {
+ if (handleOrDid.startsWith('did:')) return handleOrDid;
+
+ const normalized = handleOrDid.toLowerCase().replace(/^@/, '');
+ const did = await handleResolver.resolve(normalized);
+ if (!did) {
+ throw new Error(`Could not resolve handle "${handleOrDid}" to a DID`);
+ }
+
+ const doc = await didResolver.resolve(did);
+ if (!doc) {
+ throw new Error(`Could not resolve DID document for "${did}"`);
+ }
+ const docHandle = getHandle(doc);
+ if (!docHandle || docHandle.toLowerCase() !== normalized) {
+ throw new Error(
+ `Handle verification failed: "${handleOrDid}" does not match DID document handle "${docHandle ?? 'none'}"`,
+ );
+ }
+
+ return did;
+}
diff --git a/src/lib/community/index.ts b/src/lib/community/index.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/index.ts
@@ -0,0 +1,24 @@
+export { getProfile, prefetchSourceProfiles } from './profiles.js';
+export { parseEventRecord, normalizeEventMode, smokeSignalHref } from './events.js';
+export {
+ parseBlogPostRef,
+ hydrateBlogPost,
+ parseSharedDocumentRef,
+ parseSharedEventRef,
+ hydrateSharedDocument,
+ hydrateSharedEvent,
+} from './shared-content.js';
+export type {
+ CommunityEvent,
+ LocationDetail,
+ SharedPost,
+ BlogPost,
+ AtProfile,
+} from './types.js';
+export type {
+ BlogPostRef,
+ SharedDocumentRef,
+ SharedEventRef,
+ HydrateDocumentDeps,
+ HydrateEventDeps,
+} from './shared-content.js';
diff --git a/src/lib/community/profiles.ts b/src/lib/community/profiles.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/profiles.ts
@@ -0,0 +1,75 @@
+import { Agent } from '@atproto/api';
+import type { AtProfile } from './types.js';
+import { resolveHandleToDid } from './identity.js';
+
+const PUBLIC_API = 'https://public.api.bsky.app';
+
+const profileCache = new Map();
+
+// Curated source accounts (community handles + the site's own account) live in a
+// separate, never-evicted cache. Warmed once at server boot via prefetchSourceProfiles
+// so per-record transformers in live.config.ts hit it instead of refetching.
+// Kept distinct from profileCache so any future eviction policy on the general
+// cache (LRU/TTL for arbitrary external authors) doesn't drop our known set.
+const sourceProfileCache = new Map();
+
+async function fetchProfile(handleOrDid: string): Promise {
+ let did: string;
+ try {
+ did = await resolveHandleToDid(handleOrDid);
+ } catch {
+ did = handleOrDid;
+ }
+
+ try {
+ const agent = new Agent(new URL(PUBLIC_API));
+ const response = await agent.getProfile({ actor: did });
+ return {
+ did: response.data.did,
+ handle: response.data.handle,
+ displayName: response.data.displayName || undefined,
+ avatar: response.data.avatar || undefined,
+ };
+ } catch (err) {
+ console.warn(`Failed to fetch profile for ${did}:`, err);
+ return {
+ did,
+ handle: handleOrDid.startsWith('did:') ? did : handleOrDid,
+ };
+ }
+}
+
+export async function prefetchSourceProfiles(handles: string[]): Promise {
+ const results = await Promise.allSettled(handles.map((h) => fetchProfile(h)));
+ results.forEach((result, i) => {
+ if (result.status !== 'fulfilled') return;
+ const profile = result.value;
+ // Index by every key callers might pass: original handle (may differ from
+ // canonical), resolved did, and resolved handle.
+ sourceProfileCache.set(handles[i], profile);
+ sourceProfileCache.set(profile.did, profile);
+ sourceProfileCache.set(profile.handle, profile);
+ });
+}
+
+export async function getProfile(handleOrDid: string): Promise {
+ const sourceHit = sourceProfileCache.get(handleOrDid);
+ if (sourceHit) return sourceHit;
+
+ let did: string;
+ try {
+ did = await resolveHandleToDid(handleOrDid);
+ } catch {
+ did = handleOrDid;
+ }
+
+ const sourceHitByDid = sourceProfileCache.get(did);
+ if (sourceHitByDid) return sourceHitByDid;
+
+ const cached = profileCache.get(did);
+ if (cached) return cached;
+
+ const profile = await fetchProfile(handleOrDid);
+ profileCache.set(did, profile);
+ return profile;
+}
diff --git a/src/lib/community/shared-content.ts b/src/lib/community/shared-content.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/shared-content.ts
@@ -0,0 +1,266 @@
+import type {
+ SharedPost,
+ AtProfile,
+ CommunityEvent,
+ BlogPost,
+} from './types.js';
+import { AtUri } from '@atproto/api';
+import { isValidAtUri } from '@atproto/syntax';
+import { normalizeEventMode, parseEventRecord } from './events.js';
+
+const LEAFLET_BASE = 'https://leaflet.pub/profile';
+
+export interface BlogPostRef {
+ title: string;
+ publishedAt: Date;
+ path?: string;
+ tags?: string[];
+ textContent?: string;
+ rkey: string;
+ did: string;
+}
+
+export function parseBlogPostRef(
+ value: Record,
+ ctx: { did: string; rkey: string },
+): BlogPostRef | null {
+ const publishedAt = safeParseDate(value.publishedAt as string | undefined | null);
+ if (!publishedAt) return null;
+
+ return {
+ title: (value.title as string) || 'Untitled',
+ publishedAt,
+ path: (value.path as string) ?? undefined,
+ tags: (value.tags as string[]) ?? undefined,
+ textContent: (value.textContent as string) ?? undefined,
+ rkey: ctx.rkey,
+ did: ctx.did,
+ };
+}
+
+export function hydrateBlogPost(
+ ref: BlogPostRef,
+ deps: { author: AtProfile; baseUrl?: string },
+): BlogPost {
+ return {
+ title: ref.title,
+ url: buildPostUrl(deps.baseUrl, ref.did, ref.rkey, ref.path),
+ textContent: ref.textContent,
+ publishedAt: ref.publishedAt,
+ author: deps.author,
+ path: ref.path,
+ tags: ref.tags,
+ rkey: ref.rkey,
+ did: ref.did,
+ };
+}
+
+function buildPostUrl(
+ baseUrl: string | undefined,
+ did: string,
+ rkey: string,
+ path: string | undefined,
+): string {
+ if (baseUrl && path) {
+ return appendPath(baseUrl, path);
+ }
+ return `${LEAFLET_BASE}/${did}/${rkey}`;
+}
+
+export interface SharedDocumentRef {
+ documentUri: string;
+ titleOverride?: string;
+ sharedBy?: string;
+ sharedAt: Date;
+ source: string;
+}
+
+export interface SharedEventRef {
+ documentUri: string;
+ title?: string;
+ sharedBy?: string;
+ sharedAt: Date;
+ source: string;
+ fallbackStartsAt?: Date;
+ fallbackEndsAt?: Date;
+ fallbackLocation?: string;
+ fallbackMode?: CommunityEvent['mode'];
+}
+
+type FetchRecordValue = (atUri: string) => Promise | null>;
+
+export interface HydrateDocumentDeps {
+ fetchRecord: FetchRecordValue;
+ getProfile: (handle: string) => Promise;
+ baseUrl?: string;
+}
+
+export interface HydrateEventDeps {
+ fetchRecord: FetchRecordValue;
+}
+
+export function parseSharedDocumentRef(
+ value: Record,
+ ctx: { source: string },
+): SharedDocumentRef | null {
+ if (value.type !== 'document') return null;
+
+ const documentUri = asNonEmptyString(value.documentUri);
+ if (!documentUri) return null;
+
+ const sharedAt = safeParseDate(value.sharedAt as string | undefined);
+ if (!sharedAt) return null;
+
+ return {
+ documentUri,
+ titleOverride: asNonEmptyString(value.title),
+ sharedBy: asNonEmptyString(value.sharedBy),
+ sharedAt,
+ source: ctx.source,
+ };
+}
+
+export function parseSharedEventRef(
+ value: Record,
+ ctx: { source: string },
+): SharedEventRef | null {
+ if (value.type !== 'event') return null;
+
+ const documentUri = asNonEmptyString(value.documentUri);
+ if (!documentUri) return null;
+
+ const sharedAt = safeParseDate(value.sharedAt as string | undefined);
+ if (!sharedAt) return null;
+
+ const fallbackMode =
+ value.mode !== undefined ? normalizeEventMode(value.mode) : undefined;
+
+ return {
+ documentUri,
+ title: asNonEmptyString(value.title),
+ sharedBy: asNonEmptyString(value.sharedBy),
+ sharedAt,
+ source: ctx.source,
+ fallbackStartsAt: safeParseDate(value.startsAt as string | undefined) ?? undefined,
+ fallbackEndsAt: safeParseDate(value.endsAt as string | undefined) ?? undefined,
+ fallbackLocation: asNonEmptyString(value.location),
+ fallbackMode,
+ };
+}
+
+export async function hydrateSharedDocument(
+ ref: SharedDocumentRef,
+ deps: HydrateDocumentDeps,
+): Promise {
+ const parsed = new AtUri(ref.documentUri);
+
+ const docValue = await deps.fetchRecord(ref.documentUri);
+ if (!docValue) return null;
+
+ const author = await deps.getProfile(ref.sharedBy || parsed.host);
+ const url = await resolveDocumentUrl(
+ docValue,
+ parsed.host,
+ parsed.rkey,
+ deps.fetchRecord,
+ deps.baseUrl,
+ );
+
+ const textContent = (docValue.textContent as string) ?? undefined;
+ const publishedAt = safeParseDate(docValue.publishedAt as string | undefined);
+
+ return {
+ title: ref.titleOverride || (docValue.title as string) || 'Untitled',
+ url,
+ textContent,
+ publishedAt: publishedAt ?? undefined,
+ sharedAt: ref.sharedAt,
+ author,
+ source: ref.source,
+ documentUri: ref.documentUri,
+ tags: (docValue.tags as string[]) ?? undefined,
+ };
+}
+
+export async function hydrateSharedEvent(
+ ref: SharedEventRef,
+ deps: HydrateEventDeps,
+): Promise {
+ const parsed = new AtUri(ref.documentUri);
+
+ try {
+ const value = await deps.fetchRecord(ref.documentUri);
+ if (value) {
+ const event = parseEventRecord(value, {
+ did: parsed.host,
+ rkey: parsed.rkey,
+ source: ref.source,
+ });
+ if (event) return event;
+ }
+ } catch {
+ // Fall through to embedded fallback
+ }
+
+ if (!ref.title || !ref.fallbackStartsAt) return null;
+
+ return {
+ name: ref.title,
+ startsAt: ref.fallbackStartsAt,
+ endsAt: ref.fallbackEndsAt,
+ description: undefined,
+ mode: ref.fallbackMode ?? 'virtual',
+ status: 'scheduled',
+ location: ref.fallbackLocation,
+ locationDetail: undefined,
+ uri: `https://smokesignal.events/${parsed.host}/${parsed.rkey}`,
+ atUri: ref.documentUri,
+ source: ref.source,
+ rkey: parsed.rkey,
+ did: parsed.host,
+ };
+}
+
+async function resolveDocumentUrl(
+ docValue: Record,
+ repo: string,
+ rkey: string,
+ fetchRecord: FetchRecordValue,
+ fallbackBaseUrl?: string,
+): Promise {
+ const path = docValue.path as string | undefined;
+
+ const site = typeof docValue.site === 'string' ? docValue.site : undefined;
+
+ if (site && !isValidAtUri(site) && path) return appendPath(site, path);
+
+ if (site && isValidAtUri(site)) {
+ try {
+ const pub = await fetchRecord(site);
+ const pubUrl = pub?.url as string | undefined;
+ if (pubUrl && path) return appendPath(pubUrl, path);
+ } catch {
+ // Fall through
+ }
+ }
+
+ if (fallbackBaseUrl && path) return appendPath(fallbackBaseUrl, path);
+
+ return `${LEAFLET_BASE}/${repo}/${rkey}`;
+}
+
+function appendPath(baseUrl: string, path: string): string {
+ const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ const segment = path.startsWith('/') ? path.slice(1) : path;
+ return new URL(segment, base).toString();
+}
+
+function asNonEmptyString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+function safeParseDate(value: string | undefined | null): Date | null {
+ if (!value) return null;
+ const d = new Date(value);
+ return isNaN(d.getTime()) ? null : d;
+}
diff --git a/src/lib/community/types.ts b/src/lib/community/types.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/community/types.ts
@@ -0,0 +1,59 @@
+export interface CommunityEvent {
+ name: string;
+ startsAt: Date;
+ endsAt?: Date;
+ description?: string;
+ mode: 'inperson' | 'virtual' | 'hybrid';
+ status: string;
+ location?: string;
+ locationDetail?: LocationDetail;
+ /** Public-facing URL for this event (Smoke Signal / OpenMeet / etc.). */
+ uri: string;
+ /** at:// URI of the underlying event record. */
+ atUri: string;
+ source: string;
+ rkey: string;
+ did: string;
+}
+
+export interface LocationDetail {
+ type: 'address' | 'geo' | 'hthree';
+ name?: string;
+ locality?: string;
+ region?: string;
+ country?: string;
+ latitude?: number;
+ longitude?: number;
+}
+
+export interface SharedPost {
+ title: string;
+ url: string;
+ textContent?: string;
+ publishedAt?: Date;
+ sharedAt: Date;
+ author: AtProfile;
+ source: string;
+ documentUri: string;
+ tags?: string[];
+}
+
+export interface BlogPost {
+ title: string;
+ url: string;
+ textContent?: string;
+ publishedAt: Date;
+ author: AtProfile;
+ path?: string;
+ tags?: string[];
+ rkey: string;
+ did: string;
+}
+
+export interface AtProfile {
+ did: string;
+ handle: string;
+ displayName?: string;
+ avatar?: string;
+}
+
diff --git a/src/components/auth/icons/AtprotoIcon.astro b/src/components/auth/icons/AtprotoIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/auth/icons/AtprotoIcon.astro
@@ -0,0 +1,15 @@
+---
+interface Props {
+ class?: string;
+ width?: number | string;
+ height?: number | string;
+}
+const { class: className, width = 16, height = 16 } = Astro.props;
+---
+
+
+
diff --git a/src/components/communities/icons/BlueskyIcon.astro b/src/components/communities/icons/BlueskyIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/communities/icons/BlueskyIcon.astro
@@ -0,0 +1,9 @@
+---
+interface Props {
+ class?: string;
+}
+const { class: className } = Astro.props;
+---
+
+
+
diff --git a/src/components/events/icons/CheckIcon.astro b/src/components/events/icons/CheckIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/icons/CheckIcon.astro
@@ -0,0 +1,9 @@
+---
+interface Props {
+ class?: string;
+}
+const { class: className } = Astro.props;
+---
+
+
+
diff --git a/src/components/events/icons/ExternalLinkIcon.astro b/src/components/events/icons/ExternalLinkIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/icons/ExternalLinkIcon.astro
@@ -0,0 +1,11 @@
+---
+interface Props {
+ class?: string;
+}
+const { class: className } = Astro.props;
+---
+
+
+
+
+
diff --git a/src/components/events/icons/GlobeIcon.astro b/src/components/events/icons/GlobeIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/icons/GlobeIcon.astro
@@ -0,0 +1,11 @@
+---
+interface Props {
+ class?: string;
+}
+const { class: className } = Astro.props;
+---
+
+
+
+
+
diff --git a/src/components/events/icons/PinIcon.astro b/src/components/events/icons/PinIcon.astro
new file mode 100644
--- /dev/null
+++ b/src/components/events/icons/PinIcon.astro
@@ -0,0 +1,10 @@
+---
+interface Props {
+ class?: string;
+}
+const { class: className } = Astro.props;
+---
+
+
+
+