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 ? ( + {alt} +) : ( + +)} 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})`; -} ---- - -
- {avatar ? ( - - ) : ( - - )} -
- {name} - {location} -
- {bluesky && ( - - - - )} -
- - 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; ---- - -
- -

- -

-

{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 ? ( - - ) : ( - - )} -
-

{title}

-

{excerpt}

-
- {tag && {tag}} - {community && {community}} - {author} - - - -
-
-
-
- - 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 => ( -
- - {c.description &&

{c.description}

} + {regions.map((region) => ( +
+

{region}

+
+ {grouped.get(region)!.map(c => ( +
+ + {c.description &&

{c.description}

} +
+ ))}
- ))} -
+ + ))}
@@ -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) => ( + ))}
) : (

No upcoming events right now. Check back soon or create one on Smoke Signal.

+ )} + + {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 @@
-

Latest from the community

+

+ Latest from the community +

See all →
- {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 @@
-

Local communities

+

+ Local communities +

See all →
- {communities.slice(0, 15).map(c => ( - - ))} + { + communities + .slice(0, 15) + .map((community) => ( + + )) + }
- -
-
-

Get involved

-
- {highlights.map(h => ( -
-

{h.title}

-

{h.description}

- {h.linkText} -
- ))} -
-
-
+