A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Compare changes

Choose any two refs to compare.

+6002 -1155
+22
.tangled/workflows/lint.yml
··· 1 + # Biome lint and format checks 2 + 3 + when: 4 + - event: ["push", "manual"] 5 + branch: ["main"] 6 + - event: ["pull_request"] 7 + branch: ["main"] 8 + 9 + engine: "nixery" 10 + 11 + dependencies: 12 + nixpkgs: 13 + - bun 14 + - biome 15 + 16 + steps: 17 + - name: "Install dependencies" 18 + command: "bun install" 19 + - name: "Lint check" 20 + command: "cd packages/cli && biome lint ." 21 + - name: "Format check" 22 + command: "cd packages/cli && biome format ."
+146
CHANGELOG.md
··· 1 + ## [0.4.0] - 2026-02-07 2 + 3 + ### ๐Ÿš€ Features 4 + 5 + - Initial ui components 6 + 7 + ### โš™๏ธ Miscellaneous Tasks 8 + 9 + - Small updates 10 + - Refactored package into existing cli 11 + - Tested comments in docs 12 + - Updated thread style and added test.html 13 + - Update docs 14 + - Updated comments 15 + - Lint 16 + 17 + ## [0.3.3] - 2026-02-05 18 + 19 + ### โš™๏ธ Miscellaneous Tasks 20 + 21 + - Cleaned up remaining auth implementations 22 + - Format 23 + - Release 0.3.3 24 + ## [0.3.2] - 2026-02-05 25 + 26 + ### ๐Ÿ› Bug Fixes 27 + 28 + - Fixed issue with auth selection in init command 29 + 30 + ### โš™๏ธ Miscellaneous Tasks 31 + 32 + - Release 0.3.2 33 + ## [0.3.1] - 2026-02-04 34 + 35 + ### ๐Ÿ› Bug Fixes 36 + 37 + - Asset subdirectories 38 + 39 + ### โš™๏ธ Miscellaneous Tasks 40 + 41 + - Updated authentication ux 42 + - Release 0.3.1 43 + - Bumped version 44 + ## [0.3.0] - 2026-02-04 45 + 46 + ### ๐Ÿš€ Features 47 + 48 + - Initial oauth implementation 49 + - Add stripDatePrefix option for Jekyll-style filenames 50 + - Add `update` command 51 + 52 + ### โš™๏ธ Miscellaneous Tasks 53 + 54 + - Update changelog 55 + - Added workflows 56 + - Updated workflows 57 + - Updated workflows 58 + - Cleaned up types 59 + - Updated icon styles 60 + - Updated og image 61 + - Updated docs 62 + - Docs updates 63 + - Bumped version 64 + ## [0.2.1] - 2026-02-02 65 + 66 + ### โš™๏ธ Miscellaneous Tasks 67 + 68 + - Added CHANGELOG 69 + - Merge main into chore/fronmatter-config-updates 70 + - Added linting and formatting 71 + - Linting updates 72 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 73 + - Version bump 74 + ## [0.2.0] - 2026-02-01 75 + 76 + ### ๐Ÿš€ Features 77 + 78 + - Added bskyPostRef 79 + - Added draft field to frontmatter config 80 + 81 + ### โš™๏ธ Miscellaneous Tasks 82 + 83 + - Resolved action items from issue #3 84 + - Adjusted tags to accept yaml multiline arrays for tags 85 + - Updated inject to handle new slug options 86 + - Updated comments 87 + - Update blog post 88 + - Fix blog build error 89 + - Adjust blog post 90 + - Updated docs 91 + - Version bump 92 + ## [0.1.1] - 2026-01-31 93 + 94 + ### ๐Ÿ› Bug Fixes 95 + 96 + - Fix tangled url to repo 97 + 98 + ### โš™๏ธ Miscellaneous Tasks 99 + 100 + - Merge branch 'main' into feat/blog-post 101 + - Updated blog post 102 + - Updated date 103 + - Added publishing 104 + - Spelling and grammar 105 + - Updated package scripts 106 + - Refactored codebase to use node and fs instead of bun 107 + - Version bump 108 + ## [0.1.0] - 2026-01-30 109 + 110 + ### ๐Ÿš€ Features 111 + 112 + - Init 113 + - Added blog post 114 + 115 + ### โš™๏ธ Miscellaneous Tasks 116 + 117 + - Updated package.json 118 + - Cleaned up commands and libs 119 + - Updated init commands 120 + - Updated greeting 121 + - Updated readme 122 + - Link updates 123 + - Version bump 124 + - Added hugo support through frontmatter parsing 125 + - Version bump 126 + - Updated docs 127 + - Adapted inject.ts pattern 128 + - Updated docs 129 + - Version bump" 130 + - Updated package scripts 131 + - Updated scripts 132 + - Added ignore field to config 133 + - Udpate docs 134 + - Version bump 135 + - Added tags to flow 136 + - Added ability to exit during init flow 137 + - Version bump 138 + - Updated docs 139 + - Updated links 140 + - Updated docs 141 + - Initial refactor 142 + - Checkpoint 143 + - Refactored mapping 144 + - Docs updates 145 + - Docs updates 146 + - Version bump
+99 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.1.0", 27 + "version": "0.3.2", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 33 34 "@clack/prompts": "^1.0.0", 34 35 "cmd-ts": "^0.14.3", 35 36 "glob": "^13.0.0", 36 37 "mime-types": "^2.1.35", 37 38 "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 38 40 }, 39 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 40 43 "@types/mime-types": "^3.0.1", 41 44 "@types/node": "^20", 42 45 }, ··· 44 47 "typescript": "^5", 45 48 }, 46 49 }, 50 + "packages/ui": { 51 + "name": "sequoia-ui", 52 + "version": "0.1.0", 53 + "devDependencies": { 54 + "@biomejs/biome": "^2.3.13", 55 + "@types/node": "^20", 56 + }, 57 + "peerDependencies": { 58 + "typescript": "^5", 59 + }, 60 + }, 47 61 }, 48 62 "packages": { 49 63 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 50 64 65 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "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" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 66 + 67 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 68 + 69 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 70 + 71 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "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" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 72 + 73 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], 74 + 75 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 76 + 77 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 78 + 79 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 80 + 81 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 82 + 51 83 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 52 84 53 85 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 54 86 87 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 88 + 89 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 90 + 91 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 92 + 93 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 94 + 55 95 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 56 96 57 97 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 58 98 59 99 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 100 + 101 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "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.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 102 + 103 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "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.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 104 + 105 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 60 106 61 107 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 62 108 ··· 104 150 105 151 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 106 152 153 + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], 154 + 155 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 156 + 157 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 158 + 159 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 160 + 161 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 162 + 163 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 164 + 165 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 166 + 167 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 168 + 169 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 170 + 107 171 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 108 172 109 173 "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], ··· 596 660 597 661 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 598 662 663 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 664 + 599 665 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 600 666 601 667 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 643 709 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 644 710 645 711 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 712 + 713 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 646 714 647 715 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 648 716 ··· 742 810 743 811 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 744 812 813 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 814 + 815 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 816 + 817 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 818 + 745 819 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 746 820 747 821 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 902 976 903 977 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 904 978 979 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 980 + 905 981 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 906 982 907 983 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 908 984 909 985 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 910 986 987 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 988 + 911 989 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 912 990 991 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 992 + 993 + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], 994 + 913 995 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 914 996 915 997 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 918 1000 919 1001 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 920 1002 1003 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 1004 + 921 1005 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 922 1006 923 1007 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 925 1009 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 926 1010 927 1011 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1012 + 1013 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 928 1014 929 1015 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 930 1016 ··· 1148 1234 1149 1235 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1150 1236 1237 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1238 + 1151 1239 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1152 1240 1153 1241 "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], ··· 1189 1277 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1190 1278 1191 1279 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1280 + 1281 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1192 1282 1193 1283 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1194 1284 ··· 1263 1353 "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], 1264 1354 1265 1355 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1356 + 1357 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1266 1358 1267 1359 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1268 1360 ··· 1278 1370 1279 1371 "sequoia-cli": ["sequoia-cli@workspace:packages/cli"], 1280 1372 1373 + "sequoia-ui": ["sequoia-ui@workspace:packages/ui"], 1374 + 1281 1375 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], 1282 1376 1283 1377 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], ··· 1356 1450 1357 1451 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1358 1452 1453 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1454 + 1359 1455 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1360 1456 1361 1457 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1425 1521 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1426 1522 1427 1523 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1524 + 1525 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1428 1526 1429 1527 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1430 1528
+16 -1
docs/docs/pages/blog/introducing-sequoia.mdx
··· 24 24 25 25 It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 26 27 - <iframe width="560" height="315" src="https://www.youtube.com/embed/sxursUHq5kw?si=aZSCmkMdYPiYns8u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> 27 + <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 + src="https://www.youtube.com/embed/sxursUHq5kw" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe> 28 37 29 38 ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 30 39 ··· 43 52 bun i -g sequoia-cli 44 53 ``` 45 54 ::: 55 + 56 + <script type="module" src="/sequoia-comments.js"></script> 57 + <sequoia-comments 58 + document-uri="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v" 59 + depth="2" 60 + ></sequoia-comments>
+52 -1
docs/docs/pages/cli-reference.mdx
··· 1 1 # CLI Reference 2 2 3 + ## `login` 4 + 5 + ```bash [Terminal] 6 + sequoia login 7 + > Login with OAuth (browser-based authentication) 8 + 9 + OPTIONS: 10 + --logout <str> - Remove OAuth session for a specific DID [optional] 11 + 12 + FLAGS: 13 + --list - List all stored OAuth sessions [optional] 14 + --help, -h - show help [optional] 15 + ``` 16 + 17 + OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically. 18 + 3 19 ## `auth` 4 20 5 21 ```bash [Terminal] 6 22 sequoia auth 7 - > Authenticate with your ATProto PDS 23 + > Authenticate with your ATProto PDS using an app password 8 24 9 25 OPTIONS: 10 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 14 30 --help, -h - show help [optional] 15 31 ``` 16 32 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 + 35 + ## `add` 36 + 37 + ```bash [Terminal] 38 + sequoia add <component> 39 + > Add a UI component to your project 40 + 41 + ARGUMENTS: 42 + component - The name of the component to add 43 + 44 + FLAGS: 45 + --help, -h - show help [optional] 46 + ``` 47 + 48 + Available components: 49 + - `sequoia-comments` - Display Bluesky replies as comments on your blog posts 50 + 51 + The component will be installed to the directory specified in `ui.components` (default: `src/components`). See the [Comments guide](/comments) for usage details. 52 + 17 53 ## `init` 18 54 19 55 ```bash [Terminal] ··· 61 97 --dry-run, -n - Preview what would be synced without making changes [optional] 62 98 --help, -h - show help [optional] 63 99 ``` 100 + 101 + ## `update` 102 + 103 + ```bash [Terminal] 104 + sequoia update 105 + > Update local config or ATProto publication record 106 + 107 + FLAGS: 108 + --help, -h - show help [optional] 109 + ``` 110 + 111 + Interactive command to modify your existing configuration. Choose between: 112 + 113 + - **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings 114 + - **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+179
docs/docs/pages/comments.mdx
··· 1 + # Comments 2 + 3 + Sequoia has a small UI trick up its sleeve that lets you easily display comments on your blog posts through Bluesky posts. This is the general flow: 4 + 5 + 1. Setup your blog with `sequoia init`, and when prompted at the end to enable BlueSky posts, select `yes`. 6 + 2. When you run `sequoia publish` the CLI will publish a BlueSky post and link it to your `site.standard.document` record for your post. 7 + 3. As people reply to the BlueSky post, the replies can be rendered as comments below your post using the Sequoia UI web component. 8 + 9 + ## Setup 10 + 11 + Run the following command in your project to install the comments web component. It will ask you where you would like to store the component file. 12 + 13 + ```bash [Terminal] 14 + sequoia add sequoia-comments 15 + ``` 16 + 17 + The web component will look for the `<link rel="site.standard.document" href="atUri"/>` in your HTML head, then using the `atUri` fetch the post and the replies. 18 + 19 + ::::tip 20 + For more information on the `<link>` tags, check out the [verification guide](/verifying) 21 + :::: 22 + 23 + ## Usage 24 + 25 + Since `sequoia-comments` is a standard Web Component, it works with any framework. Choose your setup below: 26 + 27 + :::code-group 28 + 29 + ```html [HTML] 30 + <body> 31 + <h1>Blog Post Title</h1> 32 + <!--Content--> 33 + <h2>Comments</h2> 34 + 35 + <sequoia-comments></sequoia-comments> 36 + <script type="module" src="./src/components/sequoia-comments.js"></script> 37 + </body> 38 + ``` 39 + 40 + ```tsx [React] 41 + // Import the component (registers the custom element) 42 + import './components/sequoia-comments.js'; 43 + 44 + function BlogPost() { 45 + return ( 46 + <article> 47 + <h1>Blog Post Title</h1> 48 + {/* Content */} 49 + <h2>Comments</h2> 50 + <sequoia-comments /> 51 + </article> 52 + ); 53 + } 54 + ``` 55 + 56 + ```vue [Vue] 57 + <script setup> 58 + import './components/sequoia-comments.js'; 59 + </script> 60 + 61 + <template> 62 + <article> 63 + <h1>Blog Post Title</h1> 64 + <!-- Content --> 65 + <h2>Comments</h2> 66 + <sequoia-comments /> 67 + </article> 68 + </template> 69 + ``` 70 + 71 + ```svelte [Svelte] 72 + <script> 73 + import './components/sequoia-comments.js'; 74 + </script> 75 + 76 + <article> 77 + <h1>Blog Post Title</h1> 78 + <!-- Content --> 79 + <h2>Comments</h2> 80 + <sequoia-comments /> 81 + </article> 82 + ``` 83 + 84 + ```astro [Astro] 85 + <article> 86 + <h1>Blog Post Title</h1> 87 + <!-- Content --> 88 + <h2>Comments</h2> 89 + <sequoia-comments /> 90 + <script> 91 + import './components/sequoia-comments.js'; 92 + </script> 93 + </article> 94 + ``` 95 + 96 + ::: 97 + 98 + ### TypeScript Support 99 + 100 + If you're using TypeScript with React, add this type declaration to avoid JSX errors: 101 + 102 + ```ts [custom-elements.d.ts] 103 + declare namespace JSX { 104 + interface IntrinsicElements { 105 + 'sequoia-comments': React.DetailedHTMLProps< 106 + React.HTMLAttributes<HTMLElement> & { 107 + 'document-uri'?: string; 108 + depth?: string | number; 109 + }, 110 + HTMLElement 111 + >; 112 + } 113 + } 114 + ``` 115 + 116 + ### Vue Configuration 117 + 118 + For Vue, you may need to configure the compiler to recognize custom elements: 119 + 120 + ```ts [vite.config.ts] 121 + export default defineConfig({ 122 + plugins: [ 123 + vue({ 124 + template: { 125 + compilerOptions: { 126 + isCustomElement: (tag) => tag === 'sequoia-comments' 127 + } 128 + } 129 + }) 130 + ] 131 + }); 132 + ``` 133 + 134 + ## Configuration 135 + 136 + The comments web component has several configuration options available. 137 + 138 + ### Attributes 139 + 140 + The `<sequoia-comments>` component accepts the following attributes: 141 + 142 + | Attribute | Type | Default | Description | 143 + |-----------|------|---------|-------------| 144 + | `document-uri` | `string` | - | AT Protocol URI for the document. Optional if a `<link rel="site.standard.document">` tag exists in the page head. | 145 + | `depth` | `number` | `6` | Maximum depth of nested replies to fetch. | 146 + 147 + ```html 148 + <!-- Use attributes for explicit control --> 149 + <sequoia-comments 150 + document-uri="at://did:plc:example/site.standard.document/abc123" 151 + depth="10"> 152 + </sequoia-comments> 153 + ``` 154 + 155 + ### Styling 156 + 157 + The component uses CSS custom properties for theming. Set these in your `:root` or parent element to customize the appearance: 158 + 159 + | CSS Property | Default | Description | 160 + |--------------|---------|-------------| 161 + | `--sequoia-fg-color` | `#1f2937` | Text color | 162 + | `--sequoia-bg-color` | `#ffffff` | Background color | 163 + | `--sequoia-border-color` | `#e5e7eb` | Border color | 164 + | `--sequoia-accent-color` | `#2563eb` | Accent/link color | 165 + | `--sequoia-secondary-color` | `#6b7280` | Secondary text color (handles, timestamps) | 166 + | `--sequoia-border-radius` | `8px` | Border radius for cards and buttons | 167 + 168 + ### Example: Dark Theme 169 + 170 + ```css 171 + :root { 172 + --sequoia-accent-color: #3A5A40; 173 + --sequoia-border-radius: 12px; 174 + --sequoia-bg-color: #1a1a1a; 175 + --sequoia-fg-color: #F5F3EF; 176 + --sequoia-border-color: #333; 177 + --sequoia-secondary-color: #8B7355; 178 + } 179 + ```
+46 -2
docs/docs/pages/config.mdx
··· 14 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 15 | `identity` | `string` | No | - | Which stored identity to use | 16 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 17 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 + | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 + | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) | 23 + | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 24 + | `ui` | `object` | No | - | UI components configuration | 25 + | `ui.components` | `string` | No | `"src/components"` | Directory where UI components are installed | 18 26 19 27 ### Example 20 28 ··· 31 39 "frontmatter": { 32 40 "publishDate": "date" 33 41 }, 34 - "ignore": ["_index.md"] 42 + "ignore": ["_index.md"], 43 + "bluesky": { 44 + "enabled": true, 45 + "maxAgeDays": 30 46 + }, 47 + "ui": { 48 + "components": "src/components" 49 + } 35 50 } 36 51 ``` 37 52 ··· 44 59 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 45 60 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 46 61 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 62 + | `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish | 47 63 48 64 ### Example 49 65 ··· 54 70 publishDate: 2024-01-15 55 71 ogImage: cover.jpg 56 72 tags: [welcome, intro] 73 + draft: false 57 74 --- 58 75 ``` 59 76 ··· 65 82 { 66 83 "frontmatter": { 67 84 "publishDate": "date", 68 - "coverImage": "thumbnail" 85 + "coverImage": "thumbnail", 86 + "draft": "private" 69 87 } 70 88 } 71 89 ``` 90 + 91 + ### Slug Configuration 92 + 93 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 94 + 95 + ```json 96 + { 97 + "frontmatter": { 98 + "slugField": "url" 99 + } 100 + } 101 + ``` 102 + 103 + If the frontmatter field is not found, it falls back to the filepath. 104 + 105 + ### Jekyll-Style Date Prefixes 106 + 107 + Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs: 108 + 109 + ```json 110 + { 111 + "stripDatePrefix": true 112 + } 113 + ``` 114 + 115 + This transforms `2024-01-15-my-post.md` into the slug `my-post`. 72 116 73 117 ### Ignoring Files 74 118
+45 -1
docs/docs/pages/publishing.mdx
··· 10 10 sequoia publish --dry-run 11 11 ``` 12 12 13 - This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it! 13 + This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it! 14 14 15 15 ```bash [Terminal] 16 16 sequoia publish ··· 27 27 ``` 28 28 29 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 + 31 + ## Bluesky Posting 32 + 33 + Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config: 34 + 35 + ```json 36 + { 37 + "bluesky": { 38 + "enabled": true, 39 + "maxAgeDays": 30 40 + } 41 + } 42 + ``` 43 + 44 + When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters. 45 + 46 + The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky. 47 + 48 + ## Draft Posts 49 + 50 + Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it. 51 + 52 + ```yaml 53 + --- 54 + title: Work in Progress 55 + draft: true 56 + --- 57 + ``` 58 + 59 + If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`: 60 + 61 + ```json 62 + { 63 + "frontmatter": { 64 + "draft": "private" 65 + } 66 + } 67 + ``` 68 + 69 + ## Comments 70 + 71 + When Bluesky posting is enabled, Sequoia links each published document to its corresponding Bluesky post. This enables comments on your blog posts through Bluesky replies. 72 + 73 + To display comments on your site, use the `sequoia-comments` web component. See the [Comments guide](/comments) for setup instructions. 30 74 31 75 ## Troubleshooting 32 76
+9 -7
docs/docs/pages/quickstart.mdx
··· 31 31 sequoia 32 32 ``` 33 33 34 - ### Authorize 35 - 36 - In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password. 34 + ### Login 37 35 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 41 37 42 38 ```bash [Terminal] 43 - sequoia auth 39 + sequoia login 44 40 ``` 41 + 42 + This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI. 43 + 44 + :::tip 45 + Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth. 46 + ::: 45 47 46 48 ### Initialize 47 49
docs/docs/public/icon-dark.png

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

+856
docs/docs/public/sequoia-comments.js
··· 1 + /** 2 + * Sequoia Comments - A Bluesky-powered comments component 3 + * 4 + * A self-contained Web Component that displays comments from Bluesky posts 5 + * linked to documents via the AT Protocol. 6 + * 7 + * Usage: 8 + * <sequoia-comments></sequoia-comments> 9 + * 10 + * The component looks for a document URI in two places: 11 + * 1. The `document-uri` attribute on the element 12 + * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head 13 + * 14 + * Attributes: 15 + * - document-uri: AT Protocol URI for the document (optional if link tag exists) 16 + * - depth: Maximum depth of nested replies to fetch (default: 6) 17 + * 18 + * CSS Custom Properties: 19 + * - --sequoia-fg-color: Text color (default: #1f2937) 20 + * - --sequoia-bg-color: Background color (default: #ffffff) 21 + * - --sequoia-border-color: Border color (default: #e5e7eb) 22 + * - --sequoia-accent-color: Accent/link color (default: #2563eb) 23 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 24 + * - --sequoia-border-radius: Border radius (default: 8px) 25 + */ 26 + 27 + // ============================================================================ 28 + // Styles 29 + // ============================================================================ 30 + 31 + const styles = ` 32 + :host { 33 + display: block; 34 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 35 + color: var(--sequoia-fg-color, #1f2937); 36 + line-height: 1.5; 37 + } 38 + 39 + * { 40 + box-sizing: border-box; 41 + } 42 + 43 + .sequoia-comments-container { 44 + max-width: 100%; 45 + } 46 + 47 + .sequoia-loading, 48 + .sequoia-error, 49 + .sequoia-empty, 50 + .sequoia-warning { 51 + padding: 1rem; 52 + border-radius: var(--sequoia-border-radius, 8px); 53 + text-align: center; 54 + } 55 + 56 + .sequoia-loading { 57 + background: var(--sequoia-bg-color, #ffffff); 58 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 59 + color: var(--sequoia-secondary-color, #6b7280); 60 + } 61 + 62 + .sequoia-loading-spinner { 63 + display: inline-block; 64 + width: 1.25rem; 65 + height: 1.25rem; 66 + border: 2px solid var(--sequoia-border-color, #e5e7eb); 67 + border-top-color: var(--sequoia-accent-color, #2563eb); 68 + border-radius: 50%; 69 + animation: sequoia-spin 0.8s linear infinite; 70 + margin-right: 0.5rem; 71 + vertical-align: middle; 72 + } 73 + 74 + @keyframes sequoia-spin { 75 + to { transform: rotate(360deg); } 76 + } 77 + 78 + .sequoia-error { 79 + background: #fef2f2; 80 + border: 1px solid #fecaca; 81 + color: #dc2626; 82 + } 83 + 84 + .sequoia-warning { 85 + background: #fffbeb; 86 + border: 1px solid #fde68a; 87 + color: #d97706; 88 + } 89 + 90 + .sequoia-empty { 91 + background: var(--sequoia-bg-color, #ffffff); 92 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 93 + color: var(--sequoia-secondary-color, #6b7280); 94 + } 95 + 96 + .sequoia-comments-header { 97 + display: flex; 98 + justify-content: space-between; 99 + align-items: center; 100 + margin-bottom: 1rem; 101 + padding-bottom: 0.75rem; 102 + } 103 + 104 + .sequoia-comments-title { 105 + font-size: 1.125rem; 106 + font-weight: 600; 107 + margin: 0; 108 + } 109 + 110 + .sequoia-reply-button { 111 + display: inline-flex; 112 + align-items: center; 113 + gap: 0.375rem; 114 + padding: 0.5rem 1rem; 115 + background: var(--sequoia-accent-color, #2563eb); 116 + color: #ffffff; 117 + border: none; 118 + border-radius: var(--sequoia-border-radius, 8px); 119 + font-size: 0.875rem; 120 + font-weight: 500; 121 + cursor: pointer; 122 + text-decoration: none; 123 + transition: background-color 0.15s ease; 124 + } 125 + 126 + .sequoia-reply-button:hover { 127 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 128 + } 129 + 130 + .sequoia-reply-button svg { 131 + width: 1rem; 132 + height: 1rem; 133 + } 134 + 135 + .sequoia-comments-list { 136 + display: flex; 137 + flex-direction: column; 138 + } 139 + 140 + .sequoia-thread { 141 + border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 142 + padding-bottom: 1rem; 143 + } 144 + 145 + .sequoia-thread + .sequoia-thread { 146 + margin-top: 0.5rem; 147 + } 148 + 149 + .sequoia-thread:last-child { 150 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 151 + } 152 + 153 + .sequoia-comment { 154 + display: flex; 155 + gap: 0.75rem; 156 + padding-top: 1rem; 157 + } 158 + 159 + .sequoia-comment-avatar-column { 160 + display: flex; 161 + flex-direction: column; 162 + align-items: center; 163 + flex-shrink: 0; 164 + width: 2.5rem; 165 + position: relative; 166 + } 167 + 168 + .sequoia-comment-avatar { 169 + width: 2.5rem; 170 + height: 2.5rem; 171 + border-radius: 50%; 172 + background: var(--sequoia-border-color, #e5e7eb); 173 + object-fit: cover; 174 + flex-shrink: 0; 175 + position: relative; 176 + z-index: 1; 177 + } 178 + 179 + .sequoia-comment-avatar-placeholder { 180 + width: 2.5rem; 181 + height: 2.5rem; 182 + border-radius: 50%; 183 + background: var(--sequoia-border-color, #e5e7eb); 184 + display: flex; 185 + align-items: center; 186 + justify-content: center; 187 + flex-shrink: 0; 188 + color: var(--sequoia-secondary-color, #6b7280); 189 + font-weight: 600; 190 + font-size: 1rem; 191 + position: relative; 192 + z-index: 1; 193 + } 194 + 195 + .sequoia-thread-line { 196 + position: absolute; 197 + top: 2.5rem; 198 + bottom: calc(-1rem - 0.5rem); 199 + left: 50%; 200 + transform: translateX(-50%); 201 + width: 2px; 202 + background: var(--sequoia-border-color, #e5e7eb); 203 + } 204 + 205 + .sequoia-comment-content { 206 + flex: 1; 207 + min-width: 0; 208 + } 209 + 210 + .sequoia-comment-header { 211 + display: flex; 212 + align-items: baseline; 213 + gap: 0.5rem; 214 + margin-bottom: 0.25rem; 215 + flex-wrap: wrap; 216 + } 217 + 218 + .sequoia-comment-author { 219 + font-weight: 600; 220 + color: var(--sequoia-fg-color, #1f2937); 221 + text-decoration: none; 222 + overflow: hidden; 223 + text-overflow: ellipsis; 224 + white-space: nowrap; 225 + } 226 + 227 + .sequoia-comment-author:hover { 228 + color: var(--sequoia-accent-color, #2563eb); 229 + } 230 + 231 + .sequoia-comment-handle { 232 + font-size: 0.875rem; 233 + color: var(--sequoia-secondary-color, #6b7280); 234 + overflow: hidden; 235 + text-overflow: ellipsis; 236 + white-space: nowrap; 237 + } 238 + 239 + .sequoia-comment-time { 240 + font-size: 0.875rem; 241 + color: var(--sequoia-secondary-color, #6b7280); 242 + flex-shrink: 0; 243 + } 244 + 245 + .sequoia-comment-time::before { 246 + content: "ยท"; 247 + margin-right: 0.5rem; 248 + } 249 + 250 + .sequoia-comment-text { 251 + margin: 0; 252 + white-space: pre-wrap; 253 + word-wrap: break-word; 254 + } 255 + 256 + .sequoia-comment-text a { 257 + color: var(--sequoia-accent-color, #2563eb); 258 + text-decoration: none; 259 + } 260 + 261 + .sequoia-comment-text a:hover { 262 + text-decoration: underline; 263 + } 264 + 265 + .sequoia-bsky-logo { 266 + width: 1rem; 267 + height: 1rem; 268 + } 269 + `; 270 + 271 + // ============================================================================ 272 + // Utility Functions 273 + // ============================================================================ 274 + 275 + /** 276 + * Format a relative time string (e.g., "2 hours ago") 277 + * @param {string} dateString - ISO date string 278 + * @returns {string} Formatted relative time 279 + */ 280 + function formatRelativeTime(dateString) { 281 + const date = new Date(dateString); 282 + const now = new Date(); 283 + const diffMs = now.getTime() - date.getTime(); 284 + const diffSeconds = Math.floor(diffMs / 1000); 285 + const diffMinutes = Math.floor(diffSeconds / 60); 286 + const diffHours = Math.floor(diffMinutes / 60); 287 + const diffDays = Math.floor(diffHours / 24); 288 + const diffWeeks = Math.floor(diffDays / 7); 289 + const diffMonths = Math.floor(diffDays / 30); 290 + const diffYears = Math.floor(diffDays / 365); 291 + 292 + if (diffSeconds < 60) { 293 + return "just now"; 294 + } 295 + if (diffMinutes < 60) { 296 + return `${diffMinutes}m ago`; 297 + } 298 + if (diffHours < 24) { 299 + return `${diffHours}h ago`; 300 + } 301 + if (diffDays < 7) { 302 + return `${diffDays}d ago`; 303 + } 304 + if (diffWeeks < 4) { 305 + return `${diffWeeks}w ago`; 306 + } 307 + if (diffMonths < 12) { 308 + return `${diffMonths}mo ago`; 309 + } 310 + return `${diffYears}y ago`; 311 + } 312 + 313 + /** 314 + * Escape HTML special characters 315 + * @param {string} text - Text to escape 316 + * @returns {string} Escaped HTML 317 + */ 318 + function escapeHtml(text) { 319 + const div = document.createElement("div"); 320 + div.textContent = text; 321 + return div.innerHTML; 322 + } 323 + 324 + /** 325 + * Convert post text with facets to HTML 326 + * @param {string} text - Post text 327 + * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 328 + * @returns {string} HTML string with links 329 + */ 330 + function renderTextWithFacets(text, facets) { 331 + if (!facets || facets.length === 0) { 332 + return escapeHtml(text); 333 + } 334 + 335 + // Convert text to bytes for proper indexing 336 + const encoder = new TextEncoder(); 337 + const decoder = new TextDecoder(); 338 + const textBytes = encoder.encode(text); 339 + 340 + // Sort facets by start index 341 + const sortedFacets = [...facets].sort( 342 + (a, b) => a.index.byteStart - b.index.byteStart, 343 + ); 344 + 345 + let result = ""; 346 + let lastEnd = 0; 347 + 348 + for (const facet of sortedFacets) { 349 + const { byteStart, byteEnd } = facet.index; 350 + 351 + // Add text before this facet 352 + if (byteStart > lastEnd) { 353 + const beforeBytes = textBytes.slice(lastEnd, byteStart); 354 + result += escapeHtml(decoder.decode(beforeBytes)); 355 + } 356 + 357 + // Get the facet text 358 + const facetBytes = textBytes.slice(byteStart, byteEnd); 359 + const facetText = decoder.decode(facetBytes); 360 + 361 + // Find the first renderable feature 362 + const feature = facet.features[0]; 363 + if (feature) { 364 + if (feature.$type === "app.bsky.richtext.facet#link") { 365 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 366 + } else if (feature.$type === "app.bsky.richtext.facet#mention") { 367 + result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 368 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 369 + result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 370 + } else { 371 + result += escapeHtml(facetText); 372 + } 373 + } else { 374 + result += escapeHtml(facetText); 375 + } 376 + 377 + lastEnd = byteEnd; 378 + } 379 + 380 + // Add remaining text 381 + if (lastEnd < textBytes.length) { 382 + const remainingBytes = textBytes.slice(lastEnd); 383 + result += escapeHtml(decoder.decode(remainingBytes)); 384 + } 385 + 386 + return result; 387 + } 388 + 389 + /** 390 + * Get initials from a name for avatar placeholder 391 + * @param {string} name - Display name 392 + * @returns {string} Initials (1-2 characters) 393 + */ 394 + function getInitials(name) { 395 + const parts = name.trim().split(/\s+/); 396 + if (parts.length >= 2) { 397 + return (parts[0][0] + parts[1][0]).toUpperCase(); 398 + } 399 + return name.substring(0, 2).toUpperCase(); 400 + } 401 + 402 + // ============================================================================ 403 + // AT Protocol Client Functions 404 + // ============================================================================ 405 + 406 + /** 407 + * Parse an AT URI into its components 408 + * Format: at://did/collection/rkey 409 + * @param {string} atUri - AT Protocol URI 410 + * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 411 + */ 412 + function parseAtUri(atUri) { 413 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 414 + if (!match) return null; 415 + return { 416 + did: match[1], 417 + collection: match[2], 418 + rkey: match[3], 419 + }; 420 + } 421 + 422 + /** 423 + * Resolve a DID to its PDS URL 424 + * Supports did:plc and did:web methods 425 + * @param {string} did - Decentralized Identifier 426 + * @returns {Promise<string>} PDS URL 427 + */ 428 + async function resolvePDS(did) { 429 + let pdsUrl; 430 + 431 + if (did.startsWith("did:plc:")) { 432 + // Fetch DID document from plc.directory 433 + const didDocUrl = `https://plc.directory/${did}`; 434 + const didDocResponse = await fetch(didDocUrl); 435 + if (!didDocResponse.ok) { 436 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 437 + } 438 + const didDoc = await didDocResponse.json(); 439 + 440 + // Find the PDS service endpoint 441 + const pdsService = didDoc.service?.find( 442 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 443 + ); 444 + pdsUrl = pdsService?.serviceEndpoint; 445 + } else if (did.startsWith("did:web:")) { 446 + // For did:web, fetch the DID document from the domain 447 + const domain = did.replace("did:web:", ""); 448 + const didDocUrl = `https://${domain}/.well-known/did.json`; 449 + const didDocResponse = await fetch(didDocUrl); 450 + if (!didDocResponse.ok) { 451 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 452 + } 453 + const didDoc = await didDocResponse.json(); 454 + 455 + const pdsService = didDoc.service?.find( 456 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 457 + ); 458 + pdsUrl = pdsService?.serviceEndpoint; 459 + } else { 460 + throw new Error(`Unsupported DID method: ${did}`); 461 + } 462 + 463 + if (!pdsUrl) { 464 + throw new Error("Could not find PDS URL for user"); 465 + } 466 + 467 + return pdsUrl; 468 + } 469 + 470 + /** 471 + * Fetch a record from a PDS using the public API 472 + * @param {string} did - DID of the repository owner 473 + * @param {string} collection - Collection name 474 + * @param {string} rkey - Record key 475 + * @returns {Promise<any>} Record value 476 + */ 477 + async function getRecord(did, collection, rkey) { 478 + const pdsUrl = await resolvePDS(did); 479 + 480 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 481 + url.searchParams.set("repo", did); 482 + url.searchParams.set("collection", collection); 483 + url.searchParams.set("rkey", rkey); 484 + 485 + const response = await fetch(url.toString()); 486 + if (!response.ok) { 487 + throw new Error(`Failed to fetch record: ${response.status}`); 488 + } 489 + 490 + const data = await response.json(); 491 + return data.value; 492 + } 493 + 494 + /** 495 + * Fetch a document record from its AT URI 496 + * @param {string} atUri - AT Protocol URI for the document 497 + * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record 498 + */ 499 + async function getDocument(atUri) { 500 + const parsed = parseAtUri(atUri); 501 + if (!parsed) { 502 + throw new Error(`Invalid AT URI: ${atUri}`); 503 + } 504 + 505 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 506 + } 507 + 508 + /** 509 + * Fetch a post thread from the public Bluesky API 510 + * @param {string} postUri - AT Protocol URI for the post 511 + * @param {number} [depth=6] - Maximum depth of replies to fetch 512 + * @returns {Promise<ThreadViewPost>} Thread view post 513 + */ 514 + async function getPostThread(postUri, depth = 6) { 515 + const url = new URL( 516 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 517 + ); 518 + url.searchParams.set("uri", postUri); 519 + url.searchParams.set("depth", depth.toString()); 520 + 521 + const response = await fetch(url.toString()); 522 + if (!response.ok) { 523 + throw new Error(`Failed to fetch post thread: ${response.status}`); 524 + } 525 + 526 + const data = await response.json(); 527 + 528 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 529 + throw new Error("Post not found or blocked"); 530 + } 531 + 532 + return data.thread; 533 + } 534 + 535 + /** 536 + * Build a Bluesky app URL for a post 537 + * @param {string} postUri - AT Protocol URI for the post 538 + * @returns {string} Bluesky app URL 539 + */ 540 + function buildBskyAppUrl(postUri) { 541 + const parsed = parseAtUri(postUri); 542 + if (!parsed) { 543 + throw new Error(`Invalid post URI: ${postUri}`); 544 + } 545 + 546 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 547 + } 548 + 549 + /** 550 + * Type guard for ThreadViewPost 551 + * @param {any} post - Post to check 552 + * @returns {boolean} True if post is a ThreadViewPost 553 + */ 554 + function isThreadViewPost(post) { 555 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 556 + } 557 + 558 + // ============================================================================ 559 + // Bluesky Icon 560 + // ============================================================================ 561 + 562 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 563 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 564 + </svg>`; 565 + 566 + // ============================================================================ 567 + // Web Component 568 + // ============================================================================ 569 + 570 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 571 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 572 + 573 + class SequoiaComments extends BaseElement { 574 + constructor() { 575 + super(); 576 + this.shadow = this.attachShadow({ mode: "open" }); 577 + this.state = { type: "loading" }; 578 + this.abortController = null; 579 + } 580 + 581 + static get observedAttributes() { 582 + return ["document-uri", "depth"]; 583 + } 584 + 585 + connectedCallback() { 586 + this.render(); 587 + this.loadComments(); 588 + } 589 + 590 + disconnectedCallback() { 591 + this.abortController?.abort(); 592 + } 593 + 594 + attributeChangedCallback() { 595 + if (this.isConnected) { 596 + this.loadComments(); 597 + } 598 + } 599 + 600 + get documentUri() { 601 + // First check attribute 602 + const attrUri = this.getAttribute("document-uri"); 603 + if (attrUri) { 604 + return attrUri; 605 + } 606 + 607 + // Then scan for link tag in document head 608 + const linkTag = document.querySelector( 609 + 'link[rel="site.standard.document"]', 610 + ); 611 + return linkTag?.href ?? null; 612 + } 613 + 614 + get depth() { 615 + const depthAttr = this.getAttribute("depth"); 616 + return depthAttr ? parseInt(depthAttr, 10) : 6; 617 + } 618 + 619 + async loadComments() { 620 + // Cancel any in-flight request 621 + this.abortController?.abort(); 622 + this.abortController = new AbortController(); 623 + 624 + this.state = { type: "loading" }; 625 + this.render(); 626 + 627 + const docUri = this.documentUri; 628 + if (!docUri) { 629 + this.state = { type: "no-document" }; 630 + this.render(); 631 + return; 632 + } 633 + 634 + try { 635 + // Fetch the document record 636 + const document = await getDocument(docUri); 637 + 638 + // Check if document has a Bluesky post reference 639 + if (!document.bskyPostRef) { 640 + this.state = { type: "no-comments-enabled" }; 641 + this.render(); 642 + return; 643 + } 644 + 645 + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 646 + 647 + // Fetch the post thread 648 + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 649 + 650 + // Check if there are any replies 651 + const replies = thread.replies?.filter(isThreadViewPost) ?? []; 652 + if (replies.length === 0) { 653 + this.state = { type: "empty", postUrl }; 654 + this.render(); 655 + return; 656 + } 657 + 658 + this.state = { type: "loaded", thread, postUrl }; 659 + this.render(); 660 + } catch (error) { 661 + const message = 662 + error instanceof Error ? error.message : "Failed to load comments"; 663 + this.state = { type: "error", message }; 664 + this.render(); 665 + } 666 + } 667 + 668 + render() { 669 + const styleTag = `<style>${styles}</style>`; 670 + 671 + switch (this.state.type) { 672 + case "loading": 673 + this.shadow.innerHTML = ` 674 + ${styleTag} 675 + <div class="sequoia-comments-container"> 676 + <div class="sequoia-loading"> 677 + <span class="sequoia-loading-spinner"></span> 678 + Loading comments... 679 + </div> 680 + </div> 681 + `; 682 + break; 683 + 684 + case "no-document": 685 + this.shadow.innerHTML = ` 686 + ${styleTag} 687 + <div class="sequoia-comments-container"> 688 + <div class="sequoia-warning"> 689 + No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 690 + </div> 691 + </div> 692 + `; 693 + break; 694 + 695 + case "no-comments-enabled": 696 + this.shadow.innerHTML = ` 697 + ${styleTag} 698 + <div class="sequoia-comments-container"> 699 + <div class="sequoia-empty"> 700 + Comments are not enabled for this post. 701 + </div> 702 + </div> 703 + `; 704 + break; 705 + 706 + case "empty": 707 + this.shadow.innerHTML = ` 708 + ${styleTag} 709 + <div class="sequoia-comments-container"> 710 + <div class="sequoia-comments-header"> 711 + <h3 class="sequoia-comments-title">Comments</h3> 712 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 713 + ${BLUESKY_ICON} 714 + Reply on Bluesky 715 + </a> 716 + </div> 717 + <div class="sequoia-empty"> 718 + No comments yet. Be the first to reply on Bluesky! 719 + </div> 720 + </div> 721 + `; 722 + break; 723 + 724 + case "error": 725 + this.shadow.innerHTML = ` 726 + ${styleTag} 727 + <div class="sequoia-comments-container"> 728 + <div class="sequoia-error"> 729 + Failed to load comments: ${escapeHtml(this.state.message)} 730 + </div> 731 + </div> 732 + `; 733 + break; 734 + 735 + case "loaded": { 736 + const replies = 737 + this.state.thread.replies?.filter(isThreadViewPost) ?? []; 738 + const threadsHtml = replies 739 + .map((reply) => this.renderThread(reply)) 740 + .join(""); 741 + const commentCount = this.countComments(replies); 742 + 743 + this.shadow.innerHTML = ` 744 + ${styleTag} 745 + <div class="sequoia-comments-container"> 746 + <div class="sequoia-comments-header"> 747 + <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 748 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 749 + ${BLUESKY_ICON} 750 + Reply on Bluesky 751 + </a> 752 + </div> 753 + <div class="sequoia-comments-list"> 754 + ${threadsHtml} 755 + </div> 756 + </div> 757 + `; 758 + break; 759 + } 760 + } 761 + } 762 + 763 + /** 764 + * Flatten a thread into a linear list of comments 765 + * @param {ThreadViewPost} thread - Thread to flatten 766 + * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 767 + */ 768 + flattenThread(thread) { 769 + const result = []; 770 + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 771 + 772 + result.push({ 773 + post: thread.post, 774 + hasMoreReplies: nestedReplies.length > 0, 775 + }); 776 + 777 + // Recursively flatten nested replies 778 + for (const reply of nestedReplies) { 779 + result.push(...this.flattenThread(reply)); 780 + } 781 + 782 + return result; 783 + } 784 + 785 + /** 786 + * Render a complete thread (top-level comment + all nested replies) 787 + */ 788 + renderThread(thread) { 789 + const flatComments = this.flattenThread(thread); 790 + const commentsHtml = flatComments 791 + .map((item, index) => 792 + this.renderComment(item.post, item.hasMoreReplies, index), 793 + ) 794 + .join(""); 795 + 796 + return `<div class="sequoia-thread">${commentsHtml}</div>`; 797 + } 798 + 799 + /** 800 + * Render a single comment 801 + * @param {any} post - Post data 802 + * @param {boolean} showThreadLine - Whether to show the connecting thread line 803 + * @param {number} _index - Index in the flattened thread (0 = top-level) 804 + */ 805 + renderComment(post, showThreadLine = false, _index = 0) { 806 + const author = post.author; 807 + const displayName = author.displayName || author.handle; 808 + const avatarHtml = author.avatar 809 + ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 810 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 811 + 812 + const profileUrl = `https://bsky.app/profile/${author.did}`; 813 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 814 + const timeAgo = formatRelativeTime(post.record.createdAt); 815 + const threadLineHtml = showThreadLine 816 + ? '<div class="sequoia-thread-line"></div>' 817 + : ""; 818 + 819 + return ` 820 + <div class="sequoia-comment"> 821 + <div class="sequoia-comment-avatar-column"> 822 + ${avatarHtml} 823 + ${threadLineHtml} 824 + </div> 825 + <div class="sequoia-comment-content"> 826 + <div class="sequoia-comment-header"> 827 + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 828 + ${escapeHtml(displayName)} 829 + </a> 830 + <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 831 + <span class="sequoia-comment-time">${timeAgo}</span> 832 + </div> 833 + <p class="sequoia-comment-text">${textHtml}</p> 834 + </div> 835 + </div> 836 + `; 837 + } 838 + 839 + countComments(replies) { 840 + let count = 0; 841 + for (const reply of replies) { 842 + count += 1; 843 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 844 + count += this.countComments(nested); 845 + } 846 + return count; 847 + } 848 + } 849 + 850 + // Register the custom element 851 + if (typeof customElements !== "undefined") { 852 + customElements.define("sequoia-comments", SequoiaComments); 853 + } 854 + 855 + // Export for module usage 856 + export { SequoiaComments };
+8
docs/docs/styles.css
··· 1 + :root { 2 + --sequoia-fg-color: var(--vocs-color_text); 3 + --sequoia-bg-color: var(--vocs-color_background); 4 + --sequoia-border-color: var(--vocs-color_border); 5 + --sequoia-accent-color: var(--vocs-color_link); 6 + --sequoia-secondary-color: var(--vocs-color_text3); 7 + --sequoia-border-radius: 8px; 8 + }
+18 -13
docs/sequoia.json
··· 1 1 { 2 - "siteUrl": "https://sequoia.pub", 3 - "contentDir": "docs/pages/blog", 4 - "imagesDir": "docs/public", 5 - "publicDir": "docs/public", 6 - "outputDir": "docs/dist", 7 - "pathPrefix": "/blog", 8 - "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v", 9 - "pdsUrl": "https://andromeda.social", 10 - "frontmatter": { 11 - "publishDate": "date" 12 - }, 13 - "ignore": ["index.mdx"] 14 - } 2 + "siteUrl": "https://sequoia.pub", 3 + "contentDir": "docs/pages/blog", 4 + "imagesDir": "docs/public", 5 + "publicDir": "docs/public", 6 + "outputDir": "docs/dist", 7 + "pathPrefix": "/blog", 8 + "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v", 9 + "pdsUrl": "https://andromeda.social", 10 + "frontmatter": { 11 + "publishDate": "date" 12 + }, 13 + "ignore": [ 14 + "index.mdx" 15 + ], 16 + "ui": { 17 + "components": "docs/components" 18 + } 19 + }
+1
docs/vocs.config.ts
··· 34 34 items: [ 35 35 { text: "Setup", link: "/setup" }, 36 36 { text: "Publishing", link: "/publishing" }, 37 + { text: "Comments", link: "/comments" }, 37 38 { text: "Verifying", link: "/verifying" }, 38 39 { text: "Workflows", link: "/workflows" }, 39 40 ],
+37
packages/cli/biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "includes": ["**", "!!**/dist"] 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true, 19 + "style": { 20 + "noNonNullAssertion": "off" 21 + } 22 + } 23 + }, 24 + "javascript": { 25 + "formatter": { 26 + "quoteStyle": "double" 27 + } 28 + }, 29 + "assist": { 30 + "enabled": true, 31 + "actions": { 32 + "source": { 33 + "organizeImports": "on" 34 + } 35 + } 36 + } 37 + }
+8 -3
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.1.1", 3 + "version": "0.4.0", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js" ··· 14 14 ".": "./dist/index.js" 15 15 }, 16 16 "scripts": { 17 - "build": "bun build src/index.ts --target node --outdir dist", 17 + "lint": "biome lint --write", 18 + "format": "biome format --write", 19 + "build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/", 18 20 "dev": "bun run build && bun link", 19 21 "deploy": "bun run build && bun publish" 20 22 }, 21 23 "devDependencies": { 24 + "@biomejs/biome": "^2.3.13", 22 25 "@types/mime-types": "^3.0.1", 23 26 "@types/node": "^20" 24 27 }, ··· 27 30 }, 28 31 "dependencies": { 29 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 30 34 "@clack/prompts": "^1.0.0", 31 35 "cmd-ts": "^0.14.3", 32 36 "glob": "^13.0.0", 33 37 "mime-types": "^2.1.35", 34 - "minimatch": "^10.1.1" 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0" 35 40 } 36 41 }
+157
packages/cli/src/commands/add.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { existsSync } from "node:fs"; 3 + import * as path from "node:path"; 4 + import { command, positional, string } from "cmd-ts"; 5 + import { intro, outro, text, spinner, log, note } from "@clack/prompts"; 6 + import { fileURLToPath } from "node:url"; 7 + import { dirname } from "node:path"; 8 + import { findConfig, loadConfig } from "../lib/config"; 9 + import type { PublisherConfig } from "../lib/types"; 10 + 11 + const __filename = fileURLToPath(import.meta.url); 12 + const __dirname = dirname(__filename); 13 + const COMPONENTS_DIR = path.join(__dirname, "components"); 14 + 15 + const DEFAULT_COMPONENTS_PATH = "src/components"; 16 + 17 + const AVAILABLE_COMPONENTS = ["sequoia-comments"]; 18 + 19 + export const addCommand = command({ 20 + name: "add", 21 + description: "Add a UI component to your project", 22 + args: { 23 + componentName: positional({ 24 + type: string, 25 + displayName: "component", 26 + description: "The name of the component to add", 27 + }), 28 + }, 29 + handler: async ({ componentName }) => { 30 + intro("Add Sequoia Component"); 31 + 32 + // Validate component name 33 + if (!AVAILABLE_COMPONENTS.includes(componentName)) { 34 + log.error(`Component '${componentName}' not found`); 35 + log.info("Available components:"); 36 + for (const comp of AVAILABLE_COMPONENTS) { 37 + log.info(` - ${comp}`); 38 + } 39 + process.exit(1); 40 + } 41 + 42 + // Try to load existing config 43 + const configPath = await findConfig(); 44 + let config: PublisherConfig | null = null; 45 + let componentsDir = DEFAULT_COMPONENTS_PATH; 46 + 47 + if (configPath) { 48 + try { 49 + config = await loadConfig(configPath); 50 + if (config.ui?.components) { 51 + componentsDir = config.ui.components; 52 + } 53 + } catch { 54 + // Config exists but may be incomplete - that's ok for UI components 55 + } 56 + } 57 + 58 + // If no UI config, prompt for components directory 59 + if (!config?.ui?.components) { 60 + log.info("No UI configuration found in sequoia.json"); 61 + 62 + const inputPath = await text({ 63 + message: "Where would you like to install components?", 64 + placeholder: DEFAULT_COMPONENTS_PATH, 65 + defaultValue: DEFAULT_COMPONENTS_PATH, 66 + }); 67 + 68 + if (inputPath === Symbol.for("cancel")) { 69 + outro("Cancelled"); 70 + process.exit(0); 71 + } 72 + 73 + componentsDir = inputPath as string; 74 + 75 + // Update or create config with UI settings 76 + if (configPath) { 77 + const s = spinner(); 78 + s.start("Updating sequoia.json..."); 79 + try { 80 + const configContent = await fs.readFile(configPath, "utf-8"); 81 + const existingConfig = JSON.parse(configContent); 82 + existingConfig.ui = { components: componentsDir }; 83 + await fs.writeFile( 84 + configPath, 85 + JSON.stringify(existingConfig, null, 2), 86 + "utf-8", 87 + ); 88 + s.stop("Updated sequoia.json with UI configuration"); 89 + } catch (error) { 90 + s.stop("Failed to update sequoia.json"); 91 + log.warn(`Could not update config: ${error}`); 92 + } 93 + } else { 94 + // Create minimal config just for UI 95 + const s = spinner(); 96 + s.start("Creating sequoia.json..."); 97 + const minimalConfig = { 98 + ui: { components: componentsDir }, 99 + }; 100 + await fs.writeFile( 101 + path.join(process.cwd(), "sequoia.json"), 102 + JSON.stringify(minimalConfig, null, 2), 103 + "utf-8", 104 + ); 105 + s.stop("Created sequoia.json with UI configuration"); 106 + } 107 + } 108 + 109 + // Resolve components directory 110 + const resolvedComponentsDir = path.isAbsolute(componentsDir) 111 + ? componentsDir 112 + : path.join(process.cwd(), componentsDir); 113 + 114 + // Create components directory if it doesn't exist 115 + if (!existsSync(resolvedComponentsDir)) { 116 + const s = spinner(); 117 + s.start(`Creating ${componentsDir} directory...`); 118 + await fs.mkdir(resolvedComponentsDir, { recursive: true }); 119 + s.stop(`Created ${componentsDir}`); 120 + } 121 + 122 + // Copy the component 123 + const sourceFile = path.join(COMPONENTS_DIR, `${componentName}.js`); 124 + const destFile = path.join(resolvedComponentsDir, `${componentName}.js`); 125 + 126 + if (!existsSync(sourceFile)) { 127 + log.error(`Component source file not found: ${sourceFile}`); 128 + log.info("This may be a build issue. Try reinstalling sequoia-cli."); 129 + process.exit(1); 130 + } 131 + 132 + const s = spinner(); 133 + s.start(`Installing ${componentName}...`); 134 + 135 + try { 136 + const componentCode = await fs.readFile(sourceFile, "utf-8"); 137 + await fs.writeFile(destFile, componentCode, "utf-8"); 138 + s.stop(`Installed ${componentName}`); 139 + } catch (error) { 140 + s.stop("Failed to install component"); 141 + log.error(`Error: ${error}`); 142 + process.exit(1); 143 + } 144 + 145 + // Show usage instructions 146 + note( 147 + `Add to your HTML:\n\n` + 148 + `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 149 + `<${componentName}></${componentName}>\n\n` + 150 + `The component will automatically read the document URI from:\n` + 151 + `<link rel="site.standard.document" href="at://...">`, 152 + "Usage", 153 + ); 154 + 155 + outro(`${componentName} added successfully!`); 156 + }, 157 + });
+153 -135
packages/cli/src/commands/auth.ts
··· 1 - import { command, flag, option, optional, string } from "cmd-ts"; 2 - import { note, text, password, confirm, select, spinner, log } from "@clack/prompts"; 3 1 import { AtpAgent } from "@atproto/api"; 4 2 import { 5 - saveCredentials, 6 - deleteCredentials, 7 - listCredentials, 8 - getCredentials, 9 - getCredentialsPath, 10 - } from "../lib/credentials"; 3 + confirm, 4 + log, 5 + note, 6 + password, 7 + select, 8 + spinner, 9 + text, 10 + } from "@clack/prompts"; 11 + import { command, flag, option, optional, string } from "cmd-ts"; 11 12 import { resolveHandleToPDS } from "../lib/atproto"; 13 + import { 14 + deleteCredentials, 15 + getCredentials, 16 + getCredentialsPath, 17 + listCredentials, 18 + saveCredentials, 19 + } from "../lib/credentials"; 12 20 import { exitOnCancel } from "../lib/prompts"; 13 21 14 22 export const authCommand = command({ 15 - name: "auth", 16 - description: "Authenticate with your ATProto PDS", 17 - args: { 18 - logout: option({ 19 - long: "logout", 20 - description: "Remove credentials for a specific identity (or all if only one exists)", 21 - type: optional(string), 22 - }), 23 - list: flag({ 24 - long: "list", 25 - description: "List all stored identities", 26 - }), 27 - }, 28 - handler: async ({ logout, list }) => { 29 - // List identities 30 - if (list) { 31 - const identities = await listCredentials(); 32 - if (identities.length === 0) { 33 - log.info("No stored identities"); 34 - } else { 35 - log.info("Stored identities:"); 36 - for (const id of identities) { 37 - console.log(` - ${id}`); 38 - } 39 - } 40 - return; 41 - } 23 + name: "auth", 24 + description: "Authenticate with your ATProto PDS", 25 + args: { 26 + logout: option({ 27 + long: "logout", 28 + description: 29 + "Remove credentials for a specific identity (or all if only one exists)", 30 + type: optional(string), 31 + }), 32 + list: flag({ 33 + long: "list", 34 + description: "List all stored identities", 35 + }), 36 + }, 37 + handler: async ({ logout, list }) => { 38 + // List identities 39 + if (list) { 40 + const identities = await listCredentials(); 41 + if (identities.length === 0) { 42 + log.info("No stored identities"); 43 + } else { 44 + log.info("Stored identities:"); 45 + for (const id of identities) { 46 + console.log(` - ${id}`); 47 + } 48 + } 49 + return; 50 + } 42 51 43 - // Logout 44 - if (logout !== undefined) { 45 - // If --logout was passed without a value, it will be an empty string 46 - const identifier = logout || undefined; 52 + // Logout 53 + if (logout !== undefined) { 54 + // If --logout was passed without a value, it will be an empty string 55 + const identifier = logout || undefined; 47 56 48 - if (!identifier) { 49 - // No identifier provided - show available and prompt 50 - const identities = await listCredentials(); 51 - if (identities.length === 0) { 52 - log.info("No saved credentials found"); 53 - return; 54 - } 55 - if (identities.length === 1) { 56 - const deleted = await deleteCredentials(identities[0]); 57 - if (deleted) { 58 - log.success(`Removed credentials for ${identities[0]}`); 59 - } 60 - return; 61 - } 62 - // Multiple identities - prompt 63 - const selected = exitOnCancel(await select({ 64 - message: "Select identity to remove:", 65 - options: identities.map(id => ({ value: id, label: id })), 66 - })); 67 - const deleted = await deleteCredentials(selected); 68 - if (deleted) { 69 - log.success(`Removed credentials for ${selected}`); 70 - } 71 - return; 72 - } 57 + if (!identifier) { 58 + // No identifier provided - show available and prompt 59 + const identities = await listCredentials(); 60 + if (identities.length === 0) { 61 + log.info("No saved credentials found"); 62 + return; 63 + } 64 + if (identities.length === 1) { 65 + const deleted = await deleteCredentials(identities[0]); 66 + if (deleted) { 67 + log.success(`Removed credentials for ${identities[0]}`); 68 + } 69 + return; 70 + } 71 + // Multiple identities - prompt 72 + const selected = exitOnCancel( 73 + await select({ 74 + message: "Select identity to remove:", 75 + options: identities.map((id) => ({ value: id, label: id })), 76 + }), 77 + ); 78 + const deleted = await deleteCredentials(selected); 79 + if (deleted) { 80 + log.success(`Removed credentials for ${selected}`); 81 + } 82 + return; 83 + } 73 84 74 - const deleted = await deleteCredentials(identifier); 75 - if (deleted) { 76 - log.success(`Removed credentials for ${identifier}`); 77 - } else { 78 - log.info(`No credentials found for ${identifier}`); 79 - } 80 - return; 81 - } 85 + const deleted = await deleteCredentials(identifier); 86 + if (deleted) { 87 + log.success(`Removed credentials for ${identifier}`); 88 + } else { 89 + log.info(`No credentials found for ${identifier}`); 90 + } 91 + return; 92 + } 82 93 83 - note( 84 - "To authenticate, you'll need an App Password.\n\n" + 85 - "Create one at: https://bsky.app/settings/app-passwords\n\n" + 86 - "App Passwords are safer than your main password and can be revoked.", 87 - "Authentication" 88 - ); 94 + note( 95 + "To authenticate, you'll need an App Password.\n\n" + 96 + "Create one at: https://bsky.app/settings/app-passwords\n\n" + 97 + "App Passwords are safer than your main password and can be revoked.", 98 + "Authentication", 99 + ); 89 100 90 - const identifier = exitOnCancel(await text({ 91 - message: "Handle or DID:", 92 - placeholder: "yourhandle.bsky.social", 93 - })); 101 + const identifier = exitOnCancel( 102 + await text({ 103 + message: "Handle or DID:", 104 + placeholder: "yourhandle.bsky.social", 105 + }), 106 + ); 94 107 95 - const appPassword = exitOnCancel(await password({ 96 - message: "App Password:", 97 - })); 108 + const appPassword = exitOnCancel( 109 + await password({ 110 + message: "App Password:", 111 + }), 112 + ); 98 113 99 - if (!identifier || !appPassword) { 100 - log.error("Handle and password are required"); 101 - process.exit(1); 102 - } 114 + if (!identifier || !appPassword) { 115 + log.error("Handle and password are required"); 116 + process.exit(1); 117 + } 103 118 104 - // Check if this identity already exists 105 - const existing = await getCredentials(identifier); 106 - if (existing) { 107 - const overwrite = exitOnCancel(await confirm({ 108 - message: `Credentials for ${identifier} already exist. Update?`, 109 - initialValue: false, 110 - })); 111 - if (!overwrite) { 112 - log.info("Keeping existing credentials"); 113 - return; 114 - } 115 - } 119 + // Check if this identity already exists 120 + const existing = await getCredentials(identifier); 121 + if (existing) { 122 + const overwrite = exitOnCancel( 123 + await confirm({ 124 + message: `Credentials for ${identifier} already exist. Update?`, 125 + initialValue: false, 126 + }), 127 + ); 128 + if (!overwrite) { 129 + log.info("Keeping existing credentials"); 130 + return; 131 + } 132 + } 116 133 117 - // Resolve PDS from handle 118 - const s = spinner(); 119 - s.start("Resolving PDS..."); 120 - let pdsUrl: string; 121 - try { 122 - pdsUrl = await resolveHandleToPDS(identifier); 123 - s.stop(`Found PDS: ${pdsUrl}`); 124 - } catch (error) { 125 - s.stop("Failed to resolve PDS"); 126 - log.error(`Failed to resolve PDS from handle: ${error}`); 127 - process.exit(1); 128 - } 134 + // Resolve PDS from handle 135 + const s = spinner(); 136 + s.start("Resolving PDS..."); 137 + let pdsUrl: string; 138 + try { 139 + pdsUrl = await resolveHandleToPDS(identifier); 140 + s.stop(`Found PDS: ${pdsUrl}`); 141 + } catch (error) { 142 + s.stop("Failed to resolve PDS"); 143 + log.error(`Failed to resolve PDS from handle: ${error}`); 144 + process.exit(1); 145 + } 129 146 130 - // Verify credentials 131 - s.start("Verifying credentials..."); 147 + // Verify credentials 148 + s.start("Verifying credentials..."); 132 149 133 - try { 134 - const agent = new AtpAgent({ service: pdsUrl }); 135 - await agent.login({ 136 - identifier: identifier, 137 - password: appPassword, 138 - }); 150 + try { 151 + const agent = new AtpAgent({ service: pdsUrl }); 152 + await agent.login({ 153 + identifier: identifier, 154 + password: appPassword, 155 + }); 139 156 140 - s.stop(`Logged in as ${agent.session?.handle}`); 157 + s.stop(`Logged in as ${agent.session?.handle}`); 141 158 142 - // Save credentials 143 - await saveCredentials({ 144 - pdsUrl, 145 - identifier: identifier, 146 - password: appPassword, 147 - }); 159 + // Save credentials 160 + await saveCredentials({ 161 + type: "app-password", 162 + pdsUrl, 163 + identifier: identifier, 164 + password: appPassword, 165 + }); 148 166 149 - log.success(`Credentials saved to ${getCredentialsPath()}`); 150 - } catch (error) { 151 - s.stop("Failed to login"); 152 - log.error(`Failed to login: ${error}`); 153 - process.exit(1); 154 - } 155 - }, 167 + log.success(`Credentials saved to ${getCredentialsPath()}`); 168 + } catch (error) { 169 + s.stop("Failed to login"); 170 + log.error(`Failed to login: ${error}`); 171 + process.exit(1); 172 + } 173 + }, 156 174 });
+76 -12
packages/cli/src/commands/init.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command } from "cmd-ts"; 3 3 import { 4 4 intro, ··· 11 11 log, 12 12 group, 13 13 } from "@clack/prompts"; 14 - import * as path from "path"; 14 + import * as path from "node:path"; 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 - import { loadCredentials } from "../lib/credentials"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 - import type { FrontmatterMapping } from "../lib/types"; 18 + import { selectCredential } from "../lib/credential-select"; 19 + import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 20 21 async function fileExists(filePath: string): Promise<boolean> { 21 22 try { ··· 138 139 defaultValue: "tags", 139 140 placeholder: "tags, categories, keywords, etc.", 140 141 }), 142 + draftField: () => 143 + text({ 144 + message: "Field name for draft status:", 145 + defaultValue: "draft", 146 + placeholder: "draft, private, hidden, etc.", 147 + }), 141 148 }, 142 149 { onCancel }, 143 150 ); ··· 149 156 ["publishDate", frontmatterConfig.dateField, "publishDate"], 150 157 ["coverImage", frontmatterConfig.coverField, "ogImage"], 151 158 ["tags", frontmatterConfig.tagsField, "tags"], 159 + ["draft", frontmatterConfig.draftField, "draft"], 152 160 ]; 153 161 154 162 const builtMapping = fieldMappings.reduce<FrontmatterMapping>( ··· 179 187 } 180 188 181 189 let publicationUri: string; 182 - const credentials = await loadCredentials(); 190 + let credentials = await loadCredentials(); 183 191 184 192 if (publicationChoice === "create") { 185 193 // Need credentials to create a publication 186 194 if (!credentials) { 195 + // Check if there are multiple identities - if so, prompt to select 196 + const allCredentials = await listAllCredentials(); 197 + if (allCredentials.length > 1) { 198 + credentials = await selectCredential(allCredentials); 199 + } else if (allCredentials.length === 1) { 200 + // Single credential exists but couldn't be loaded - try to load it explicitly 201 + credentials = await selectCredential(allCredentials); 202 + } else { 203 + log.error( 204 + "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 205 + ); 206 + process.exit(1); 207 + } 208 + } 209 + 210 + if (!credentials) { 187 211 log.error( 188 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 189 213 ); 190 214 process.exit(1); 191 215 } 192 216 193 217 const s = spinner(); 194 218 s.start("Connecting to ATProto..."); 195 - let agent; 219 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 196 220 try { 197 221 agent = await createAgent(credentials); 198 222 s.stop("Connected!"); 199 - } catch (error) { 223 + } catch (_error) { 200 224 s.stop("Failed to connect"); 201 225 log.error( 202 - "Failed to connect. Check your credentials with 'sequoia auth'.", 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 203 227 ); 204 228 process.exit(1); 205 229 } ··· 263 287 publicationUri = uri as string; 264 288 } 265 289 266 - // Get PDS URL from credentials (already loaded earlier) 267 - const pdsUrl = credentials?.pdsUrl; 290 + // Bluesky posting configuration 291 + const enableBluesky = await confirm({ 292 + message: "Enable automatic Bluesky posting when publishing?", 293 + initialValue: false, 294 + }); 295 + 296 + if (enableBluesky === Symbol.for("cancel")) { 297 + onCancel(); 298 + } 299 + 300 + let blueskyConfig: BlueskyConfig | undefined; 301 + if (enableBluesky) { 302 + const maxAgeDaysInput = await text({ 303 + message: "Maximum age (in days) for posts to be shared on Bluesky:", 304 + defaultValue: "7", 305 + placeholder: "7", 306 + validate: (value) => { 307 + if (!value) { 308 + return "Please enter a number"; 309 + } 310 + const num = Number.parseInt(value, 10); 311 + if (Number.isNaN(num) || num < 1) { 312 + return "Please enter a positive number"; 313 + } 314 + }, 315 + }); 316 + 317 + if (maxAgeDaysInput === Symbol.for("cancel")) { 318 + onCancel(); 319 + } 320 + 321 + const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); 322 + blueskyConfig = { 323 + enabled: true, 324 + ...(maxAgeDays !== 7 && { maxAgeDays }), 325 + }; 326 + } 327 + 328 + // Get PDS URL from credentials (only available for app-password auth) 329 + const pdsUrl = 330 + credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 268 331 269 332 // Generate config file 270 333 const configContent = generateConfigTemplate({ ··· 277 340 publicationUri, 278 341 pdsUrl, 279 342 frontmatter: frontmatterMapping, 343 + bluesky: blueskyConfig, 280 344 }); 281 345 282 346 const configPath = path.join(process.cwd(), "sequoia.json"); ··· 308 372 if (!gitignoreContent.includes(stateFilename)) { 309 373 await fs.writeFile( 310 374 gitignorePath, 311 - gitignoreContent + `\n${stateFilename}\n`, 375 + `${gitignoreContent}\n${stateFilename}\n`, 312 376 ); 313 377 log.info(`Added ${stateFilename} to .gitignore`); 314 378 }
+32 -56
packages/cli/src/commands/inject.ts
··· 1 - import * as fs from "fs/promises"; 2 - import { command, flag, option, optional, string } from "cmd-ts"; 3 1 import { log } from "@clack/prompts"; 4 - import * as path from "path"; 2 + import { command, flag, option, optional, string } from "cmd-ts"; 5 3 import { glob } from "glob"; 6 - import { loadConfig, loadState, findConfig } from "../lib/config"; 4 + import * as fs from "node:fs/promises"; 5 + import * as path from "node:path"; 6 + import { findConfig, loadConfig, loadState } from "../lib/config"; 7 7 8 8 export const injectCommand = command({ 9 9 name: "inject", 10 - description: 11 - "Inject site.standard.document link tags into built HTML files", 10 + description: "Inject site.standard.document link tags into built HTML files", 12 11 args: { 13 12 outputDir: option({ 14 13 long: "output", ··· 44 43 // Load state to get atUri mappings 45 44 const state = await loadState(configDir); 46 45 47 - // Generic filenames where the slug is the parent directory, not the filename 48 - // Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc. 49 - const genericFilenames = new Set([ 50 - "+page", 51 - "index", 52 - "_index", 53 - "page", 54 - "readme", 55 - ]); 56 - 57 - // Build a map of slug/path to atUri from state 58 - const pathToAtUri = new Map<string, string>(); 46 + // Build a map of slug to atUri from state 47 + // The slug is stored in state by the publish command, using the configured slug options 48 + const slugToAtUri = new Map<string, string>(); 59 49 for (const [filePath, postState] of Object.entries(state.posts)) { 60 - if (postState.atUri) { 61 - // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 62 - let basename = path.basename(filePath, path.extname(filePath)); 63 - 64 - // If the filename is a generic convention name, use the parent directory as slug 65 - if (genericFilenames.has(basename.toLowerCase())) { 66 - // Split path and filter out route groups like (blog-article) 67 - const pathParts = filePath 68 - .split(/[/\\]/) 69 - .filter((p) => p && !(p.startsWith("(") && p.endsWith(")"))); 70 - // The slug should be the second-to-last part (last is the filename) 71 - if (pathParts.length >= 2) { 72 - const slug = pathParts[pathParts.length - 2]; 73 - if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") { 74 - basename = slug; 75 - } 76 - } 77 - } 78 - 79 - pathToAtUri.set(basename, postState.atUri); 50 + if (postState.atUri && postState.slug) { 51 + // Use the slug stored in state (computed by publish with config options) 52 + slugToAtUri.set(postState.slug, postState.atUri); 80 53 81 - // Also add variations that might match HTML file paths 82 - // e.g., /blog/my-post, /posts/my-post, my-post/index 83 - const dirName = path.basename(path.dirname(filePath)); 84 - // Skip route groups and common directory names 85 - if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) { 86 - pathToAtUri.set(`${dirName}/${basename}`, postState.atUri); 54 + // Also add the last segment for simpler matching 55 + // e.g., "other/my-other-post" -> also map "my-other-post" 56 + const lastSegment = postState.slug.split("/").pop(); 57 + if (lastSegment && lastSegment !== postState.slug) { 58 + slugToAtUri.set(lastSegment, postState.atUri); 87 59 } 60 + } else if (postState.atUri) { 61 + // Fallback for older state files without slug field 62 + // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 63 + const basename = path.basename(filePath, path.extname(filePath)); 64 + slugToAtUri.set(basename.toLowerCase(), postState.atUri); 88 65 } 89 66 } 90 67 91 - if (pathToAtUri.size === 0) { 68 + if (slugToAtUri.size === 0) { 92 69 log.warn( 93 70 "No published posts found in state. Run 'sequoia publish' first.", 94 71 ); 95 72 return; 96 73 } 97 74 98 - log.info(`Found ${pathToAtUri.size} published posts in state`); 75 + log.info(`Found ${slugToAtUri.size} slug mappings from published posts`); 99 76 100 77 // Scan for HTML files 101 78 const htmlFiles = await glob("**/*.html", { ··· 125 102 let atUri: string | undefined; 126 103 127 104 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post) 128 - atUri = pathToAtUri.get(htmlBasename); 105 + atUri = slugToAtUri.get(htmlBasename); 129 106 130 - // Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post) 107 + // Strategy 2: For index.html, try the directory path 108 + // e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift 131 109 if (!atUri && htmlBasename === "index" && htmlDir !== ".") { 132 - const slug = path.basename(htmlDir); 133 - atUri = pathToAtUri.get(slug); 110 + // Try full directory path (for nested subdirectories) 111 + atUri = slugToAtUri.get(htmlDir); 134 112 135 - // Also try parent/slug pattern 113 + // Also try just the last directory segment 136 114 if (!atUri) { 137 - const parentDir = path.dirname(htmlDir); 138 - if (parentDir !== ".") { 139 - atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`); 140 - } 115 + const lastDir = path.basename(htmlDir); 116 + atUri = slugToAtUri.get(lastDir); 141 117 } 142 118 } 143 119 144 120 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post) 145 121 if (!atUri && htmlDir !== ".") { 146 - atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`); 122 + atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`); 147 123 } 148 124 149 125 if (!atUri) {
+305
packages/cli/src/commands/login.ts
··· 1 + import * as http from "node:http"; 2 + import { log, note, select, spinner, text } from "@clack/prompts"; 3 + import { command, flag, option, optional, string } from "cmd-ts"; 4 + import { resolveHandleToDid } from "../lib/atproto"; 5 + import { 6 + getCallbackPort, 7 + getOAuthClient, 8 + getOAuthScope, 9 + } from "../lib/oauth-client"; 10 + import { 11 + deleteOAuthSession, 12 + getOAuthStorePath, 13 + listOAuthSessions, 14 + listOAuthSessionsWithHandles, 15 + setOAuthHandle, 16 + } from "../lib/oauth-store"; 17 + import { exitOnCancel } from "../lib/prompts"; 18 + 19 + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 20 + 21 + export const loginCommand = command({ 22 + name: "login", 23 + description: "Login with OAuth (browser-based authentication)", 24 + args: { 25 + logout: option({ 26 + long: "logout", 27 + description: "Remove OAuth session for a specific DID", 28 + type: optional(string), 29 + }), 30 + list: flag({ 31 + long: "list", 32 + description: "List all stored OAuth sessions", 33 + }), 34 + }, 35 + handler: async ({ logout, list }) => { 36 + // List sessions 37 + if (list) { 38 + const sessions = await listOAuthSessionsWithHandles(); 39 + if (sessions.length === 0) { 40 + log.info("No OAuth sessions stored"); 41 + } else { 42 + log.info("OAuth sessions:"); 43 + for (const { did, handle } of sessions) { 44 + console.log(` - ${handle || did} (${did})`); 45 + } 46 + } 47 + return; 48 + } 49 + 50 + // Logout 51 + if (logout !== undefined) { 52 + const did = logout || undefined; 53 + 54 + if (!did) { 55 + // No DID provided - show available and prompt 56 + const sessions = await listOAuthSessions(); 57 + if (sessions.length === 0) { 58 + log.info("No OAuth sessions found"); 59 + return; 60 + } 61 + if (sessions.length === 1) { 62 + const deleted = await deleteOAuthSession(sessions[0]!); 63 + if (deleted) { 64 + log.success(`Removed OAuth session for ${sessions[0]}`); 65 + } 66 + return; 67 + } 68 + // Multiple sessions - prompt 69 + const selected = exitOnCancel( 70 + await select({ 71 + message: "Select session to remove:", 72 + options: sessions.map((d) => ({ value: d, label: d })), 73 + }), 74 + ); 75 + const deleted = await deleteOAuthSession(selected); 76 + if (deleted) { 77 + log.success(`Removed OAuth session for ${selected}`); 78 + } 79 + return; 80 + } 81 + 82 + const deleted = await deleteOAuthSession(did); 83 + if (deleted) { 84 + log.success(`Removed OAuth session for ${did}`); 85 + } else { 86 + log.info(`No OAuth session found for ${did}`); 87 + } 88 + return; 89 + } 90 + 91 + // OAuth login flow 92 + note( 93 + "OAuth login will open your browser to authenticate.\n\n" + 94 + "This is more secure than app passwords and tokens refresh automatically.", 95 + "OAuth Login", 96 + ); 97 + 98 + const handle = exitOnCancel( 99 + await text({ 100 + message: "Handle or DID:", 101 + placeholder: "yourhandle.bsky.social", 102 + }), 103 + ); 104 + 105 + if (!handle) { 106 + log.error("Handle is required"); 107 + process.exit(1); 108 + } 109 + 110 + const s = spinner(); 111 + s.start("Resolving identity..."); 112 + 113 + let did: string; 114 + try { 115 + did = await resolveHandleToDid(handle); 116 + s.stop(`Identity resolved`); 117 + } catch (error) { 118 + s.stop("Failed to resolve identity"); 119 + if (error instanceof Error) { 120 + log.error(`Error: ${error.message}`); 121 + } else { 122 + log.error(`Error: ${error}`); 123 + } 124 + process.exit(1); 125 + } 126 + 127 + s.start("Initializing OAuth..."); 128 + 129 + try { 130 + const client = await getOAuthClient(); 131 + 132 + // Generate authorization URL using the resolved DID 133 + const authUrl = await client.authorize(did, { 134 + scope: getOAuthScope(), 135 + }); 136 + 137 + log.info(`Login URL: ${authUrl}`); 138 + 139 + s.message("Opening browser..."); 140 + 141 + // Try to open browser 142 + let browserOpened = true; 143 + try { 144 + const open = (await import("open")).default; 145 + await open(authUrl.toString()); 146 + } catch { 147 + browserOpened = false; 148 + } 149 + 150 + s.message("Waiting for authentication..."); 151 + 152 + // Show URL info 153 + if (!browserOpened) { 154 + s.stop("Could not open browser automatically"); 155 + log.warn("Please open the following URL in your browser:"); 156 + log.info(authUrl.toString()); 157 + s.start("Waiting for authentication..."); 158 + } 159 + 160 + // Start HTTP server to receive callback 161 + const result = await waitForCallback(); 162 + 163 + if (!result.success) { 164 + s.stop("Authentication failed"); 165 + log.error(result.error || "OAuth callback failed"); 166 + process.exit(1); 167 + } 168 + 169 + s.message("Completing authentication..."); 170 + 171 + // Exchange code for tokens 172 + const { session } = await client.callback( 173 + new URLSearchParams(result.params!), 174 + ); 175 + 176 + // Store the handle for friendly display 177 + // Use the original handle input (unless it was a DID) 178 + const handleToStore = handle.startsWith("did:") ? undefined : handle; 179 + if (handleToStore) { 180 + await setOAuthHandle(session.did, handleToStore); 181 + } 182 + 183 + // Try to get the handle for display (use the original handle input as fallback) 184 + const displayName = handleToStore || session.did; 185 + 186 + s.stop(`Logged in as ${displayName}`); 187 + 188 + log.success(`OAuth session saved to ${getOAuthStorePath()}`); 189 + log.info("Your session will refresh automatically when needed."); 190 + 191 + // Exit cleanly - the OAuth client may have background processes 192 + process.exit(0); 193 + } catch (error) { 194 + s.stop("OAuth login failed"); 195 + if (error instanceof Error) { 196 + log.error(`Error: ${error.message}`); 197 + } else { 198 + log.error(`Error: ${error}`); 199 + } 200 + process.exit(1); 201 + } 202 + }, 203 + }); 204 + 205 + interface CallbackResult { 206 + success: boolean; 207 + params?: Record<string, string>; 208 + error?: string; 209 + } 210 + 211 + function waitForCallback(): Promise<CallbackResult> { 212 + return new Promise((resolve) => { 213 + const port = getCallbackPort(); 214 + let timeoutId: ReturnType<typeof setTimeout> | undefined; 215 + 216 + const server = http.createServer((req, res) => { 217 + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); 218 + 219 + if (url.pathname === "/oauth/callback") { 220 + const params: Record<string, string> = {}; 221 + url.searchParams.forEach((value, key) => { 222 + params[key] = value; 223 + }); 224 + 225 + // Clear the timeout 226 + if (timeoutId) clearTimeout(timeoutId); 227 + 228 + // Check for error 229 + if (params.error) { 230 + res.writeHead(200, { "Content-Type": "text/html" }); 231 + res.end(` 232 + <html> 233 + <head> 234 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 235 + </head> 236 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 237 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 238 + <h1 style="font-weight: 400;">Authentication Failed</h1> 239 + <p>${params.error_description || params.error}</p> 240 + <p>You can close this window.</p> 241 + </body> 242 + </html> 243 + `); 244 + server.close(() => { 245 + resolve({ 246 + success: false, 247 + error: params.error_description || params.error, 248 + }); 249 + }); 250 + return; 251 + } 252 + 253 + // Success 254 + res.writeHead(200, { "Content-Type": "text/html" }); 255 + res.end(` 256 + <html> 257 + <head> 258 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 259 + </head> 260 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 261 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 262 + <h1 style="font-weight: 400;">Authentication Successful</h1> 263 + <p>You can close this window and return to the terminal.</p> 264 + </body> 265 + </html> 266 + `); 267 + server.close(() => { 268 + resolve({ success: true, params }); 269 + }); 270 + return; 271 + } 272 + 273 + // Not the callback path 274 + res.writeHead(404); 275 + res.end("Not found"); 276 + }); 277 + 278 + server.on("error", (err: NodeJS.ErrnoException) => { 279 + if (timeoutId) clearTimeout(timeoutId); 280 + if (err.code === "EADDRINUSE") { 281 + resolve({ 282 + success: false, 283 + error: `Port ${port} is already in use. Please close the application using that port and try again.`, 284 + }); 285 + } else { 286 + resolve({ 287 + success: false, 288 + error: `Server error: ${err.message}`, 289 + }); 290 + } 291 + }); 292 + 293 + server.listen(port, "127.0.0.1"); 294 + 295 + // Timeout after 5 minutes 296 + timeoutId = setTimeout(() => { 297 + server.close(() => { 298 + resolve({ 299 + success: false, 300 + error: "Timeout waiting for OAuth callback. Please try again.", 301 + }); 302 + }); 303 + }, CALLBACK_TIMEOUT_MS); 304 + }); 305 + }
+359 -199
packages/cli/src/commands/publish.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command, flag } from "cmd-ts"; 3 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 4 + import * as path from "node:path"; 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 - import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 + import { 13 + createAgent, 14 + createDocument, 15 + updateDocument, 16 + uploadImage, 17 + resolveImagePath, 18 + createBlueskyPost, 19 + addBskyPostRefToDocument, 20 + } from "../lib/atproto"; 8 21 import { 9 - scanContentDirectory, 10 - getContentHash, 11 - updateFrontmatterWithAtUri, 22 + scanContentDirectory, 23 + getContentHash, 24 + updateFrontmatterWithAtUri, 12 25 } from "../lib/markdown"; 13 - import type { BlogPost, BlobObject } from "../lib/types"; 26 + import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 14 27 import { exitOnCancel } from "../lib/prompts"; 15 28 16 29 export const publishCommand = command({ 17 - name: "publish", 18 - description: "Publish content to ATProto", 19 - args: { 20 - force: flag({ 21 - long: "force", 22 - short: "f", 23 - description: "Force publish all posts, ignoring change detection", 24 - }), 25 - dryRun: flag({ 26 - long: "dry-run", 27 - short: "n", 28 - description: "Preview what would be published without making changes", 29 - }), 30 - }, 31 - handler: async ({ force, dryRun }) => { 32 - // Load config 33 - const configPath = await findConfig(); 34 - if (!configPath) { 35 - log.error("No publisher.config.ts found. Run 'publisher init' first."); 36 - process.exit(1); 37 - } 30 + name: "publish", 31 + description: "Publish content to ATProto", 32 + args: { 33 + force: flag({ 34 + long: "force", 35 + short: "f", 36 + description: "Force publish all posts, ignoring change detection", 37 + }), 38 + dryRun: flag({ 39 + long: "dry-run", 40 + short: "n", 41 + description: "Preview what would be published without making changes", 42 + }), 43 + }, 44 + handler: async ({ force, dryRun }) => { 45 + // Load config 46 + const configPath = await findConfig(); 47 + if (!configPath) { 48 + log.error("No publisher.config.ts found. Run 'publisher init' first."); 49 + process.exit(1); 50 + } 38 51 39 - const config = await loadConfig(configPath); 40 - const configDir = path.dirname(configPath); 52 + const config = await loadConfig(configPath); 53 + const configDir = path.dirname(configPath); 41 54 42 - log.info(`Site: ${config.siteUrl}`); 43 - log.info(`Content directory: ${config.contentDir}`); 55 + log.info(`Site: ${config.siteUrl}`); 56 + log.info(`Content directory: ${config.contentDir}`); 44 57 45 - // Load credentials 46 - let credentials = await loadCredentials(config.identity); 58 + // Load credentials 59 + let credentials = await loadCredentials(config.identity); 47 60 48 - // If no credentials resolved, check if we need to prompt for identity selection 49 - if (!credentials) { 50 - const identities = await listCredentials(); 51 - if (identities.length === 0) { 52 - log.error("No credentials found. Run 'sequoia auth' first."); 53 - log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables."); 54 - process.exit(1); 55 - } 61 + // If no credentials resolved, check if we need to prompt for identity selection 62 + if (!credentials) { 63 + const identities = await listAllCredentials(); 64 + if (identities.length === 0) { 65 + log.error( 66 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 67 + ); 68 + log.info( 69 + "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 70 + ); 71 + process.exit(1); 72 + } 56 73 57 - // Multiple identities exist but none selected - prompt user 58 - log.info("Multiple identities found. Select one to use:"); 59 - const selected = exitOnCancel(await select({ 60 - message: "Identity:", 61 - options: identities.map(id => ({ value: id, label: id })), 62 - })); 74 + // Build labels with handles for OAuth sessions 75 + const options = await Promise.all( 76 + identities.map(async (cred) => { 77 + if (cred.type === "oauth") { 78 + const handle = await getOAuthHandle(cred.id); 79 + return { 80 + value: cred.id, 81 + label: `${handle || cred.id} (OAuth)`, 82 + }; 83 + } 84 + return { 85 + value: cred.id, 86 + label: `${cred.id} (App Password)`, 87 + }; 88 + }), 89 + ); 63 90 64 - credentials = await getCredentials(selected); 65 - if (!credentials) { 66 - log.error("Failed to load selected credentials."); 67 - process.exit(1); 68 - } 91 + // Multiple identities exist but none selected - prompt user 92 + log.info("Multiple identities found. Select one to use:"); 93 + const selected = exitOnCancel( 94 + await select({ 95 + message: "Identity:", 96 + options, 97 + }), 98 + ); 69 99 70 - log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`); 71 - } 100 + // Load the selected credentials 101 + const selectedCred = identities.find((c) => c.id === selected); 102 + if (selectedCred?.type === "oauth") { 103 + const session = await getOAuthSession(selected); 104 + if (session) { 105 + const handle = await getOAuthHandle(selected); 106 + credentials = { 107 + type: "oauth", 108 + did: selected, 109 + handle: handle || selected, 110 + }; 111 + } 112 + } else { 113 + credentials = await getCredentials(selected); 114 + } 72 115 73 - // Resolve content directory 74 - const contentDir = path.isAbsolute(config.contentDir) 75 - ? config.contentDir 76 - : path.join(configDir, config.contentDir); 116 + if (!credentials) { 117 + log.error("Failed to load selected credentials."); 118 + process.exit(1); 119 + } 77 120 78 - const imagesDir = config.imagesDir 79 - ? path.isAbsolute(config.imagesDir) 80 - ? config.imagesDir 81 - : path.join(configDir, config.imagesDir) 82 - : undefined; 121 + const displayId = 122 + credentials.type === "oauth" 123 + ? credentials.handle || credentials.did 124 + : credentials.identifier; 125 + log.info( 126 + `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 127 + ); 128 + } 83 129 84 - // Load state 85 - const state = await loadState(configDir); 130 + // Resolve content directory 131 + const contentDir = path.isAbsolute(config.contentDir) 132 + ? config.contentDir 133 + : path.join(configDir, config.contentDir); 86 134 87 - // Scan for posts 88 - const s = spinner(); 89 - s.start("Scanning for posts..."); 90 - const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore); 91 - s.stop(`Found ${posts.length} posts`); 135 + const imagesDir = config.imagesDir 136 + ? path.isAbsolute(config.imagesDir) 137 + ? config.imagesDir 138 + : path.join(configDir, config.imagesDir) 139 + : undefined; 92 140 93 - // Determine which posts need publishing 94 - const postsToPublish: Array<{ 95 - post: BlogPost; 96 - action: "create" | "update"; 97 - reason: string; 98 - }> = []; 141 + // Load state 142 + const state = await loadState(configDir); 99 143 100 - for (const post of posts) { 101 - const contentHash = await getContentHash(post.rawContent); 102 - const relativeFilePath = path.relative(configDir, post.filePath); 103 - const postState = state.posts[relativeFilePath]; 144 + // Scan for posts 145 + const s = spinner(); 146 + s.start("Scanning for posts..."); 147 + const posts = await scanContentDirectory(contentDir, { 148 + frontmatterMapping: config.frontmatter, 149 + ignorePatterns: config.ignore, 150 + slugField: config.frontmatter?.slugField, 151 + removeIndexFromSlug: config.removeIndexFromSlug, 152 + stripDatePrefix: config.stripDatePrefix, 153 + }); 154 + s.stop(`Found ${posts.length} posts`); 104 155 105 - if (force) { 106 - postsToPublish.push({ 107 - post, 108 - action: post.frontmatter.atUri ? "update" : "create", 109 - reason: "forced", 110 - }); 111 - } else if (!postState) { 112 - // New post 113 - postsToPublish.push({ 114 - post, 115 - action: "create", 116 - reason: "new post", 117 - }); 118 - } else if (postState.contentHash !== contentHash) { 119 - // Changed post 120 - postsToPublish.push({ 121 - post, 122 - action: post.frontmatter.atUri ? "update" : "create", 123 - reason: "content changed", 124 - }); 125 - } 126 - } 156 + // Determine which posts need publishing 157 + const postsToPublish: Array<{ 158 + post: BlogPost; 159 + action: "create" | "update"; 160 + reason: string; 161 + }> = []; 162 + const draftPosts: BlogPost[] = []; 127 163 128 - if (postsToPublish.length === 0) { 129 - log.success("All posts are up to date. Nothing to publish."); 130 - return; 131 - } 164 + for (const post of posts) { 165 + // Skip draft posts 166 + if (post.frontmatter.draft) { 167 + draftPosts.push(post); 168 + continue; 169 + } 132 170 133 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 134 - for (const { post, action, reason } of postsToPublish) { 135 - const icon = action === "create" ? "+" : "~"; 136 - log.message(` ${icon} ${post.frontmatter.title} (${reason})`); 137 - } 171 + const contentHash = await getContentHash(post.rawContent); 172 + const relativeFilePath = path.relative(configDir, post.filePath); 173 + const postState = state.posts[relativeFilePath]; 138 174 139 - if (dryRun) { 140 - log.info("\nDry run complete. No changes made."); 141 - return; 142 - } 175 + if (force) { 176 + postsToPublish.push({ 177 + post, 178 + action: post.frontmatter.atUri ? "update" : "create", 179 + reason: "forced", 180 + }); 181 + } else if (!postState) { 182 + // New post 183 + postsToPublish.push({ 184 + post, 185 + action: "create", 186 + reason: "new post", 187 + }); 188 + } else if (postState.contentHash !== contentHash) { 189 + // Changed post 190 + postsToPublish.push({ 191 + post, 192 + action: post.frontmatter.atUri ? "update" : "create", 193 + reason: "content changed", 194 + }); 195 + } 196 + } 143 197 144 - // Create agent 145 - s.start(`Connecting to ${credentials.pdsUrl}...`); 146 - let agent; 147 - try { 148 - agent = await createAgent(credentials); 149 - s.stop(`Logged in as ${agent.session?.handle}`); 150 - } catch (error) { 151 - s.stop("Failed to login"); 152 - log.error(`Failed to login: ${error}`); 153 - process.exit(1); 154 - } 198 + if (draftPosts.length > 0) { 199 + log.info( 200 + `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, 201 + ); 202 + } 155 203 156 - // Publish posts 157 - let publishedCount = 0; 158 - let updatedCount = 0; 159 - let errorCount = 0; 204 + if (postsToPublish.length === 0) { 205 + log.success("All posts are up to date. Nothing to publish."); 206 + return; 207 + } 160 208 161 - for (const { post, action } of postsToPublish) { 162 - s.start(`Publishing: ${post.frontmatter.title}`); 209 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 163 210 164 - try { 165 - // Handle cover image upload 166 - let coverImage: BlobObject | undefined; 167 - if (post.frontmatter.ogImage) { 168 - const imagePath = await resolveImagePath( 169 - post.frontmatter.ogImage, 170 - imagesDir, 171 - contentDir 172 - ); 211 + // Bluesky posting configuration 212 + const blueskyEnabled = config.bluesky?.enabled ?? false; 213 + const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 214 + const cutoffDate = new Date(); 215 + cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 216 + 217 + for (const { post, action, reason } of postsToPublish) { 218 + const icon = action === "create" ? "+" : "~"; 219 + const relativeFilePath = path.relative(configDir, post.filePath); 220 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 221 + 222 + let bskyNote = ""; 223 + if (blueskyEnabled) { 224 + if (existingBskyPostRef) { 225 + bskyNote = " [bsky: exists]"; 226 + } else { 227 + const publishDate = new Date(post.frontmatter.publishDate); 228 + if (publishDate < cutoffDate) { 229 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 230 + } else { 231 + bskyNote = " [bsky: will post]"; 232 + } 233 + } 234 + } 235 + 236 + log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 237 + } 238 + 239 + if (dryRun) { 240 + if (blueskyEnabled) { 241 + log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 242 + } 243 + log.info("\nDry run complete. No changes made."); 244 + return; 245 + } 246 + 247 + // Create agent 248 + const connectingTo = 249 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 250 + s.start(`Connecting as ${connectingTo}...`); 251 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 252 + try { 253 + agent = await createAgent(credentials); 254 + s.stop(`Logged in as ${agent.did}`); 255 + } catch (error) { 256 + s.stop("Failed to login"); 257 + log.error(`Failed to login: ${error}`); 258 + process.exit(1); 259 + } 260 + 261 + // Publish posts 262 + let publishedCount = 0; 263 + let updatedCount = 0; 264 + let errorCount = 0; 265 + let bskyPostCount = 0; 266 + 267 + for (const { post, action } of postsToPublish) { 268 + s.start(`Publishing: ${post.frontmatter.title}`); 269 + 270 + try { 271 + // Handle cover image upload 272 + let coverImage: BlobObject | undefined; 273 + if (post.frontmatter.ogImage) { 274 + const imagePath = await resolveImagePath( 275 + post.frontmatter.ogImage, 276 + imagesDir, 277 + contentDir, 278 + ); 279 + 280 + if (imagePath) { 281 + log.info(` Uploading cover image: ${path.basename(imagePath)}`); 282 + coverImage = await uploadImage(agent, imagePath); 283 + if (coverImage) { 284 + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 285 + } 286 + } else { 287 + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 288 + } 289 + } 290 + 291 + // Track atUri, content for state saving, and bskyPostRef 292 + let atUri: string; 293 + let contentForHash: string; 294 + let bskyPostRef: StrongRef | undefined; 295 + const relativeFilePath = path.relative(configDir, post.filePath); 296 + 297 + // Check if bskyPostRef already exists in state 298 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 299 + 300 + if (action === "create") { 301 + atUri = await createDocument(agent, post, config, coverImage); 302 + s.stop(`Created: ${atUri}`); 303 + 304 + // Update frontmatter with atUri 305 + const updatedContent = updateFrontmatterWithAtUri( 306 + post.rawContent, 307 + atUri, 308 + ); 309 + await fs.writeFile(post.filePath, updatedContent); 310 + log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 173 311 174 - if (imagePath) { 175 - log.info(` Uploading cover image: ${path.basename(imagePath)}`); 176 - coverImage = await uploadImage(agent, imagePath); 177 - if (coverImage) { 178 - log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 179 - } 180 - } else { 181 - log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 182 - } 183 - } 312 + // Use updated content (with atUri) for hash so next run sees matching hash 313 + contentForHash = updatedContent; 314 + publishedCount++; 315 + } else { 316 + atUri = post.frontmatter.atUri!; 317 + await updateDocument(agent, post, atUri, config, coverImage); 318 + s.stop(`Updated: ${atUri}`); 184 319 185 - // Track atUri and content for state saving 186 - let atUri: string; 187 - let contentForHash: string; 320 + // For updates, rawContent already has atUri 321 + contentForHash = post.rawContent; 322 + updatedCount++; 323 + } 188 324 189 - if (action === "create") { 190 - atUri = await createDocument(agent, post, config, coverImage); 191 - s.stop(`Created: ${atUri}`); 325 + // Create Bluesky post if enabled and conditions are met 326 + if (blueskyEnabled) { 327 + if (existingBskyPostRef) { 328 + log.info(` Bluesky post already exists, skipping`); 329 + bskyPostRef = existingBskyPostRef; 330 + } else { 331 + const publishDate = new Date(post.frontmatter.publishDate); 192 332 193 - // Update frontmatter with atUri 194 - const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri); 195 - await fs.writeFile(post.filePath, updatedContent); 196 - log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 333 + if (publishDate < cutoffDate) { 334 + log.info( 335 + ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, 336 + ); 337 + } else { 338 + // Create Bluesky post 339 + try { 340 + const pathPrefix = config.pathPrefix || "/posts"; 341 + const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 197 342 198 - // Use updated content (with atUri) for hash so next run sees matching hash 199 - contentForHash = updatedContent; 200 - publishedCount++; 201 - } else { 202 - atUri = post.frontmatter.atUri!; 203 - await updateDocument(agent, post, atUri, config, coverImage); 204 - s.stop(`Updated: ${atUri}`); 343 + bskyPostRef = await createBlueskyPost(agent, { 344 + title: post.frontmatter.title, 345 + description: post.frontmatter.description, 346 + canonicalUrl, 347 + coverImage, 348 + publishedAt: post.frontmatter.publishDate, 349 + }); 205 350 206 - // For updates, rawContent already has atUri 207 - contentForHash = post.rawContent; 208 - updatedCount++; 209 - } 351 + // Update document record with bskyPostRef 352 + await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 353 + log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 354 + bskyPostCount++; 355 + } catch (bskyError) { 356 + const errorMsg = 357 + bskyError instanceof Error 358 + ? bskyError.message 359 + : String(bskyError); 360 + log.warn(` Failed to create Bluesky post: ${errorMsg}`); 361 + } 362 + } 363 + } 364 + } 210 365 211 - // Update state (use relative path from config directory) 212 - const contentHash = await getContentHash(contentForHash); 213 - const relativeFilePath = path.relative(configDir, post.filePath); 214 - state.posts[relativeFilePath] = { 215 - contentHash, 216 - atUri, 217 - lastPublished: new Date().toISOString(), 218 - }; 219 - } catch (error) { 220 - const errorMessage = error instanceof Error ? error.message : String(error); 221 - s.stop(`Error publishing "${path.basename(post.filePath)}"`); 222 - log.error(` ${errorMessage}`); 223 - errorCount++; 224 - } 225 - } 366 + // Update state (use relative path from config directory) 367 + const contentHash = await getContentHash(contentForHash); 368 + state.posts[relativeFilePath] = { 369 + contentHash, 370 + atUri, 371 + lastPublished: new Date().toISOString(), 372 + slug: post.slug, 373 + bskyPostRef, 374 + }; 375 + } catch (error) { 376 + const errorMessage = 377 + error instanceof Error ? error.message : String(error); 378 + s.stop(`Error publishing "${path.basename(post.filePath)}"`); 379 + log.error(` ${errorMessage}`); 380 + errorCount++; 381 + } 382 + } 226 383 227 - // Save state 228 - await saveState(configDir, state); 384 + // Save state 385 + await saveState(configDir, state); 229 386 230 - // Summary 231 - log.message("\n---"); 232 - log.info(`Published: ${publishedCount}`); 233 - log.info(`Updated: ${updatedCount}`); 234 - if (errorCount > 0) { 235 - log.warn(`Errors: ${errorCount}`); 236 - } 237 - }, 387 + // Summary 388 + log.message("\n---"); 389 + log.info(`Published: ${publishedCount}`); 390 + log.info(`Updated: ${updatedCount}`); 391 + if (bskyPostCount > 0) { 392 + log.info(`Bluesky posts: ${bskyPostCount}`); 393 + } 394 + if (errorCount > 0) { 395 + log.warn(`Errors: ${errorCount}`); 396 + } 397 + }, 238 398 });
+209 -151
packages/cli/src/commands/sync.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command, flag } from "cmd-ts"; 3 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 4 + import * as path from "node:path"; 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 7 12 import { createAgent, listDocuments } from "../lib/atproto"; 8 - import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; 13 + import { 14 + scanContentDirectory, 15 + getContentHash, 16 + updateFrontmatterWithAtUri, 17 + } from "../lib/markdown"; 9 18 import { exitOnCancel } from "../lib/prompts"; 10 19 11 20 export const syncCommand = command({ 12 - name: "sync", 13 - description: "Sync state from ATProto to restore .sequoia-state.json", 14 - args: { 15 - updateFrontmatter: flag({ 16 - long: "update-frontmatter", 17 - short: "u", 18 - description: "Update frontmatter atUri fields in local markdown files", 19 - }), 20 - dryRun: flag({ 21 - long: "dry-run", 22 - short: "n", 23 - description: "Preview what would be synced without making changes", 24 - }), 25 - }, 26 - handler: async ({ updateFrontmatter, dryRun }) => { 27 - // Load config 28 - const configPath = await findConfig(); 29 - if (!configPath) { 30 - log.error("No sequoia.json found. Run 'sequoia init' first."); 31 - process.exit(1); 32 - } 21 + name: "sync", 22 + description: "Sync state from ATProto to restore .sequoia-state.json", 23 + args: { 24 + updateFrontmatter: flag({ 25 + long: "update-frontmatter", 26 + short: "u", 27 + description: "Update frontmatter atUri fields in local markdown files", 28 + }), 29 + dryRun: flag({ 30 + long: "dry-run", 31 + short: "n", 32 + description: "Preview what would be synced without making changes", 33 + }), 34 + }, 35 + handler: async ({ updateFrontmatter, dryRun }) => { 36 + // Load config 37 + const configPath = await findConfig(); 38 + if (!configPath) { 39 + log.error("No sequoia.json found. Run 'sequoia init' first."); 40 + process.exit(1); 41 + } 33 42 34 - const config = await loadConfig(configPath); 35 - const configDir = path.dirname(configPath); 43 + const config = await loadConfig(configPath); 44 + const configDir = path.dirname(configPath); 36 45 37 - log.info(`Site: ${config.siteUrl}`); 38 - log.info(`Publication: ${config.publicationUri}`); 46 + log.info(`Site: ${config.siteUrl}`); 47 + log.info(`Publication: ${config.publicationUri}`); 39 48 40 - // Load credentials 41 - let credentials = await loadCredentials(config.identity); 49 + // Load credentials 50 + let credentials = await loadCredentials(config.identity); 42 51 43 - if (!credentials) { 44 - const identities = await listCredentials(); 45 - if (identities.length === 0) { 46 - log.error("No credentials found. Run 'sequoia auth' first."); 47 - process.exit(1); 48 - } 52 + if (!credentials) { 53 + const identities = await listAllCredentials(); 54 + if (identities.length === 0) { 55 + log.error( 56 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 57 + ); 58 + process.exit(1); 59 + } 49 60 50 - log.info("Multiple identities found. Select one to use:"); 51 - const selected = exitOnCancel(await select({ 52 - message: "Identity:", 53 - options: identities.map(id => ({ value: id, label: id })), 54 - })); 61 + // Build labels with handles for OAuth sessions 62 + const options = await Promise.all( 63 + identities.map(async (cred) => { 64 + if (cred.type === "oauth") { 65 + const handle = await getOAuthHandle(cred.id); 66 + return { 67 + value: cred.id, 68 + label: `${handle || cred.id} (OAuth)`, 69 + }; 70 + } 71 + return { 72 + value: cred.id, 73 + label: `${cred.id} (App Password)`, 74 + }; 75 + }), 76 + ); 55 77 56 - credentials = await getCredentials(selected); 57 - if (!credentials) { 58 - log.error("Failed to load selected credentials."); 59 - process.exit(1); 60 - } 61 - } 78 + log.info("Multiple identities found. Select one to use:"); 79 + const selected = exitOnCancel( 80 + await select({ 81 + message: "Identity:", 82 + options, 83 + }), 84 + ); 62 85 63 - // Create agent 64 - const s = spinner(); 65 - s.start(`Connecting to ${credentials.pdsUrl}...`); 66 - let agent; 67 - try { 68 - agent = await createAgent(credentials); 69 - s.stop(`Logged in as ${agent.session?.handle}`); 70 - } catch (error) { 71 - s.stop("Failed to login"); 72 - log.error(`Failed to login: ${error}`); 73 - process.exit(1); 74 - } 86 + // Load the selected credentials 87 + const selectedCred = identities.find((c) => c.id === selected); 88 + if (selectedCred?.type === "oauth") { 89 + const session = await getOAuthSession(selected); 90 + if (session) { 91 + const handle = await getOAuthHandle(selected); 92 + credentials = { 93 + type: "oauth", 94 + did: selected, 95 + handle: handle || selected, 96 + }; 97 + } 98 + } else { 99 + credentials = await getCredentials(selected); 100 + } 75 101 76 - // Fetch documents from PDS 77 - s.start("Fetching documents from PDS..."); 78 - const documents = await listDocuments(agent, config.publicationUri); 79 - s.stop(`Found ${documents.length} documents on PDS`); 102 + if (!credentials) { 103 + log.error("Failed to load selected credentials."); 104 + process.exit(1); 105 + } 106 + } 80 107 81 - if (documents.length === 0) { 82 - log.info("No documents found for this publication."); 83 - return; 84 - } 108 + // Create agent 109 + const s = spinner(); 110 + const connectingTo = 111 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 112 + s.start(`Connecting as ${connectingTo}...`); 113 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 114 + try { 115 + agent = await createAgent(credentials); 116 + s.stop(`Logged in as ${agent.did}`); 117 + } catch (error) { 118 + s.stop("Failed to login"); 119 + log.error(`Failed to login: ${error}`); 120 + process.exit(1); 121 + } 85 122 86 - // Resolve content directory 87 - const contentDir = path.isAbsolute(config.contentDir) 88 - ? config.contentDir 89 - : path.join(configDir, config.contentDir); 123 + // Fetch documents from PDS 124 + s.start("Fetching documents from PDS..."); 125 + const documents = await listDocuments(agent, config.publicationUri); 126 + s.stop(`Found ${documents.length} documents on PDS`); 90 127 91 - // Scan local posts 92 - s.start("Scanning local content..."); 93 - const localPosts = await scanContentDirectory(contentDir, config.frontmatter); 94 - s.stop(`Found ${localPosts.length} local posts`); 128 + if (documents.length === 0) { 129 + log.info("No documents found for this publication."); 130 + return; 131 + } 95 132 96 - // Build a map of path -> local post for matching 97 - // Document path is like /posts/my-post-slug 98 - const postsByPath = new Map<string, typeof localPosts[0]>(); 99 - for (const post of localPosts) { 100 - const postPath = `/posts/${post.slug}`; 101 - postsByPath.set(postPath, post); 102 - } 133 + // Resolve content directory 134 + const contentDir = path.isAbsolute(config.contentDir) 135 + ? config.contentDir 136 + : path.join(configDir, config.contentDir); 103 137 104 - // Load existing state 105 - const state = await loadState(configDir); 106 - const originalPostCount = Object.keys(state.posts).length; 138 + // Scan local posts 139 + s.start("Scanning local content..."); 140 + const localPosts = await scanContentDirectory(contentDir, { 141 + frontmatterMapping: config.frontmatter, 142 + ignorePatterns: config.ignore, 143 + slugField: config.frontmatter?.slugField, 144 + removeIndexFromSlug: config.removeIndexFromSlug, 145 + stripDatePrefix: config.stripDatePrefix, 146 + }); 147 + s.stop(`Found ${localPosts.length} local posts`); 107 148 108 - // Track changes 109 - let matchedCount = 0; 110 - let unmatchedCount = 0; 111 - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 149 + // Build a map of path -> local post for matching 150 + // Document path is like /posts/my-post-slug (or custom pathPrefix) 151 + const pathPrefix = config.pathPrefix || "/posts"; 152 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 153 + for (const post of localPosts) { 154 + const postPath = `${pathPrefix}/${post.slug}`; 155 + postsByPath.set(postPath, post); 156 + } 112 157 113 - log.message("\nMatching documents to local files:\n"); 158 + // Load existing state 159 + const state = await loadState(configDir); 160 + const originalPostCount = Object.keys(state.posts).length; 114 161 115 - for (const doc of documents) { 116 - const docPath = doc.value.path; 117 - const localPost = postsByPath.get(docPath); 162 + // Track changes 163 + let matchedCount = 0; 164 + let unmatchedCount = 0; 165 + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 118 166 119 - if (localPost) { 120 - matchedCount++; 121 - log.message(` โœ“ ${doc.value.title}`); 122 - log.message(` Path: ${docPath}`); 123 - log.message(` URI: ${doc.uri}`); 124 - log.message(` File: ${path.basename(localPost.filePath)}`); 167 + log.message("\nMatching documents to local files:\n"); 125 168 126 - // Update state (use relative path from config directory) 127 - const contentHash = await getContentHash(localPost.rawContent); 128 - const relativeFilePath = path.relative(configDir, localPost.filePath); 129 - state.posts[relativeFilePath] = { 130 - contentHash, 131 - atUri: doc.uri, 132 - lastPublished: doc.value.publishedAt, 133 - }; 169 + for (const doc of documents) { 170 + const docPath = doc.value.path; 171 + const localPost = postsByPath.get(docPath); 134 172 135 - // Check if frontmatter needs updating 136 - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 137 - frontmatterUpdates.push({ 138 - filePath: localPost.filePath, 139 - atUri: doc.uri, 140 - }); 141 - log.message(` โ†’ Will update frontmatter`); 142 - } 143 - } else { 144 - unmatchedCount++; 145 - log.message(` โœ— ${doc.value.title} (no matching local file)`); 146 - log.message(` Path: ${docPath}`); 147 - log.message(` URI: ${doc.uri}`); 148 - } 149 - log.message(""); 150 - } 173 + if (localPost) { 174 + matchedCount++; 175 + log.message(` โœ“ ${doc.value.title}`); 176 + log.message(` Path: ${docPath}`); 177 + log.message(` URI: ${doc.uri}`); 178 + log.message(` File: ${path.basename(localPost.filePath)}`); 151 179 152 - // Summary 153 - log.message("---"); 154 - log.info(`Matched: ${matchedCount} documents`); 155 - if (unmatchedCount > 0) { 156 - log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); 157 - } 180 + // Update state (use relative path from config directory) 181 + const contentHash = await getContentHash(localPost.rawContent); 182 + const relativeFilePath = path.relative(configDir, localPost.filePath); 183 + state.posts[relativeFilePath] = { 184 + contentHash, 185 + atUri: doc.uri, 186 + lastPublished: doc.value.publishedAt, 187 + }; 158 188 159 - if (dryRun) { 160 - log.info("\nDry run complete. No changes made."); 161 - return; 162 - } 189 + // Check if frontmatter needs updating 190 + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 191 + frontmatterUpdates.push({ 192 + filePath: localPost.filePath, 193 + atUri: doc.uri, 194 + }); 195 + log.message(` โ†’ Will update frontmatter`); 196 + } 197 + } else { 198 + unmatchedCount++; 199 + log.message(` โœ— ${doc.value.title} (no matching local file)`); 200 + log.message(` Path: ${docPath}`); 201 + log.message(` URI: ${doc.uri}`); 202 + } 203 + log.message(""); 204 + } 163 205 164 - // Save updated state 165 - await saveState(configDir, state); 166 - const newPostCount = Object.keys(state.posts).length; 167 - log.success(`\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`); 206 + // Summary 207 + log.message("---"); 208 + log.info(`Matched: ${matchedCount} documents`); 209 + if (unmatchedCount > 0) { 210 + log.warn( 211 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 212 + ); 213 + } 168 214 169 - // Update frontmatter if requested 170 - if (frontmatterUpdates.length > 0) { 171 - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 172 - for (const { filePath, atUri } of frontmatterUpdates) { 173 - const content = await fs.readFile(filePath, "utf-8"); 174 - const updated = updateFrontmatterWithAtUri(content, atUri); 175 - await fs.writeFile(filePath, updated); 176 - log.message(` Updated: ${path.basename(filePath)}`); 177 - } 178 - s.stop("Frontmatter updated"); 179 - } 215 + if (dryRun) { 216 + log.info("\nDry run complete. No changes made."); 217 + return; 218 + } 219 + 220 + // Save updated state 221 + await saveState(configDir, state); 222 + const newPostCount = Object.keys(state.posts).length; 223 + log.success( 224 + `\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`, 225 + ); 226 + 227 + // Update frontmatter if requested 228 + if (frontmatterUpdates.length > 0) { 229 + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 230 + for (const { filePath, atUri } of frontmatterUpdates) { 231 + const content = await fs.readFile(filePath, "utf-8"); 232 + const updated = updateFrontmatterWithAtUri(content, atUri); 233 + await fs.writeFile(filePath, updated); 234 + log.message(` Updated: ${path.basename(filePath)}`); 235 + } 236 + s.stop("Frontmatter updated"); 237 + } 180 238 181 - log.success("\nSync complete!"); 182 - }, 239 + log.success("\nSync complete!"); 240 + }, 183 241 });
+624
packages/cli/src/commands/update.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command } from "cmd-ts"; 3 + import { 4 + intro, 5 + outro, 6 + note, 7 + text, 8 + confirm, 9 + select, 10 + spinner, 11 + log, 12 + } from "@clack/prompts"; 13 + import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 + import { 15 + loadCredentials, 16 + listAllCredentials, 17 + getCredentials, 18 + } from "../lib/credentials"; 19 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 20 + import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 21 + import { exitOnCancel } from "../lib/prompts"; 22 + import type { 23 + PublisherConfig, 24 + FrontmatterMapping, 25 + BlueskyConfig, 26 + } from "../lib/types"; 27 + 28 + export const updateCommand = command({ 29 + name: "update", 30 + description: "Update local config or ATProto publication record", 31 + args: {}, 32 + handler: async () => { 33 + intro("Sequoia Update"); 34 + 35 + // Check if config exists 36 + const configPath = await findConfig(); 37 + if (!configPath) { 38 + log.error("No configuration found. Run 'sequoia init' first."); 39 + process.exit(1); 40 + } 41 + 42 + const config = await loadConfig(configPath); 43 + 44 + // Ask what to update 45 + const updateChoice = exitOnCancel( 46 + await select({ 47 + message: "What would you like to update?", 48 + options: [ 49 + { label: "Local configuration (sequoia.json)", value: "config" }, 50 + { label: "ATProto publication record", value: "publication" }, 51 + ], 52 + }), 53 + ); 54 + 55 + if (updateChoice === "config") { 56 + await updateConfigFlow(config, configPath); 57 + } else { 58 + await updatePublicationFlow(config); 59 + } 60 + 61 + outro("Update complete!"); 62 + }, 63 + }); 64 + 65 + async function updateConfigFlow( 66 + config: PublisherConfig, 67 + configPath: string, 68 + ): Promise<void> { 69 + // Show current config summary 70 + const configSummary = [ 71 + `Site URL: ${config.siteUrl}`, 72 + `Content Dir: ${config.contentDir}`, 73 + `Path Prefix: ${config.pathPrefix || "/posts"}`, 74 + `Publication URI: ${config.publicationUri}`, 75 + config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, 76 + config.outputDir ? `Output Dir: ${config.outputDir}` : null, 77 + config.bluesky?.enabled ? `Bluesky: enabled` : null, 78 + ] 79 + .filter(Boolean) 80 + .join("\n"); 81 + 82 + note(configSummary, "Current Configuration"); 83 + 84 + let configUpdated = { ...config }; 85 + let editing = true; 86 + 87 + while (editing) { 88 + const section = exitOnCancel( 89 + await select({ 90 + message: "Select a section to edit:", 91 + options: [ 92 + { label: "Site settings (siteUrl, pathPrefix)", value: "site" }, 93 + { 94 + label: 95 + "Directory paths (contentDir, imagesDir, publicDir, outputDir)", 96 + value: "directories", 97 + }, 98 + { 99 + label: 100 + "Frontmatter mappings (title, description, publishDate, etc.)", 101 + value: "frontmatter", 102 + }, 103 + { 104 + label: 105 + "Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)", 106 + value: "advanced", 107 + }, 108 + { 109 + label: "Bluesky settings (enabled, maxAgeDays)", 110 + value: "bluesky", 111 + }, 112 + { label: "Done editing", value: "done" }, 113 + ], 114 + }), 115 + ); 116 + 117 + if (section === "done") { 118 + editing = false; 119 + continue; 120 + } 121 + 122 + switch (section) { 123 + case "site": 124 + configUpdated = await editSiteSettings(configUpdated); 125 + break; 126 + case "directories": 127 + configUpdated = await editDirectories(configUpdated); 128 + break; 129 + case "frontmatter": 130 + configUpdated = await editFrontmatter(configUpdated); 131 + break; 132 + case "advanced": 133 + configUpdated = await editAdvanced(configUpdated); 134 + break; 135 + case "bluesky": 136 + configUpdated = await editBluesky(configUpdated); 137 + break; 138 + } 139 + } 140 + 141 + // Confirm before saving 142 + const shouldSave = exitOnCancel( 143 + await confirm({ 144 + message: "Save changes to sequoia.json?", 145 + initialValue: true, 146 + }), 147 + ); 148 + 149 + if (shouldSave) { 150 + const configContent = generateConfigTemplate({ 151 + siteUrl: configUpdated.siteUrl, 152 + contentDir: configUpdated.contentDir, 153 + imagesDir: configUpdated.imagesDir, 154 + publicDir: configUpdated.publicDir, 155 + outputDir: configUpdated.outputDir, 156 + pathPrefix: configUpdated.pathPrefix, 157 + publicationUri: configUpdated.publicationUri, 158 + pdsUrl: configUpdated.pdsUrl, 159 + frontmatter: configUpdated.frontmatter, 160 + ignore: configUpdated.ignore, 161 + removeIndexFromSlug: configUpdated.removeIndexFromSlug, 162 + stripDatePrefix: configUpdated.stripDatePrefix, 163 + textContentField: configUpdated.textContentField, 164 + bluesky: configUpdated.bluesky, 165 + }); 166 + 167 + await fs.writeFile(configPath, configContent); 168 + log.success("Configuration saved!"); 169 + } else { 170 + log.info("Changes discarded."); 171 + } 172 + } 173 + 174 + async function editSiteSettings( 175 + config: PublisherConfig, 176 + ): Promise<PublisherConfig> { 177 + const siteUrl = exitOnCancel( 178 + await text({ 179 + message: "Site URL:", 180 + initialValue: config.siteUrl, 181 + validate: (value) => { 182 + if (!value) return "Site URL is required"; 183 + try { 184 + new URL(value); 185 + } catch { 186 + return "Please enter a valid URL"; 187 + } 188 + }, 189 + }), 190 + ); 191 + 192 + const pathPrefix = exitOnCancel( 193 + await text({ 194 + message: "URL path prefix for posts:", 195 + initialValue: config.pathPrefix || "/posts", 196 + }), 197 + ); 198 + 199 + return { 200 + ...config, 201 + siteUrl, 202 + pathPrefix: pathPrefix || undefined, 203 + }; 204 + } 205 + 206 + async function editDirectories( 207 + config: PublisherConfig, 208 + ): Promise<PublisherConfig> { 209 + const contentDir = exitOnCancel( 210 + await text({ 211 + message: "Content directory:", 212 + initialValue: config.contentDir, 213 + validate: (value) => { 214 + if (!value) return "Content directory is required"; 215 + }, 216 + }), 217 + ); 218 + 219 + const imagesDir = exitOnCancel( 220 + await text({ 221 + message: "Cover images directory (leave empty to skip):", 222 + initialValue: config.imagesDir || "", 223 + }), 224 + ); 225 + 226 + const publicDir = exitOnCancel( 227 + await text({ 228 + message: "Public/static directory:", 229 + initialValue: config.publicDir || "./public", 230 + }), 231 + ); 232 + 233 + const outputDir = exitOnCancel( 234 + await text({ 235 + message: "Build output directory:", 236 + initialValue: config.outputDir || "./dist", 237 + }), 238 + ); 239 + 240 + return { 241 + ...config, 242 + contentDir, 243 + imagesDir: imagesDir || undefined, 244 + publicDir: publicDir || undefined, 245 + outputDir: outputDir || undefined, 246 + }; 247 + } 248 + 249 + async function editFrontmatter( 250 + config: PublisherConfig, 251 + ): Promise<PublisherConfig> { 252 + const currentFrontmatter = config.frontmatter || {}; 253 + 254 + log.info("Press Enter to keep current value, or type a new field name."); 255 + 256 + const titleField = exitOnCancel( 257 + await text({ 258 + message: "Field name for title:", 259 + initialValue: currentFrontmatter.title || "title", 260 + }), 261 + ); 262 + 263 + const descField = exitOnCancel( 264 + await text({ 265 + message: "Field name for description:", 266 + initialValue: currentFrontmatter.description || "description", 267 + }), 268 + ); 269 + 270 + const dateField = exitOnCancel( 271 + await text({ 272 + message: "Field name for publish date:", 273 + initialValue: currentFrontmatter.publishDate || "publishDate", 274 + }), 275 + ); 276 + 277 + const coverField = exitOnCancel( 278 + await text({ 279 + message: "Field name for cover image:", 280 + initialValue: currentFrontmatter.coverImage || "ogImage", 281 + }), 282 + ); 283 + 284 + const tagsField = exitOnCancel( 285 + await text({ 286 + message: "Field name for tags:", 287 + initialValue: currentFrontmatter.tags || "tags", 288 + }), 289 + ); 290 + 291 + const draftField = exitOnCancel( 292 + await text({ 293 + message: "Field name for draft status:", 294 + initialValue: currentFrontmatter.draft || "draft", 295 + }), 296 + ); 297 + 298 + const slugField = exitOnCancel( 299 + await text({ 300 + message: "Field name for slug (leave empty to use filepath):", 301 + initialValue: currentFrontmatter.slugField || "", 302 + }), 303 + ); 304 + 305 + // Build frontmatter mapping, only including non-default values 306 + const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ 307 + ["title", titleField, "title"], 308 + ["description", descField, "description"], 309 + ["publishDate", dateField, "publishDate"], 310 + ["coverImage", coverField, "ogImage"], 311 + ["tags", tagsField, "tags"], 312 + ["draft", draftField, "draft"], 313 + ]; 314 + 315 + const builtMapping = fieldMappings.reduce<FrontmatterMapping>( 316 + (acc, [key, value, defaultValue]) => { 317 + if (value !== defaultValue) { 318 + acc[key] = value; 319 + } 320 + return acc; 321 + }, 322 + {}, 323 + ); 324 + 325 + // Handle slugField separately since it has no default 326 + if (slugField) { 327 + builtMapping.slugField = slugField; 328 + } 329 + 330 + const frontmatter = 331 + Object.keys(builtMapping).length > 0 ? builtMapping : undefined; 332 + 333 + return { 334 + ...config, 335 + frontmatter, 336 + }; 337 + } 338 + 339 + async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> { 340 + const pdsUrl = exitOnCancel( 341 + await text({ 342 + message: "PDS URL (leave empty for default bsky.social):", 343 + initialValue: config.pdsUrl || "", 344 + }), 345 + ); 346 + 347 + const identity = exitOnCancel( 348 + await text({ 349 + message: "Identity/profile to use (leave empty for auto-detect):", 350 + initialValue: config.identity || "", 351 + }), 352 + ); 353 + 354 + const ignoreInput = exitOnCancel( 355 + await text({ 356 + message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):", 357 + initialValue: config.ignore?.join(", ") || "", 358 + }), 359 + ); 360 + 361 + const removeIndexFromSlug = exitOnCancel( 362 + await confirm({ 363 + message: "Remove /index or /_index suffix from paths?", 364 + initialValue: config.removeIndexFromSlug || false, 365 + }), 366 + ); 367 + 368 + const stripDatePrefix = exitOnCancel( 369 + await confirm({ 370 + message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?", 371 + initialValue: config.stripDatePrefix || false, 372 + }), 373 + ); 374 + 375 + const textContentField = exitOnCancel( 376 + await text({ 377 + message: 378 + "Frontmatter field for textContent (leave empty to use markdown body):", 379 + initialValue: config.textContentField || "", 380 + }), 381 + ); 382 + 383 + // Parse ignore patterns 384 + const ignore = ignoreInput 385 + ? ignoreInput 386 + .split(",") 387 + .map((p) => p.trim()) 388 + .filter(Boolean) 389 + : undefined; 390 + 391 + return { 392 + ...config, 393 + pdsUrl: pdsUrl || undefined, 394 + identity: identity || undefined, 395 + ignore: ignore && ignore.length > 0 ? ignore : undefined, 396 + removeIndexFromSlug: removeIndexFromSlug || undefined, 397 + stripDatePrefix: stripDatePrefix || undefined, 398 + textContentField: textContentField || undefined, 399 + }; 400 + } 401 + 402 + async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> { 403 + const enabled = exitOnCancel( 404 + await confirm({ 405 + message: "Enable automatic Bluesky posting when publishing?", 406 + initialValue: config.bluesky?.enabled || false, 407 + }), 408 + ); 409 + 410 + if (!enabled) { 411 + return { 412 + ...config, 413 + bluesky: undefined, 414 + }; 415 + } 416 + 417 + const maxAgeDaysInput = exitOnCancel( 418 + await text({ 419 + message: "Maximum age (in days) for posts to be shared on Bluesky:", 420 + initialValue: String(config.bluesky?.maxAgeDays || 7), 421 + validate: (value) => { 422 + if (!value) return "Please enter a number"; 423 + const num = Number.parseInt(value, 10); 424 + if (Number.isNaN(num) || num < 1) { 425 + return "Please enter a positive number"; 426 + } 427 + }, 428 + }), 429 + ); 430 + 431 + const maxAgeDays = parseInt(maxAgeDaysInput, 10); 432 + 433 + const bluesky: BlueskyConfig = { 434 + enabled: true, 435 + ...(maxAgeDays !== 7 && { maxAgeDays }), 436 + }; 437 + 438 + return { 439 + ...config, 440 + bluesky, 441 + }; 442 + } 443 + 444 + async function updatePublicationFlow(config: PublisherConfig): Promise<void> { 445 + // Load credentials 446 + let credentials = await loadCredentials(config.identity); 447 + 448 + if (!credentials) { 449 + const identities = await listAllCredentials(); 450 + if (identities.length === 0) { 451 + log.error( 452 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 453 + ); 454 + process.exit(1); 455 + } 456 + 457 + // Build labels with handles for OAuth sessions 458 + const options = await Promise.all( 459 + identities.map(async (cred) => { 460 + if (cred.type === "oauth") { 461 + const handle = await getOAuthHandle(cred.id); 462 + return { 463 + value: cred.id, 464 + label: `${handle || cred.id} (OAuth)`, 465 + }; 466 + } 467 + return { 468 + value: cred.id, 469 + label: `${cred.id} (App Password)`, 470 + }; 471 + }), 472 + ); 473 + 474 + log.info("Multiple identities found. Select one to use:"); 475 + const selected = exitOnCancel( 476 + await select({ 477 + message: "Identity:", 478 + options, 479 + }), 480 + ); 481 + 482 + // Load the selected credentials 483 + const selectedCred = identities.find((c) => c.id === selected); 484 + if (selectedCred?.type === "oauth") { 485 + const session = await getOAuthSession(selected); 486 + if (session) { 487 + const handle = await getOAuthHandle(selected); 488 + credentials = { 489 + type: "oauth", 490 + did: selected, 491 + handle: handle || selected, 492 + }; 493 + } 494 + } else { 495 + credentials = await getCredentials(selected); 496 + } 497 + 498 + if (!credentials) { 499 + log.error("Failed to load selected credentials."); 500 + process.exit(1); 501 + } 502 + } 503 + 504 + const s = spinner(); 505 + s.start("Connecting to ATProto..."); 506 + 507 + let agent: Awaited<ReturnType<typeof createAgent>>; 508 + try { 509 + agent = await createAgent(credentials); 510 + s.stop("Connected!"); 511 + } catch (error) { 512 + s.stop("Failed to connect"); 513 + log.error(`Failed to connect: ${error}`); 514 + process.exit(1); 515 + } 516 + 517 + // Fetch existing publication 518 + s.start("Fetching publication..."); 519 + const publication = await getPublication(agent, config.publicationUri); 520 + 521 + if (!publication) { 522 + s.stop("Publication not found"); 523 + log.error(`Could not find publication: ${config.publicationUri}`); 524 + process.exit(1); 525 + } 526 + s.stop("Publication loaded!"); 527 + 528 + // Show current publication info 529 + const pubRecord = publication.value; 530 + const pubSummary = [ 531 + `Name: ${pubRecord.name}`, 532 + `URL: ${pubRecord.url}`, 533 + pubRecord.description ? `Description: ${pubRecord.description}` : null, 534 + pubRecord.icon ? `Icon: (uploaded)` : null, 535 + `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`, 536 + `Created: ${pubRecord.createdAt}`, 537 + ] 538 + .filter(Boolean) 539 + .join("\n"); 540 + 541 + note(pubSummary, "Current Publication"); 542 + 543 + // Collect updates with pre-populated values 544 + const name = exitOnCancel( 545 + await text({ 546 + message: "Publication name:", 547 + initialValue: pubRecord.name, 548 + validate: (value) => { 549 + if (!value) return "Publication name is required"; 550 + }, 551 + }), 552 + ); 553 + 554 + const description = exitOnCancel( 555 + await text({ 556 + message: "Publication description (leave empty to clear):", 557 + initialValue: pubRecord.description || "", 558 + }), 559 + ); 560 + 561 + const url = exitOnCancel( 562 + await text({ 563 + message: "Publication URL:", 564 + initialValue: pubRecord.url, 565 + validate: (value) => { 566 + if (!value) return "URL is required"; 567 + try { 568 + new URL(value); 569 + } catch { 570 + return "Please enter a valid URL"; 571 + } 572 + }, 573 + }), 574 + ); 575 + 576 + const iconPath = exitOnCancel( 577 + await text({ 578 + message: "New icon path (leave empty to keep existing):", 579 + initialValue: "", 580 + }), 581 + ); 582 + 583 + const showInDiscover = exitOnCancel( 584 + await confirm({ 585 + message: "Show in Discover feed?", 586 + initialValue: pubRecord.preferences?.showInDiscover ?? true, 587 + }), 588 + ); 589 + 590 + // Confirm before updating 591 + const shouldUpdate = exitOnCancel( 592 + await confirm({ 593 + message: "Update publication on ATProto?", 594 + initialValue: true, 595 + }), 596 + ); 597 + 598 + if (!shouldUpdate) { 599 + log.info("Update cancelled."); 600 + return; 601 + } 602 + 603 + // Perform update 604 + s.start("Updating publication..."); 605 + try { 606 + await updatePublication( 607 + agent, 608 + config.publicationUri, 609 + { 610 + name, 611 + description, 612 + url, 613 + iconPath: iconPath || undefined, 614 + showInDiscover, 615 + }, 616 + pubRecord, 617 + ); 618 + s.stop("Publication updated!"); 619 + } catch (error) { 620 + s.stop("Failed to update publication"); 621 + log.error(`Failed to update: ${error}`); 622 + process.exit(1); 623 + } 624 + }
+856
packages/cli/src/components/sequoia-comments.js
··· 1 + /** 2 + * Sequoia Comments - A Bluesky-powered comments component 3 + * 4 + * A self-contained Web Component that displays comments from Bluesky posts 5 + * linked to documents via the AT Protocol. 6 + * 7 + * Usage: 8 + * <sequoia-comments></sequoia-comments> 9 + * 10 + * The component looks for a document URI in two places: 11 + * 1. The `document-uri` attribute on the element 12 + * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head 13 + * 14 + * Attributes: 15 + * - document-uri: AT Protocol URI for the document (optional if link tag exists) 16 + * - depth: Maximum depth of nested replies to fetch (default: 6) 17 + * 18 + * CSS Custom Properties: 19 + * - --sequoia-fg-color: Text color (default: #1f2937) 20 + * - --sequoia-bg-color: Background color (default: #ffffff) 21 + * - --sequoia-border-color: Border color (default: #e5e7eb) 22 + * - --sequoia-accent-color: Accent/link color (default: #2563eb) 23 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 24 + * - --sequoia-border-radius: Border radius (default: 8px) 25 + */ 26 + 27 + // ============================================================================ 28 + // Styles 29 + // ============================================================================ 30 + 31 + const styles = ` 32 + :host { 33 + display: block; 34 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 35 + color: var(--sequoia-fg-color, #1f2937); 36 + line-height: 1.5; 37 + } 38 + 39 + * { 40 + box-sizing: border-box; 41 + } 42 + 43 + .sequoia-comments-container { 44 + max-width: 100%; 45 + } 46 + 47 + .sequoia-loading, 48 + .sequoia-error, 49 + .sequoia-empty, 50 + .sequoia-warning { 51 + padding: 1rem; 52 + border-radius: var(--sequoia-border-radius, 8px); 53 + text-align: center; 54 + } 55 + 56 + .sequoia-loading { 57 + background: var(--sequoia-bg-color, #ffffff); 58 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 59 + color: var(--sequoia-secondary-color, #6b7280); 60 + } 61 + 62 + .sequoia-loading-spinner { 63 + display: inline-block; 64 + width: 1.25rem; 65 + height: 1.25rem; 66 + border: 2px solid var(--sequoia-border-color, #e5e7eb); 67 + border-top-color: var(--sequoia-accent-color, #2563eb); 68 + border-radius: 50%; 69 + animation: sequoia-spin 0.8s linear infinite; 70 + margin-right: 0.5rem; 71 + vertical-align: middle; 72 + } 73 + 74 + @keyframes sequoia-spin { 75 + to { transform: rotate(360deg); } 76 + } 77 + 78 + .sequoia-error { 79 + background: #fef2f2; 80 + border: 1px solid #fecaca; 81 + color: #dc2626; 82 + } 83 + 84 + .sequoia-warning { 85 + background: #fffbeb; 86 + border: 1px solid #fde68a; 87 + color: #d97706; 88 + } 89 + 90 + .sequoia-empty { 91 + background: var(--sequoia-bg-color, #ffffff); 92 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 93 + color: var(--sequoia-secondary-color, #6b7280); 94 + } 95 + 96 + .sequoia-comments-header { 97 + display: flex; 98 + justify-content: space-between; 99 + align-items: center; 100 + margin-bottom: 1rem; 101 + padding-bottom: 0.75rem; 102 + } 103 + 104 + .sequoia-comments-title { 105 + font-size: 1.125rem; 106 + font-weight: 600; 107 + margin: 0; 108 + } 109 + 110 + .sequoia-reply-button { 111 + display: inline-flex; 112 + align-items: center; 113 + gap: 0.375rem; 114 + padding: 0.5rem 1rem; 115 + background: var(--sequoia-accent-color, #2563eb); 116 + color: #ffffff; 117 + border: none; 118 + border-radius: var(--sequoia-border-radius, 8px); 119 + font-size: 0.875rem; 120 + font-weight: 500; 121 + cursor: pointer; 122 + text-decoration: none; 123 + transition: background-color 0.15s ease; 124 + } 125 + 126 + .sequoia-reply-button:hover { 127 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 128 + } 129 + 130 + .sequoia-reply-button svg { 131 + width: 1rem; 132 + height: 1rem; 133 + } 134 + 135 + .sequoia-comments-list { 136 + display: flex; 137 + flex-direction: column; 138 + } 139 + 140 + .sequoia-thread { 141 + border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 142 + padding-bottom: 1rem; 143 + } 144 + 145 + .sequoia-thread + .sequoia-thread { 146 + margin-top: 0.5rem; 147 + } 148 + 149 + .sequoia-thread:last-child { 150 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 151 + } 152 + 153 + .sequoia-comment { 154 + display: flex; 155 + gap: 0.75rem; 156 + padding-top: 1rem; 157 + } 158 + 159 + .sequoia-comment-avatar-column { 160 + display: flex; 161 + flex-direction: column; 162 + align-items: center; 163 + flex-shrink: 0; 164 + width: 2.5rem; 165 + position: relative; 166 + } 167 + 168 + .sequoia-comment-avatar { 169 + width: 2.5rem; 170 + height: 2.5rem; 171 + border-radius: 50%; 172 + background: var(--sequoia-border-color, #e5e7eb); 173 + object-fit: cover; 174 + flex-shrink: 0; 175 + position: relative; 176 + z-index: 1; 177 + } 178 + 179 + .sequoia-comment-avatar-placeholder { 180 + width: 2.5rem; 181 + height: 2.5rem; 182 + border-radius: 50%; 183 + background: var(--sequoia-border-color, #e5e7eb); 184 + display: flex; 185 + align-items: center; 186 + justify-content: center; 187 + flex-shrink: 0; 188 + color: var(--sequoia-secondary-color, #6b7280); 189 + font-weight: 600; 190 + font-size: 1rem; 191 + position: relative; 192 + z-index: 1; 193 + } 194 + 195 + .sequoia-thread-line { 196 + position: absolute; 197 + top: 2.5rem; 198 + bottom: calc(-1rem - 0.5rem); 199 + left: 50%; 200 + transform: translateX(-50%); 201 + width: 2px; 202 + background: var(--sequoia-border-color, #e5e7eb); 203 + } 204 + 205 + .sequoia-comment-content { 206 + flex: 1; 207 + min-width: 0; 208 + } 209 + 210 + .sequoia-comment-header { 211 + display: flex; 212 + align-items: baseline; 213 + gap: 0.5rem; 214 + margin-bottom: 0.25rem; 215 + flex-wrap: wrap; 216 + } 217 + 218 + .sequoia-comment-author { 219 + font-weight: 600; 220 + color: var(--sequoia-fg-color, #1f2937); 221 + text-decoration: none; 222 + overflow: hidden; 223 + text-overflow: ellipsis; 224 + white-space: nowrap; 225 + } 226 + 227 + .sequoia-comment-author:hover { 228 + color: var(--sequoia-accent-color, #2563eb); 229 + } 230 + 231 + .sequoia-comment-handle { 232 + font-size: 0.875rem; 233 + color: var(--sequoia-secondary-color, #6b7280); 234 + overflow: hidden; 235 + text-overflow: ellipsis; 236 + white-space: nowrap; 237 + } 238 + 239 + .sequoia-comment-time { 240 + font-size: 0.875rem; 241 + color: var(--sequoia-secondary-color, #6b7280); 242 + flex-shrink: 0; 243 + } 244 + 245 + .sequoia-comment-time::before { 246 + content: "ยท"; 247 + margin-right: 0.5rem; 248 + } 249 + 250 + .sequoia-comment-text { 251 + margin: 0; 252 + white-space: pre-wrap; 253 + word-wrap: break-word; 254 + } 255 + 256 + .sequoia-comment-text a { 257 + color: var(--sequoia-accent-color, #2563eb); 258 + text-decoration: none; 259 + } 260 + 261 + .sequoia-comment-text a:hover { 262 + text-decoration: underline; 263 + } 264 + 265 + .sequoia-bsky-logo { 266 + width: 1rem; 267 + height: 1rem; 268 + } 269 + `; 270 + 271 + // ============================================================================ 272 + // Utility Functions 273 + // ============================================================================ 274 + 275 + /** 276 + * Format a relative time string (e.g., "2 hours ago") 277 + * @param {string} dateString - ISO date string 278 + * @returns {string} Formatted relative time 279 + */ 280 + function formatRelativeTime(dateString) { 281 + const date = new Date(dateString); 282 + const now = new Date(); 283 + const diffMs = now.getTime() - date.getTime(); 284 + const diffSeconds = Math.floor(diffMs / 1000); 285 + const diffMinutes = Math.floor(diffSeconds / 60); 286 + const diffHours = Math.floor(diffMinutes / 60); 287 + const diffDays = Math.floor(diffHours / 24); 288 + const diffWeeks = Math.floor(diffDays / 7); 289 + const diffMonths = Math.floor(diffDays / 30); 290 + const diffYears = Math.floor(diffDays / 365); 291 + 292 + if (diffSeconds < 60) { 293 + return "just now"; 294 + } 295 + if (diffMinutes < 60) { 296 + return `${diffMinutes}m ago`; 297 + } 298 + if (diffHours < 24) { 299 + return `${diffHours}h ago`; 300 + } 301 + if (diffDays < 7) { 302 + return `${diffDays}d ago`; 303 + } 304 + if (diffWeeks < 4) { 305 + return `${diffWeeks}w ago`; 306 + } 307 + if (diffMonths < 12) { 308 + return `${diffMonths}mo ago`; 309 + } 310 + return `${diffYears}y ago`; 311 + } 312 + 313 + /** 314 + * Escape HTML special characters 315 + * @param {string} text - Text to escape 316 + * @returns {string} Escaped HTML 317 + */ 318 + function escapeHtml(text) { 319 + const div = document.createElement("div"); 320 + div.textContent = text; 321 + return div.innerHTML; 322 + } 323 + 324 + /** 325 + * Convert post text with facets to HTML 326 + * @param {string} text - Post text 327 + * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 328 + * @returns {string} HTML string with links 329 + */ 330 + function renderTextWithFacets(text, facets) { 331 + if (!facets || facets.length === 0) { 332 + return escapeHtml(text); 333 + } 334 + 335 + // Convert text to bytes for proper indexing 336 + const encoder = new TextEncoder(); 337 + const decoder = new TextDecoder(); 338 + const textBytes = encoder.encode(text); 339 + 340 + // Sort facets by start index 341 + const sortedFacets = [...facets].sort( 342 + (a, b) => a.index.byteStart - b.index.byteStart, 343 + ); 344 + 345 + let result = ""; 346 + let lastEnd = 0; 347 + 348 + for (const facet of sortedFacets) { 349 + const { byteStart, byteEnd } = facet.index; 350 + 351 + // Add text before this facet 352 + if (byteStart > lastEnd) { 353 + const beforeBytes = textBytes.slice(lastEnd, byteStart); 354 + result += escapeHtml(decoder.decode(beforeBytes)); 355 + } 356 + 357 + // Get the facet text 358 + const facetBytes = textBytes.slice(byteStart, byteEnd); 359 + const facetText = decoder.decode(facetBytes); 360 + 361 + // Find the first renderable feature 362 + const feature = facet.features[0]; 363 + if (feature) { 364 + if (feature.$type === "app.bsky.richtext.facet#link") { 365 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 366 + } else if (feature.$type === "app.bsky.richtext.facet#mention") { 367 + result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 368 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 369 + result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 370 + } else { 371 + result += escapeHtml(facetText); 372 + } 373 + } else { 374 + result += escapeHtml(facetText); 375 + } 376 + 377 + lastEnd = byteEnd; 378 + } 379 + 380 + // Add remaining text 381 + if (lastEnd < textBytes.length) { 382 + const remainingBytes = textBytes.slice(lastEnd); 383 + result += escapeHtml(decoder.decode(remainingBytes)); 384 + } 385 + 386 + return result; 387 + } 388 + 389 + /** 390 + * Get initials from a name for avatar placeholder 391 + * @param {string} name - Display name 392 + * @returns {string} Initials (1-2 characters) 393 + */ 394 + function getInitials(name) { 395 + const parts = name.trim().split(/\s+/); 396 + if (parts.length >= 2) { 397 + return (parts[0][0] + parts[1][0]).toUpperCase(); 398 + } 399 + return name.substring(0, 2).toUpperCase(); 400 + } 401 + 402 + // ============================================================================ 403 + // AT Protocol Client Functions 404 + // ============================================================================ 405 + 406 + /** 407 + * Parse an AT URI into its components 408 + * Format: at://did/collection/rkey 409 + * @param {string} atUri - AT Protocol URI 410 + * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 411 + */ 412 + function parseAtUri(atUri) { 413 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 414 + if (!match) return null; 415 + return { 416 + did: match[1], 417 + collection: match[2], 418 + rkey: match[3], 419 + }; 420 + } 421 + 422 + /** 423 + * Resolve a DID to its PDS URL 424 + * Supports did:plc and did:web methods 425 + * @param {string} did - Decentralized Identifier 426 + * @returns {Promise<string>} PDS URL 427 + */ 428 + async function resolvePDS(did) { 429 + let pdsUrl; 430 + 431 + if (did.startsWith("did:plc:")) { 432 + // Fetch DID document from plc.directory 433 + const didDocUrl = `https://plc.directory/${did}`; 434 + const didDocResponse = await fetch(didDocUrl); 435 + if (!didDocResponse.ok) { 436 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 437 + } 438 + const didDoc = await didDocResponse.json(); 439 + 440 + // Find the PDS service endpoint 441 + const pdsService = didDoc.service?.find( 442 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 443 + ); 444 + pdsUrl = pdsService?.serviceEndpoint; 445 + } else if (did.startsWith("did:web:")) { 446 + // For did:web, fetch the DID document from the domain 447 + const domain = did.replace("did:web:", ""); 448 + const didDocUrl = `https://${domain}/.well-known/did.json`; 449 + const didDocResponse = await fetch(didDocUrl); 450 + if (!didDocResponse.ok) { 451 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 452 + } 453 + const didDoc = await didDocResponse.json(); 454 + 455 + const pdsService = didDoc.service?.find( 456 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 457 + ); 458 + pdsUrl = pdsService?.serviceEndpoint; 459 + } else { 460 + throw new Error(`Unsupported DID method: ${did}`); 461 + } 462 + 463 + if (!pdsUrl) { 464 + throw new Error("Could not find PDS URL for user"); 465 + } 466 + 467 + return pdsUrl; 468 + } 469 + 470 + /** 471 + * Fetch a record from a PDS using the public API 472 + * @param {string} did - DID of the repository owner 473 + * @param {string} collection - Collection name 474 + * @param {string} rkey - Record key 475 + * @returns {Promise<any>} Record value 476 + */ 477 + async function getRecord(did, collection, rkey) { 478 + const pdsUrl = await resolvePDS(did); 479 + 480 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 481 + url.searchParams.set("repo", did); 482 + url.searchParams.set("collection", collection); 483 + url.searchParams.set("rkey", rkey); 484 + 485 + const response = await fetch(url.toString()); 486 + if (!response.ok) { 487 + throw new Error(`Failed to fetch record: ${response.status}`); 488 + } 489 + 490 + const data = await response.json(); 491 + return data.value; 492 + } 493 + 494 + /** 495 + * Fetch a document record from its AT URI 496 + * @param {string} atUri - AT Protocol URI for the document 497 + * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record 498 + */ 499 + async function getDocument(atUri) { 500 + const parsed = parseAtUri(atUri); 501 + if (!parsed) { 502 + throw new Error(`Invalid AT URI: ${atUri}`); 503 + } 504 + 505 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 506 + } 507 + 508 + /** 509 + * Fetch a post thread from the public Bluesky API 510 + * @param {string} postUri - AT Protocol URI for the post 511 + * @param {number} [depth=6] - Maximum depth of replies to fetch 512 + * @returns {Promise<ThreadViewPost>} Thread view post 513 + */ 514 + async function getPostThread(postUri, depth = 6) { 515 + const url = new URL( 516 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 517 + ); 518 + url.searchParams.set("uri", postUri); 519 + url.searchParams.set("depth", depth.toString()); 520 + 521 + const response = await fetch(url.toString()); 522 + if (!response.ok) { 523 + throw new Error(`Failed to fetch post thread: ${response.status}`); 524 + } 525 + 526 + const data = await response.json(); 527 + 528 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 529 + throw new Error("Post not found or blocked"); 530 + } 531 + 532 + return data.thread; 533 + } 534 + 535 + /** 536 + * Build a Bluesky app URL for a post 537 + * @param {string} postUri - AT Protocol URI for the post 538 + * @returns {string} Bluesky app URL 539 + */ 540 + function buildBskyAppUrl(postUri) { 541 + const parsed = parseAtUri(postUri); 542 + if (!parsed) { 543 + throw new Error(`Invalid post URI: ${postUri}`); 544 + } 545 + 546 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 547 + } 548 + 549 + /** 550 + * Type guard for ThreadViewPost 551 + * @param {any} post - Post to check 552 + * @returns {boolean} True if post is a ThreadViewPost 553 + */ 554 + function isThreadViewPost(post) { 555 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 556 + } 557 + 558 + // ============================================================================ 559 + // Bluesky Icon 560 + // ============================================================================ 561 + 562 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 563 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 564 + </svg>`; 565 + 566 + // ============================================================================ 567 + // Web Component 568 + // ============================================================================ 569 + 570 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 571 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 572 + 573 + class SequoiaComments extends BaseElement { 574 + constructor() { 575 + super(); 576 + this.shadow = this.attachShadow({ mode: "open" }); 577 + this.state = { type: "loading" }; 578 + this.abortController = null; 579 + } 580 + 581 + static get observedAttributes() { 582 + return ["document-uri", "depth"]; 583 + } 584 + 585 + connectedCallback() { 586 + this.render(); 587 + this.loadComments(); 588 + } 589 + 590 + disconnectedCallback() { 591 + this.abortController?.abort(); 592 + } 593 + 594 + attributeChangedCallback() { 595 + if (this.isConnected) { 596 + this.loadComments(); 597 + } 598 + } 599 + 600 + get documentUri() { 601 + // First check attribute 602 + const attrUri = this.getAttribute("document-uri"); 603 + if (attrUri) { 604 + return attrUri; 605 + } 606 + 607 + // Then scan for link tag in document head 608 + const linkTag = document.querySelector( 609 + 'link[rel="site.standard.document"]', 610 + ); 611 + return linkTag?.href ?? null; 612 + } 613 + 614 + get depth() { 615 + const depthAttr = this.getAttribute("depth"); 616 + return depthAttr ? parseInt(depthAttr, 10) : 6; 617 + } 618 + 619 + async loadComments() { 620 + // Cancel any in-flight request 621 + this.abortController?.abort(); 622 + this.abortController = new AbortController(); 623 + 624 + this.state = { type: "loading" }; 625 + this.render(); 626 + 627 + const docUri = this.documentUri; 628 + if (!docUri) { 629 + this.state = { type: "no-document" }; 630 + this.render(); 631 + return; 632 + } 633 + 634 + try { 635 + // Fetch the document record 636 + const document = await getDocument(docUri); 637 + 638 + // Check if document has a Bluesky post reference 639 + if (!document.bskyPostRef) { 640 + this.state = { type: "no-comments-enabled" }; 641 + this.render(); 642 + return; 643 + } 644 + 645 + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 646 + 647 + // Fetch the post thread 648 + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 649 + 650 + // Check if there are any replies 651 + const replies = thread.replies?.filter(isThreadViewPost) ?? []; 652 + if (replies.length === 0) { 653 + this.state = { type: "empty", postUrl }; 654 + this.render(); 655 + return; 656 + } 657 + 658 + this.state = { type: "loaded", thread, postUrl }; 659 + this.render(); 660 + } catch (error) { 661 + const message = 662 + error instanceof Error ? error.message : "Failed to load comments"; 663 + this.state = { type: "error", message }; 664 + this.render(); 665 + } 666 + } 667 + 668 + render() { 669 + const styleTag = `<style>${styles}</style>`; 670 + 671 + switch (this.state.type) { 672 + case "loading": 673 + this.shadow.innerHTML = ` 674 + ${styleTag} 675 + <div class="sequoia-comments-container"> 676 + <div class="sequoia-loading"> 677 + <span class="sequoia-loading-spinner"></span> 678 + Loading comments... 679 + </div> 680 + </div> 681 + `; 682 + break; 683 + 684 + case "no-document": 685 + this.shadow.innerHTML = ` 686 + ${styleTag} 687 + <div class="sequoia-comments-container"> 688 + <div class="sequoia-warning"> 689 + No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 690 + </div> 691 + </div> 692 + `; 693 + break; 694 + 695 + case "no-comments-enabled": 696 + this.shadow.innerHTML = ` 697 + ${styleTag} 698 + <div class="sequoia-comments-container"> 699 + <div class="sequoia-empty"> 700 + Comments are not enabled for this post. 701 + </div> 702 + </div> 703 + `; 704 + break; 705 + 706 + case "empty": 707 + this.shadow.innerHTML = ` 708 + ${styleTag} 709 + <div class="sequoia-comments-container"> 710 + <div class="sequoia-comments-header"> 711 + <h3 class="sequoia-comments-title">Comments</h3> 712 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 713 + ${BLUESKY_ICON} 714 + Reply on Bluesky 715 + </a> 716 + </div> 717 + <div class="sequoia-empty"> 718 + No comments yet. Be the first to reply on Bluesky! 719 + </div> 720 + </div> 721 + `; 722 + break; 723 + 724 + case "error": 725 + this.shadow.innerHTML = ` 726 + ${styleTag} 727 + <div class="sequoia-comments-container"> 728 + <div class="sequoia-error"> 729 + Failed to load comments: ${escapeHtml(this.state.message)} 730 + </div> 731 + </div> 732 + `; 733 + break; 734 + 735 + case "loaded": { 736 + const replies = 737 + this.state.thread.replies?.filter(isThreadViewPost) ?? []; 738 + const threadsHtml = replies 739 + .map((reply) => this.renderThread(reply)) 740 + .join(""); 741 + const commentCount = this.countComments(replies); 742 + 743 + this.shadow.innerHTML = ` 744 + ${styleTag} 745 + <div class="sequoia-comments-container"> 746 + <div class="sequoia-comments-header"> 747 + <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 748 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 749 + ${BLUESKY_ICON} 750 + Reply on Bluesky 751 + </a> 752 + </div> 753 + <div class="sequoia-comments-list"> 754 + ${threadsHtml} 755 + </div> 756 + </div> 757 + `; 758 + break; 759 + } 760 + } 761 + } 762 + 763 + /** 764 + * Flatten a thread into a linear list of comments 765 + * @param {ThreadViewPost} thread - Thread to flatten 766 + * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 767 + */ 768 + flattenThread(thread) { 769 + const result = []; 770 + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 771 + 772 + result.push({ 773 + post: thread.post, 774 + hasMoreReplies: nestedReplies.length > 0, 775 + }); 776 + 777 + // Recursively flatten nested replies 778 + for (const reply of nestedReplies) { 779 + result.push(...this.flattenThread(reply)); 780 + } 781 + 782 + return result; 783 + } 784 + 785 + /** 786 + * Render a complete thread (top-level comment + all nested replies) 787 + */ 788 + renderThread(thread) { 789 + const flatComments = this.flattenThread(thread); 790 + const commentsHtml = flatComments 791 + .map((item, index) => 792 + this.renderComment(item.post, item.hasMoreReplies, index), 793 + ) 794 + .join(""); 795 + 796 + return `<div class="sequoia-thread">${commentsHtml}</div>`; 797 + } 798 + 799 + /** 800 + * Render a single comment 801 + * @param {any} post - Post data 802 + * @param {boolean} showThreadLine - Whether to show the connecting thread line 803 + * @param {number} _index - Index in the flattened thread (0 = top-level) 804 + */ 805 + renderComment(post, showThreadLine = false, _index = 0) { 806 + const author = post.author; 807 + const displayName = author.displayName || author.handle; 808 + const avatarHtml = author.avatar 809 + ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 810 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 811 + 812 + const profileUrl = `https://bsky.app/profile/${author.did}`; 813 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 814 + const timeAgo = formatRelativeTime(post.record.createdAt); 815 + const threadLineHtml = showThreadLine 816 + ? '<div class="sequoia-thread-line"></div>' 817 + : ""; 818 + 819 + return ` 820 + <div class="sequoia-comment"> 821 + <div class="sequoia-comment-avatar-column"> 822 + ${avatarHtml} 823 + ${threadLineHtml} 824 + </div> 825 + <div class="sequoia-comment-content"> 826 + <div class="sequoia-comment-header"> 827 + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 828 + ${escapeHtml(displayName)} 829 + </a> 830 + <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 831 + <span class="sequoia-comment-time">${timeAgo}</span> 832 + </div> 833 + <p class="sequoia-comment-text">${textHtml}</p> 834 + </div> 835 + </div> 836 + `; 837 + } 838 + 839 + countComments(replies) { 840 + let count = 0; 841 + for (const reply of replies) { 842 + count += 1; 843 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 844 + count += this.countComments(nested); 845 + } 846 + return count; 847 + } 848 + } 849 + 850 + // Register the custom element 851 + if (typeof customElements !== "undefined") { 852 + customElements.define("sequoia-comments", SequoiaComments); 853 + } 854 + 855 + // Export for module usage 856 + export { SequoiaComments };
+7 -1
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 3 3 import { run, subcommands } from "cmd-ts"; 4 + import { addCommand } from "./commands/add"; 4 5 import { authCommand } from "./commands/auth"; 5 6 import { initCommand } from "./commands/init"; 6 7 import { injectCommand } from "./commands/inject"; 8 + import { loginCommand } from "./commands/login"; 7 9 import { publishCommand } from "./commands/publish"; 8 10 import { syncCommand } from "./commands/sync"; 11 + import { updateCommand } from "./commands/update"; 9 12 10 13 const app = subcommands({ 11 14 name: "sequoia", ··· 33 36 34 37 > https://tangled.org/stevedylan.dev/sequoia 35 38 `, 36 - version: "0.1.1", 39 + version: "0.4.0", 37 40 cmds: { 41 + add: addCommand, 38 42 auth: authCommand, 39 43 init: initCommand, 40 44 inject: injectCommand, 45 + login: loginCommand, 41 46 publish: publishCommand, 42 47 sync: syncCommand, 48 + update: updateCommand, 43 49 }, 44 50 }); 45 51
+661 -270
packages/cli/src/lib/atproto.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 2 - import * as fs from "fs/promises"; 3 - import * as path from "path"; 1 + import { Agent, AtpAgent } from "@atproto/api"; 4 2 import * as mimeTypes from "mime-types"; 5 - import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types"; 3 + import * as fs from "node:fs/promises"; 4 + import * as path from "node:path"; 6 5 import { stripMarkdownForText } from "./markdown"; 6 + import { getOAuthClient } from "./oauth-client"; 7 + import type { 8 + BlobObject, 9 + BlogPost, 10 + Credentials, 11 + PublicationRecord, 12 + PublisherConfig, 13 + StrongRef, 14 + } from "./types"; 15 + import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 + 17 + /** 18 + * Type guard to check if a record value is a DocumentRecord 19 + */ 20 + function isDocumentRecord(value: unknown): value is DocumentRecord { 21 + if (!value || typeof value !== "object") return false; 22 + const v = value as Record<string, unknown>; 23 + return ( 24 + v.$type === "site.standard.document" && 25 + typeof v.title === "string" && 26 + typeof v.site === "string" && 27 + typeof v.path === "string" && 28 + typeof v.textContent === "string" && 29 + typeof v.publishedAt === "string" 30 + ); 31 + } 7 32 8 33 async function fileExists(filePath: string): Promise<boolean> { 9 - try { 10 - await fs.access(filePath); 11 - return true; 12 - } catch { 13 - return false; 14 - } 34 + try { 35 + await fs.access(filePath); 36 + return true; 37 + } catch { 38 + return false; 39 + } 40 + } 41 + 42 + /** 43 + * Resolve a handle to a DID 44 + */ 45 + export async function resolveHandleToDid(handle: string): Promise<string> { 46 + if (handle.startsWith("did:")) { 47 + return handle; 48 + } 49 + 50 + // Try to resolve handle via Bluesky API 51 + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 52 + const resolveResponse = await fetch(resolveUrl); 53 + if (!resolveResponse.ok) { 54 + throw new Error("Could not resolve handle"); 55 + } 56 + const resolveData = (await resolveResponse.json()) as { did: string }; 57 + return resolveData.did; 15 58 } 16 59 17 60 export async function resolveHandleToPDS(handle: string): Promise<string> { 18 - // First, resolve the handle to a DID 19 - let did: string; 61 + // First, resolve the handle to a DID 62 + const did = await resolveHandleToDid(handle); 20 63 21 - if (handle.startsWith("did:")) { 22 - did = handle; 23 - } else { 24 - // Try to resolve handle via Bluesky API 25 - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 26 - const resolveResponse = await fetch(resolveUrl); 27 - if (!resolveResponse.ok) { 28 - throw new Error("Could not resolve handle"); 29 - } 30 - const resolveData = (await resolveResponse.json()) as { did: string }; 31 - did = resolveData.did; 32 - } 64 + // Now resolve the DID to get the PDS URL from the DID document 65 + let pdsUrl: string | undefined; 33 66 34 - // Now resolve the DID to get the PDS URL from the DID document 35 - let pdsUrl: string | undefined; 67 + if (did.startsWith("did:plc:")) { 68 + // Fetch DID document from plc.directory 69 + const didDocUrl = `https://plc.directory/${did}`; 70 + const didDocResponse = await fetch(didDocUrl); 71 + if (!didDocResponse.ok) { 72 + throw new Error("Could not fetch DID document"); 73 + } 74 + const didDoc = (await didDocResponse.json()) as { 75 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 76 + }; 36 77 37 - if (did.startsWith("did:plc:")) { 38 - // Fetch DID document from plc.directory 39 - const didDocUrl = `https://plc.directory/${did}`; 40 - const didDocResponse = await fetch(didDocUrl); 41 - if (!didDocResponse.ok) { 42 - throw new Error("Could not fetch DID document"); 43 - } 44 - const didDoc = (await didDocResponse.json()) as { 45 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 46 - }; 78 + // Find the PDS service endpoint 79 + const pdsService = didDoc.service?.find( 80 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 81 + ); 82 + pdsUrl = pdsService?.serviceEndpoint; 83 + } else if (did.startsWith("did:web:")) { 84 + // For did:web, fetch the DID document from the domain 85 + const domain = did.replace("did:web:", ""); 86 + const didDocUrl = `https://${domain}/.well-known/did.json`; 87 + const didDocResponse = await fetch(didDocUrl); 88 + if (!didDocResponse.ok) { 89 + throw new Error("Could not fetch DID document"); 90 + } 91 + const didDoc = (await didDocResponse.json()) as { 92 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 93 + }; 47 94 48 - // Find the PDS service endpoint 49 - const pdsService = didDoc.service?.find( 50 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 51 - ); 52 - pdsUrl = pdsService?.serviceEndpoint; 53 - } else if (did.startsWith("did:web:")) { 54 - // For did:web, fetch the DID document from the domain 55 - const domain = did.replace("did:web:", ""); 56 - const didDocUrl = `https://${domain}/.well-known/did.json`; 57 - const didDocResponse = await fetch(didDocUrl); 58 - if (!didDocResponse.ok) { 59 - throw new Error("Could not fetch DID document"); 60 - } 61 - const didDoc = (await didDocResponse.json()) as { 62 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 63 - }; 64 - 65 - const pdsService = didDoc.service?.find( 66 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 67 - ); 68 - pdsUrl = pdsService?.serviceEndpoint; 69 - } 95 + const pdsService = didDoc.service?.find( 96 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 97 + ); 98 + pdsUrl = pdsService?.serviceEndpoint; 99 + } 70 100 71 - if (!pdsUrl) { 72 - throw new Error("Could not find PDS URL for user"); 73 - } 101 + if (!pdsUrl) { 102 + throw new Error("Could not find PDS URL for user"); 103 + } 74 104 75 - return pdsUrl; 105 + return pdsUrl; 76 106 } 77 107 78 108 export interface CreatePublicationOptions { 79 - url: string; 80 - name: string; 81 - description?: string; 82 - iconPath?: string; 83 - showInDiscover?: boolean; 109 + url: string; 110 + name: string; 111 + description?: string; 112 + iconPath?: string; 113 + showInDiscover?: boolean; 84 114 } 85 115 86 - export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 87 - const agent = new AtpAgent({ service: credentials.pdsUrl }); 116 + export async function createAgent(credentials: Credentials): Promise<Agent> { 117 + if (isOAuthCredentials(credentials)) { 118 + // OAuth flow - restore session from stored tokens 119 + const client = await getOAuthClient(); 120 + try { 121 + const oauthSession = await client.restore(credentials.did); 122 + // Wrap the OAuth session in an Agent which provides the atproto API 123 + return new Agent(oauthSession); 124 + } catch (error) { 125 + if (error instanceof Error) { 126 + // Check for common OAuth errors 127 + if ( 128 + error.message.includes("expired") || 129 + error.message.includes("revoked") 130 + ) { 131 + throw new Error( 132 + `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 133 + ); 134 + } 135 + } 136 + throw error; 137 + } 138 + } 88 139 89 - await agent.login({ 90 - identifier: credentials.identifier, 91 - password: credentials.password, 92 - }); 140 + // App password flow 141 + if (!isAppPasswordCredentials(credentials)) { 142 + throw new Error("Invalid credential type"); 143 + } 144 + const agent = new AtpAgent({ service: credentials.pdsUrl }); 93 145 94 - return agent; 146 + await agent.login({ 147 + identifier: credentials.identifier, 148 + password: credentials.password, 149 + }); 150 + 151 + return agent; 95 152 } 96 153 97 154 export async function uploadImage( 98 - agent: AtpAgent, 99 - imagePath: string 155 + agent: Agent, 156 + imagePath: string, 100 157 ): Promise<BlobObject | undefined> { 101 - if (!(await fileExists(imagePath))) { 102 - return undefined; 103 - } 158 + if (!(await fileExists(imagePath))) { 159 + return undefined; 160 + } 104 161 105 - try { 106 - const imageBuffer = await fs.readFile(imagePath); 107 - const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 162 + try { 163 + const imageBuffer = await fs.readFile(imagePath); 164 + const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 108 165 109 - const response = await agent.com.atproto.repo.uploadBlob( 110 - new Uint8Array(imageBuffer), 111 - { 112 - encoding: mimeType, 113 - } 114 - ); 166 + const response = await agent.com.atproto.repo.uploadBlob( 167 + new Uint8Array(imageBuffer), 168 + { 169 + encoding: mimeType, 170 + }, 171 + ); 115 172 116 - return { 117 - $type: "blob", 118 - ref: { 119 - $link: response.data.blob.ref.toString(), 120 - }, 121 - mimeType, 122 - size: imageBuffer.byteLength, 123 - }; 124 - } catch (error) { 125 - console.error(`Error uploading image ${imagePath}:`, error); 126 - return undefined; 127 - } 173 + return { 174 + $type: "blob", 175 + ref: { 176 + $link: response.data.blob.ref.toString(), 177 + }, 178 + mimeType, 179 + size: imageBuffer.byteLength, 180 + }; 181 + } catch (error) { 182 + console.error(`Error uploading image ${imagePath}:`, error); 183 + return undefined; 184 + } 128 185 } 129 186 130 187 export async function resolveImagePath( 131 - ogImage: string, 132 - imagesDir: string | undefined, 133 - contentDir: string 188 + ogImage: string, 189 + imagesDir: string | undefined, 190 + contentDir: string, 134 191 ): Promise<string | null> { 135 - // Try multiple resolution strategies 136 - const filename = path.basename(ogImage); 192 + // Try multiple resolution strategies 193 + 194 + // 1. If imagesDir is specified, look there 195 + if (imagesDir) { 196 + // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") 197 + const imagesDirBaseName = path.basename(imagesDir); 198 + 199 + // Check if ogImage contains the images directory name and extract the relative path 200 + // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" 201 + const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); 202 + let relativePath: string; 203 + 204 + if (imagesDirIndex !== -1) { 205 + // Extract everything after "blog-images/" 206 + const afterImagesDir = ogImage.substring( 207 + imagesDirIndex + imagesDirBaseName.length, 208 + ); 209 + // Remove leading slash if present 210 + relativePath = afterImagesDir.replace(/^[/\\]/, ""); 211 + } else { 212 + // Fall back to just the filename 213 + relativePath = path.basename(ogImage); 214 + } 137 215 138 - // 1. If imagesDir is specified, look there 139 - if (imagesDir) { 140 - const imagePath = path.join(imagesDir, filename); 141 - if (await fileExists(imagePath)) { 142 - const stat = await fs.stat(imagePath); 143 - if (stat.size > 0) { 144 - return imagePath; 145 - } 146 - } 147 - } 216 + const imagePath = path.join(imagesDir, relativePath); 217 + if (await fileExists(imagePath)) { 218 + const stat = await fs.stat(imagePath); 219 + if (stat.size > 0) { 220 + return imagePath; 221 + } 222 + } 223 + } 148 224 149 - // 2. Try the ogImage path directly (if it's absolute) 150 - if (path.isAbsolute(ogImage)) { 151 - return ogImage; 152 - } 225 + // 2. Try the ogImage path directly (if it's absolute) 226 + if (path.isAbsolute(ogImage)) { 227 + return ogImage; 228 + } 153 229 154 - // 3. Try relative to content directory 155 - const contentRelative = path.join(contentDir, ogImage); 156 - if (await fileExists(contentRelative)) { 157 - const stat = await fs.stat(contentRelative); 158 - if (stat.size > 0) { 159 - return contentRelative; 160 - } 161 - } 230 + // 3. Try relative to content directory 231 + const contentRelative = path.join(contentDir, ogImage); 232 + if (await fileExists(contentRelative)) { 233 + const stat = await fs.stat(contentRelative); 234 + if (stat.size > 0) { 235 + return contentRelative; 236 + } 237 + } 162 238 163 - return null; 239 + return null; 164 240 } 165 241 166 242 export async function createDocument( 167 - agent: AtpAgent, 168 - post: BlogPost, 169 - config: PublisherConfig, 170 - coverImage?: BlobObject 243 + agent: Agent, 244 + post: BlogPost, 245 + config: PublisherConfig, 246 + coverImage?: BlobObject, 171 247 ): Promise<string> { 172 - const pathPrefix = config.pathPrefix || "/posts"; 173 - const postPath = `${pathPrefix}/${post.slug}`; 174 - const textContent = stripMarkdownForText(post.content); 175 - const publishDate = new Date(post.frontmatter.publishDate); 248 + const pathPrefix = config.pathPrefix || "/posts"; 249 + const postPath = `${pathPrefix}/${post.slug}`; 250 + const publishDate = new Date(post.frontmatter.publishDate); 251 + 252 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 253 + let textContent: string; 254 + if ( 255 + config.textContentField && 256 + post.rawFrontmatter?.[config.textContentField] 257 + ) { 258 + textContent = String(post.rawFrontmatter[config.textContentField]); 259 + } else { 260 + textContent = stripMarkdownForText(post.content); 261 + } 176 262 177 - const record: Record<string, unknown> = { 178 - $type: "site.standard.document", 179 - title: post.frontmatter.title, 180 - site: config.publicationUri, 181 - path: postPath, 182 - textContent: textContent.slice(0, 10000), 183 - publishedAt: publishDate.toISOString(), 184 - canonicalUrl: `${config.siteUrl}${postPath}`, 185 - }; 263 + const record: Record<string, unknown> = { 264 + $type: "site.standard.document", 265 + title: post.frontmatter.title, 266 + site: config.publicationUri, 267 + path: postPath, 268 + textContent: textContent.slice(0, 10000), 269 + publishedAt: publishDate.toISOString(), 270 + canonicalUrl: `${config.siteUrl}${postPath}`, 271 + }; 186 272 187 - if (coverImage) { 188 - record.coverImage = coverImage; 189 - } 273 + if (post.frontmatter.description) { 274 + record.description = post.frontmatter.description; 275 + } 190 276 191 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 192 - record.tags = post.frontmatter.tags; 193 - } 277 + if (coverImage) { 278 + record.coverImage = coverImage; 279 + } 280 + 281 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 282 + record.tags = post.frontmatter.tags; 283 + } 194 284 195 - const response = await agent.com.atproto.repo.createRecord({ 196 - repo: agent.session!.did, 197 - collection: "site.standard.document", 198 - record, 199 - }); 285 + const response = await agent.com.atproto.repo.createRecord({ 286 + repo: agent.did!, 287 + collection: "site.standard.document", 288 + record, 289 + }); 200 290 201 - return response.data.uri; 291 + return response.data.uri; 202 292 } 203 293 204 294 export async function updateDocument( 205 - agent: AtpAgent, 206 - post: BlogPost, 207 - atUri: string, 208 - config: PublisherConfig, 209 - coverImage?: BlobObject 295 + agent: Agent, 296 + post: BlogPost, 297 + atUri: string, 298 + config: PublisherConfig, 299 + coverImage?: BlobObject, 210 300 ): Promise<void> { 211 - // Parse the atUri to get the collection and rkey 212 - // Format: at://did:plc:xxx/collection/rkey 213 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 214 - if (!uriMatch) { 215 - throw new Error(`Invalid atUri format: ${atUri}`); 216 - } 301 + // Parse the atUri to get the collection and rkey 302 + // Format: at://did:plc:xxx/collection/rkey 303 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 304 + if (!uriMatch) { 305 + throw new Error(`Invalid atUri format: ${atUri}`); 306 + } 217 307 218 - const [, , collection, rkey] = uriMatch; 308 + const [, , collection, rkey] = uriMatch; 219 309 220 - const pathPrefix = config.pathPrefix || "/posts"; 221 - const postPath = `${pathPrefix}/${post.slug}`; 222 - const textContent = stripMarkdownForText(post.content); 223 - const publishDate = new Date(post.frontmatter.publishDate); 310 + const pathPrefix = config.pathPrefix || "/posts"; 311 + const postPath = `${pathPrefix}/${post.slug}`; 312 + const publishDate = new Date(post.frontmatter.publishDate); 224 313 225 - const record: Record<string, unknown> = { 226 - $type: "site.standard.document", 227 - title: post.frontmatter.title, 228 - site: config.publicationUri, 229 - path: postPath, 230 - textContent: textContent.slice(0, 10000), 231 - publishedAt: publishDate.toISOString(), 232 - canonicalUrl: `${config.siteUrl}${postPath}`, 233 - }; 314 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 315 + let textContent: string; 316 + if ( 317 + config.textContentField && 318 + post.rawFrontmatter?.[config.textContentField] 319 + ) { 320 + textContent = String(post.rawFrontmatter[config.textContentField]); 321 + } else { 322 + textContent = stripMarkdownForText(post.content); 323 + } 324 + 325 + const record: Record<string, unknown> = { 326 + $type: "site.standard.document", 327 + title: post.frontmatter.title, 328 + site: config.publicationUri, 329 + path: postPath, 330 + textContent: textContent.slice(0, 10000), 331 + publishedAt: publishDate.toISOString(), 332 + canonicalUrl: `${config.siteUrl}${postPath}`, 333 + }; 334 + 335 + if (post.frontmatter.description) { 336 + record.description = post.frontmatter.description; 337 + } 234 338 235 - if (coverImage) { 236 - record.coverImage = coverImage; 237 - } 339 + if (coverImage) { 340 + record.coverImage = coverImage; 341 + } 238 342 239 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 240 - record.tags = post.frontmatter.tags; 241 - } 343 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 344 + record.tags = post.frontmatter.tags; 345 + } 242 346 243 - await agent.com.atproto.repo.putRecord({ 244 - repo: agent.session!.did, 245 - collection: collection!, 246 - rkey: rkey!, 247 - record, 248 - }); 347 + await agent.com.atproto.repo.putRecord({ 348 + repo: agent.did!, 349 + collection: collection!, 350 + rkey: rkey!, 351 + record, 352 + }); 249 353 } 250 354 251 - export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { 252 - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 253 - if (!match) return null; 254 - return { 255 - did: match[1]!, 256 - collection: match[2]!, 257 - rkey: match[3]!, 258 - }; 355 + export function parseAtUri( 356 + atUri: string, 357 + ): { did: string; collection: string; rkey: string } | null { 358 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 359 + if (!match) return null; 360 + return { 361 + did: match[1]!, 362 + collection: match[2]!, 363 + rkey: match[3]!, 364 + }; 259 365 } 260 366 261 367 export interface DocumentRecord { 262 - $type: "site.standard.document"; 263 - title: string; 264 - site: string; 265 - path: string; 266 - textContent: string; 267 - publishedAt: string; 268 - canonicalUrl?: string; 269 - coverImage?: BlobObject; 270 - tags?: string[]; 271 - location?: string; 368 + $type: "site.standard.document"; 369 + title: string; 370 + site: string; 371 + path: string; 372 + textContent: string; 373 + publishedAt: string; 374 + canonicalUrl?: string; 375 + description?: string; 376 + coverImage?: BlobObject; 377 + tags?: string[]; 378 + location?: string; 272 379 } 273 380 274 381 export interface ListDocumentsResult { 275 - uri: string; 276 - cid: string; 277 - value: DocumentRecord; 382 + uri: string; 383 + cid: string; 384 + value: DocumentRecord; 278 385 } 279 386 280 387 export async function listDocuments( 281 - agent: AtpAgent, 282 - publicationUri?: string 388 + agent: Agent, 389 + publicationUri?: string, 283 390 ): Promise<ListDocumentsResult[]> { 284 - const documents: ListDocumentsResult[] = []; 285 - let cursor: string | undefined; 391 + const documents: ListDocumentsResult[] = []; 392 + let cursor: string | undefined; 286 393 287 - do { 288 - const response = await agent.com.atproto.repo.listRecords({ 289 - repo: agent.session!.did, 290 - collection: "site.standard.document", 291 - limit: 100, 292 - cursor, 293 - }); 394 + do { 395 + const response = await agent.com.atproto.repo.listRecords({ 396 + repo: agent.did!, 397 + collection: "site.standard.document", 398 + limit: 100, 399 + cursor, 400 + }); 294 401 295 - for (const record of response.data.records) { 296 - const value = record.value as unknown as DocumentRecord; 402 + for (const record of response.data.records) { 403 + if (!isDocumentRecord(record.value)) { 404 + continue; 405 + } 297 406 298 - // If publicationUri is specified, only include documents from that publication 299 - if (publicationUri && value.site !== publicationUri) { 300 - continue; 301 - } 407 + // If publicationUri is specified, only include documents from that publication 408 + if (publicationUri && record.value.site !== publicationUri) { 409 + continue; 410 + } 302 411 303 - documents.push({ 304 - uri: record.uri, 305 - cid: record.cid, 306 - value, 307 - }); 308 - } 412 + documents.push({ 413 + uri: record.uri, 414 + cid: record.cid, 415 + value: record.value, 416 + }); 417 + } 309 418 310 - cursor = response.data.cursor; 311 - } while (cursor); 419 + cursor = response.data.cursor; 420 + } while (cursor); 312 421 313 - return documents; 422 + return documents; 314 423 } 315 424 316 425 export async function createPublication( 317 - agent: AtpAgent, 318 - options: CreatePublicationOptions 426 + agent: Agent, 427 + options: CreatePublicationOptions, 319 428 ): Promise<string> { 320 - let icon: BlobObject | undefined; 429 + let icon: BlobObject | undefined; 430 + 431 + if (options.iconPath) { 432 + icon = await uploadImage(agent, options.iconPath); 433 + } 434 + 435 + const record: Record<string, unknown> = { 436 + $type: "site.standard.publication", 437 + url: options.url, 438 + name: options.name, 439 + createdAt: new Date().toISOString(), 440 + }; 441 + 442 + if (options.description) { 443 + record.description = options.description; 444 + } 445 + 446 + if (icon) { 447 + record.icon = icon; 448 + } 449 + 450 + if (options.showInDiscover !== undefined) { 451 + record.preferences = { 452 + showInDiscover: options.showInDiscover, 453 + }; 454 + } 455 + 456 + const response = await agent.com.atproto.repo.createRecord({ 457 + repo: agent.did!, 458 + collection: "site.standard.publication", 459 + record, 460 + }); 461 + 462 + return response.data.uri; 463 + } 464 + 465 + export interface GetPublicationResult { 466 + uri: string; 467 + cid: string; 468 + value: PublicationRecord; 469 + } 470 + 471 + export async function getPublication( 472 + agent: Agent, 473 + publicationUri: string, 474 + ): Promise<GetPublicationResult | null> { 475 + const parsed = parseAtUri(publicationUri); 476 + if (!parsed) { 477 + return null; 478 + } 479 + 480 + try { 481 + const response = await agent.com.atproto.repo.getRecord({ 482 + repo: parsed.did, 483 + collection: parsed.collection, 484 + rkey: parsed.rkey, 485 + }); 486 + 487 + return { 488 + uri: publicationUri, 489 + cid: response.data.cid!, 490 + value: response.data.value as unknown as PublicationRecord, 491 + }; 492 + } catch { 493 + return null; 494 + } 495 + } 496 + 497 + export interface UpdatePublicationOptions { 498 + url?: string; 499 + name?: string; 500 + description?: string; 501 + iconPath?: string; 502 + showInDiscover?: boolean; 503 + } 504 + 505 + export async function updatePublication( 506 + agent: Agent, 507 + publicationUri: string, 508 + options: UpdatePublicationOptions, 509 + existingRecord: PublicationRecord, 510 + ): Promise<void> { 511 + const parsed = parseAtUri(publicationUri); 512 + if (!parsed) { 513 + throw new Error(`Invalid publication URI: ${publicationUri}`); 514 + } 515 + 516 + // Build updated record, preserving createdAt and $type 517 + const record: Record<string, unknown> = { 518 + $type: existingRecord.$type, 519 + url: options.url ?? existingRecord.url, 520 + name: options.name ?? existingRecord.name, 521 + createdAt: existingRecord.createdAt, 522 + }; 523 + 524 + // Handle description - can be cleared with empty string 525 + if (options.description !== undefined) { 526 + if (options.description) { 527 + record.description = options.description; 528 + } 529 + // If empty string, don't include description (clears it) 530 + } else if (existingRecord.description) { 531 + record.description = existingRecord.description; 532 + } 321 533 322 - if (options.iconPath) { 323 - icon = await uploadImage(agent, options.iconPath); 324 - } 534 + // Handle icon - upload new if provided, otherwise keep existing 535 + if (options.iconPath) { 536 + const icon = await uploadImage(agent, options.iconPath); 537 + if (icon) { 538 + record.icon = icon; 539 + } 540 + } else if (existingRecord.icon) { 541 + record.icon = existingRecord.icon; 542 + } 325 543 326 - const record: Record<string, unknown> = { 327 - $type: "site.standard.publication", 328 - url: options.url, 329 - name: options.name, 330 - createdAt: new Date().toISOString(), 331 - }; 544 + // Handle preferences 545 + if (options.showInDiscover !== undefined) { 546 + record.preferences = { 547 + showInDiscover: options.showInDiscover, 548 + }; 549 + } else if (existingRecord.preferences) { 550 + record.preferences = existingRecord.preferences; 551 + } 332 552 333 - if (options.description) { 334 - record.description = options.description; 335 - } 553 + await agent.com.atproto.repo.putRecord({ 554 + repo: parsed.did, 555 + collection: parsed.collection, 556 + rkey: parsed.rkey, 557 + record, 558 + }); 559 + } 336 560 337 - if (icon) { 338 - record.icon = icon; 339 - } 561 + // --- Bluesky Post Creation --- 340 562 341 - if (options.showInDiscover !== undefined) { 342 - record.preferences = { 343 - showInDiscover: options.showInDiscover, 344 - }; 345 - } 563 + export interface CreateBlueskyPostOptions { 564 + title: string; 565 + description?: string; 566 + canonicalUrl: string; 567 + coverImage?: BlobObject; 568 + publishedAt: string; // Used as createdAt for the post 569 + } 346 570 347 - const response = await agent.com.atproto.repo.createRecord({ 348 - repo: agent.session!.did, 349 - collection: "site.standard.publication", 350 - record, 351 - }); 571 + /** 572 + * Count graphemes in a string (for Bluesky's 300 grapheme limit) 573 + */ 574 + function countGraphemes(str: string): number { 575 + // Use Intl.Segmenter if available, otherwise fallback to spread operator 576 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 577 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 578 + return [...segmenter.segment(str)].length; 579 + } 580 + return [...str].length; 581 + } 352 582 353 - return response.data.uri; 583 + /** 584 + * Truncate a string to a maximum number of graphemes 585 + */ 586 + function truncateToGraphemes(str: string, maxGraphemes: number): string { 587 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 588 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 589 + const segments = [...segmenter.segment(str)]; 590 + if (segments.length <= maxGraphemes) return str; 591 + return `${segments 592 + .slice(0, maxGraphemes - 3) 593 + .map((s) => s.segment) 594 + .join("")}...`; 595 + } 596 + // Fallback 597 + const chars = [...str]; 598 + if (chars.length <= maxGraphemes) return str; 599 + return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 600 + } 601 + 602 + /** 603 + * Create a Bluesky post with external link embed 604 + */ 605 + export async function createBlueskyPost( 606 + agent: Agent, 607 + options: CreateBlueskyPostOptions, 608 + ): Promise<StrongRef> { 609 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 610 + 611 + // Build post text: title + description + URL 612 + // Max 300 graphemes for Bluesky posts 613 + const MAX_GRAPHEMES = 300; 614 + 615 + let postText: string; 616 + const urlPart = `\n\n${canonicalUrl}`; 617 + const urlGraphemes = countGraphemes(urlPart); 618 + 619 + if (description) { 620 + // Try: title + description + URL 621 + const fullText = `${title}\n\n${description}${urlPart}`; 622 + if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 623 + postText = fullText; 624 + } else { 625 + // Truncate description to fit 626 + const availableForDesc = 627 + MAX_GRAPHEMES - 628 + countGraphemes(title) - 629 + countGraphemes("\n\n") - 630 + urlGraphemes - 631 + countGraphemes("\n\n"); 632 + if (availableForDesc > 10) { 633 + const truncatedDesc = truncateToGraphemes( 634 + description, 635 + availableForDesc, 636 + ); 637 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 638 + } else { 639 + // Just title + URL 640 + postText = `${title}${urlPart}`; 641 + } 642 + } 643 + } else { 644 + // Just title + URL 645 + postText = `${title}${urlPart}`; 646 + } 647 + 648 + // Final truncation if still too long (shouldn't happen but safety check) 649 + if (countGraphemes(postText) > MAX_GRAPHEMES) { 650 + postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 651 + } 652 + 653 + // Calculate byte indices for the URL facet 654 + const encoder = new TextEncoder(); 655 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 656 + const beforeUrl = postText.substring(0, urlStartInText); 657 + const byteStart = encoder.encode(beforeUrl).length; 658 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 659 + 660 + // Build facets for the URL link 661 + const facets = [ 662 + { 663 + index: { 664 + byteStart, 665 + byteEnd, 666 + }, 667 + features: [ 668 + { 669 + $type: "app.bsky.richtext.facet#link", 670 + uri: canonicalUrl, 671 + }, 672 + ], 673 + }, 674 + ]; 675 + 676 + // Build external embed 677 + const embed: Record<string, unknown> = { 678 + $type: "app.bsky.embed.external", 679 + external: { 680 + uri: canonicalUrl, 681 + title: title.substring(0, 500), // Max 500 chars for title 682 + description: (description || "").substring(0, 1000), // Max 1000 chars for description 683 + }, 684 + }; 685 + 686 + // Add thumbnail if coverImage is available 687 + if (coverImage) { 688 + (embed.external as Record<string, unknown>).thumb = coverImage; 689 + } 690 + 691 + // Create the post record 692 + const record: Record<string, unknown> = { 693 + $type: "app.bsky.feed.post", 694 + text: postText, 695 + facets, 696 + embed, 697 + createdAt: new Date(publishedAt).toISOString(), 698 + }; 699 + 700 + const response = await agent.com.atproto.repo.createRecord({ 701 + repo: agent.did!, 702 + collection: "app.bsky.feed.post", 703 + record, 704 + }); 705 + 706 + return { 707 + uri: response.data.uri, 708 + cid: response.data.cid, 709 + }; 710 + } 711 + 712 + /** 713 + * Add bskyPostRef to an existing document record 714 + */ 715 + export async function addBskyPostRefToDocument( 716 + agent: Agent, 717 + documentAtUri: string, 718 + bskyPostRef: StrongRef, 719 + ): Promise<void> { 720 + const parsed = parseAtUri(documentAtUri); 721 + if (!parsed) { 722 + throw new Error(`Invalid document URI: ${documentAtUri}`); 723 + } 724 + 725 + // Fetch existing record 726 + const existingRecord = await agent.com.atproto.repo.getRecord({ 727 + repo: parsed.did, 728 + collection: parsed.collection, 729 + rkey: parsed.rkey, 730 + }); 731 + 732 + // Add bskyPostRef to the record 733 + const updatedRecord = { 734 + ...(existingRecord.data.value as Record<string, unknown>), 735 + bskyPostRef, 736 + }; 737 + 738 + // Update the record 739 + await agent.com.atproto.repo.putRecord({ 740 + repo: parsed.did, 741 + collection: parsed.collection, 742 + rkey: parsed.rkey, 743 + record: updatedRecord, 744 + }); 354 745 }
+27 -3
packages/cli/src/lib/config.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 3 - import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types"; 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import type { 4 + PublisherConfig, 5 + PublisherState, 6 + FrontmatterMapping, 7 + BlueskyConfig, 8 + } from "./types"; 4 9 5 10 const CONFIG_FILENAME = "sequoia.json"; 6 11 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 81 pdsUrl?: string; 77 82 frontmatter?: FrontmatterMapping; 78 83 ignore?: string[]; 84 + removeIndexFromSlug?: boolean; 85 + stripDatePrefix?: boolean; 86 + textContentField?: string; 87 + bluesky?: BlueskyConfig; 79 88 }): string { 80 89 const config: Record<string, unknown> = { 81 90 siteUrl: options.siteUrl, ··· 110 119 111 120 if (options.ignore && options.ignore.length > 0) { 112 121 config.ignore = options.ignore; 122 + } 123 + 124 + if (options.removeIndexFromSlug) { 125 + config.removeIndexFromSlug = options.removeIndexFromSlug; 126 + } 127 + 128 + if (options.stripDatePrefix) { 129 + config.stripDatePrefix = options.stripDatePrefix; 130 + } 131 + 132 + if (options.textContentField) { 133 + config.textContentField = options.textContentField; 134 + } 135 + if (options.bluesky) { 136 + config.bluesky = options.bluesky; 113 137 } 114 138 115 139 return JSON.stringify(config, null, 2);
+54
packages/cli/src/lib/credential-select.ts
··· 1 + import { select } from "@clack/prompts"; 2 + import { getOAuthHandle, getOAuthSession } from "./oauth-store"; 3 + import { getCredentials } from "./credentials"; 4 + import type { Credentials } from "./types"; 5 + import { exitOnCancel } from "./prompts"; 6 + 7 + /** 8 + * Prompt user to select from multiple credentials 9 + */ 10 + export async function selectCredential( 11 + allCredentials: Array<{ id: string; type: "app-password" | "oauth" }>, 12 + ): Promise<Credentials | null> { 13 + // Build options with friendly labels 14 + const options = await Promise.all( 15 + allCredentials.map(async ({ id, type }) => { 16 + let label = id; 17 + if (type === "oauth") { 18 + const handle = await getOAuthHandle(id); 19 + label = handle ? `${handle} (${id})` : id; 20 + } 21 + return { 22 + value: { id, type }, 23 + label: `${label} [${type}]`, 24 + }; 25 + }), 26 + ); 27 + 28 + const selected = exitOnCancel( 29 + await select({ 30 + message: "Multiple identities found. Select one:", 31 + options, 32 + }), 33 + ); 34 + 35 + // Load the full credentials for the selected identity 36 + if (selected.type === "oauth") { 37 + const session = await getOAuthSession(selected.id); 38 + if (session) { 39 + const handle = await getOAuthHandle(selected.id); 40 + return { 41 + type: "oauth", 42 + did: selected.id, 43 + handle: handle || selected.id, 44 + }; 45 + } 46 + } else { 47 + const creds = await getCredentials(selected.id); 48 + if (creds) { 49 + return creds; 50 + } 51 + } 52 + 53 + return null; 54 + }
+217 -102
packages/cli/src/lib/credentials.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 3 - import * as os from "os"; 4 - import type { Credentials } from "./types"; 1 + import * as fs from "node:fs/promises"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import { 5 + getOAuthHandle, 6 + getOAuthSession, 7 + listOAuthSessions, 8 + listOAuthSessionsWithHandles, 9 + } from "./oauth-store"; 10 + import type { 11 + AppPasswordCredentials, 12 + Credentials, 13 + LegacyCredentials, 14 + OAuthCredentials, 15 + } from "./types"; 5 16 6 17 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 7 18 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 8 19 9 - // Stored credentials keyed by identifier 10 - type CredentialsStore = Record<string, Credentials>; 20 + // Stored credentials keyed by identifier (can be legacy or typed) 21 + type CredentialsStore = Record< 22 + string, 23 + AppPasswordCredentials | LegacyCredentials 24 + >; 11 25 12 26 async function fileExists(filePath: string): Promise<boolean> { 13 - try { 14 - await fs.access(filePath); 15 - return true; 16 - } catch { 17 - return false; 18 - } 27 + try { 28 + await fs.access(filePath); 29 + return true; 30 + } catch { 31 + return false; 32 + } 19 33 } 20 34 21 35 /** 22 - * Load all stored credentials 36 + * Normalize credentials to have explicit type 23 37 */ 38 + function normalizeCredentials( 39 + creds: AppPasswordCredentials | LegacyCredentials, 40 + ): AppPasswordCredentials { 41 + // If it already has type, return as-is 42 + if ("type" in creds && creds.type === "app-password") { 43 + return creds; 44 + } 45 + // Migrate legacy format 46 + return { 47 + type: "app-password", 48 + pdsUrl: creds.pdsUrl, 49 + identifier: creds.identifier, 50 + password: creds.password, 51 + }; 52 + } 53 + 24 54 async function loadCredentialsStore(): Promise<CredentialsStore> { 25 - if (!(await fileExists(CREDENTIALS_FILE))) { 26 - return {}; 27 - } 55 + if (!(await fileExists(CREDENTIALS_FILE))) { 56 + return {}; 57 + } 28 58 29 - try { 30 - const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 31 - const parsed = JSON.parse(content); 59 + try { 60 + const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 61 + const parsed = JSON.parse(content); 32 62 33 - // Handle legacy single-credential format (migrate on read) 34 - if (parsed.identifier && parsed.password) { 35 - const legacy = parsed as Credentials; 36 - return { [legacy.identifier]: legacy }; 37 - } 63 + // Handle legacy single-credential format (migrate on read) 64 + if (parsed.identifier && parsed.password) { 65 + const legacy = parsed as LegacyCredentials; 66 + return { [legacy.identifier]: legacy }; 67 + } 38 68 39 - return parsed as CredentialsStore; 40 - } catch { 41 - return {}; 42 - } 69 + return parsed as CredentialsStore; 70 + } catch { 71 + return {}; 72 + } 43 73 } 44 74 45 75 /** 46 76 * Save the entire credentials store 47 77 */ 48 78 async function saveCredentialsStore(store: CredentialsStore): Promise<void> { 49 - await fs.mkdir(CONFIG_DIR, { recursive: true }); 50 - await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 51 - await fs.chmod(CREDENTIALS_FILE, 0o600); 79 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 80 + await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 81 + await fs.chmod(CREDENTIALS_FILE, 0o600); 82 + } 83 + 84 + /** 85 + * Try to load OAuth credentials for a given profile (DID or handle) 86 + */ 87 + async function tryLoadOAuthCredentials( 88 + profile: string, 89 + ): Promise<OAuthCredentials | null> { 90 + // If it looks like a DID, try to get the session directly 91 + if (profile.startsWith("did:")) { 92 + const session = await getOAuthSession(profile); 93 + if (session) { 94 + const handle = await getOAuthHandle(profile); 95 + return { 96 + type: "oauth", 97 + did: profile, 98 + handle: handle || profile, 99 + }; 100 + } 101 + } 102 + 103 + // Try to find OAuth session by handle 104 + const sessions = await listOAuthSessionsWithHandles(); 105 + const match = sessions.find((s) => s.handle === profile); 106 + if (match) { 107 + return { 108 + type: "oauth", 109 + did: match.did, 110 + handle: match.handle || match.did, 111 + }; 112 + } 113 + 114 + return null; 52 115 } 53 116 54 117 /** ··· 56 119 * 57 120 * Priority: 58 121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 59 - * 2. SEQUOIA_PROFILE env var - selects from stored credentials 122 + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 60 123 * 3. projectIdentity parameter (from sequoia.json) 61 - * 4. If only one identity stored, use it 124 + * 4. If only one identity stored (app-password or OAuth), use it 62 125 * 5. Return null (caller should prompt user) 63 126 */ 64 127 export async function loadCredentials( 65 - projectIdentity?: string 128 + projectIdentity?: string, 66 129 ): Promise<Credentials | null> { 67 - // 1. Check environment variables first (full override) 68 - const envIdentifier = process.env.ATP_IDENTIFIER; 69 - const envPassword = process.env.ATP_APP_PASSWORD; 70 - const envPdsUrl = process.env.PDS_URL; 130 + // 1. Check environment variables first (full override) 131 + const envIdentifier = process.env.ATP_IDENTIFIER; 132 + const envPassword = process.env.ATP_APP_PASSWORD; 133 + const envPdsUrl = process.env.PDS_URL; 71 134 72 - if (envIdentifier && envPassword) { 73 - return { 74 - identifier: envIdentifier, 75 - password: envPassword, 76 - pdsUrl: envPdsUrl || "https://bsky.social", 77 - }; 78 - } 135 + if (envIdentifier && envPassword) { 136 + return { 137 + type: "app-password", 138 + identifier: envIdentifier, 139 + password: envPassword, 140 + pdsUrl: envPdsUrl || "https://bsky.social", 141 + }; 142 + } 79 143 80 - const store = await loadCredentialsStore(); 81 - const identifiers = Object.keys(store); 144 + const store = await loadCredentialsStore(); 145 + const appPasswordIds = Object.keys(store); 146 + const oauthDids = await listOAuthSessions(); 82 147 83 - if (identifiers.length === 0) { 84 - return null; 85 - } 86 - 87 - // 2. SEQUOIA_PROFILE env var 88 - const profileEnv = process.env.SEQUOIA_PROFILE; 89 - if (profileEnv && store[profileEnv]) { 90 - return store[profileEnv]; 91 - } 148 + // 2. SEQUOIA_PROFILE env var 149 + const profileEnv = process.env.SEQUOIA_PROFILE; 150 + if (profileEnv) { 151 + // Try app-password credentials first 152 + if (store[profileEnv]) { 153 + return normalizeCredentials(store[profileEnv]); 154 + } 155 + // Try OAuth session (profile could be a DID) 156 + const oauth = await tryLoadOAuthCredentials(profileEnv); 157 + if (oauth) { 158 + return oauth; 159 + } 160 + } 92 161 93 - // 3. Project-specific identity (from sequoia.json) 94 - if (projectIdentity && store[projectIdentity]) { 95 - return store[projectIdentity]; 96 - } 162 + // 3. Project-specific identity (from sequoia.json) 163 + if (projectIdentity) { 164 + if (store[projectIdentity]) { 165 + return normalizeCredentials(store[projectIdentity]); 166 + } 167 + const oauth = await tryLoadOAuthCredentials(projectIdentity); 168 + if (oauth) { 169 + return oauth; 170 + } 171 + } 97 172 98 - // 4. If only one identity, use it 99 - if (identifiers.length === 1 && identifiers[0]) { 100 - return store[identifiers[0]] ?? null; 101 - } 173 + // 4. If only one identity total, use it 174 + const totalIdentities = appPasswordIds.length + oauthDids.length; 175 + if (totalIdentities === 1) { 176 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 177 + return normalizeCredentials(store[appPasswordIds[0]]!); 178 + } 179 + if (oauthDids.length === 1 && oauthDids[0]) { 180 + const session = await getOAuthSession(oauthDids[0]); 181 + if (session) { 182 + const handle = await getOAuthHandle(oauthDids[0]); 183 + return { 184 + type: "oauth", 185 + did: oauthDids[0], 186 + handle: handle || oauthDids[0], 187 + }; 188 + } 189 + } 190 + } 102 191 103 - // Multiple identities exist but none selected 104 - return null; 192 + // Multiple identities exist but none selected, or no identities 193 + return null; 105 194 } 106 195 107 196 /** 108 - * Get a specific identity by identifier 197 + * Get a specific identity by identifier (app-password only) 109 198 */ 110 199 export async function getCredentials( 111 - identifier: string 112 - ): Promise<Credentials | null> { 113 - const store = await loadCredentialsStore(); 114 - return store[identifier] || null; 200 + identifier: string, 201 + ): Promise<AppPasswordCredentials | null> { 202 + const store = await loadCredentialsStore(); 203 + const creds = store[identifier]; 204 + if (!creds) return null; 205 + return normalizeCredentials(creds); 115 206 } 116 207 117 208 /** 118 - * List all stored identities 209 + * List all stored app-password identities 119 210 */ 120 211 export async function listCredentials(): Promise<string[]> { 121 - const store = await loadCredentialsStore(); 122 - return Object.keys(store); 212 + const store = await loadCredentialsStore(); 213 + return Object.keys(store); 214 + } 215 + 216 + /** 217 + * List all credentials (both app-password and OAuth) 218 + */ 219 + export async function listAllCredentials(): Promise< 220 + Array<{ id: string; type: "app-password" | "oauth" }> 221 + > { 222 + const store = await loadCredentialsStore(); 223 + const oauthDids = await listOAuthSessions(); 224 + 225 + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 226 + 227 + for (const id of Object.keys(store)) { 228 + result.push({ id, type: "app-password" }); 229 + } 230 + 231 + for (const did of oauthDids) { 232 + result.push({ id: did, type: "oauth" }); 233 + } 234 + 235 + return result; 123 236 } 124 237 125 238 /** 126 - * Save credentials for an identity (adds or updates) 239 + * Save app-password credentials for an identity (adds or updates) 127 240 */ 128 - export async function saveCredentials(credentials: Credentials): Promise<void> { 129 - const store = await loadCredentialsStore(); 130 - store[credentials.identifier] = credentials; 131 - await saveCredentialsStore(store); 241 + export async function saveCredentials( 242 + credentials: AppPasswordCredentials, 243 + ): Promise<void> { 244 + const store = await loadCredentialsStore(); 245 + store[credentials.identifier] = credentials; 246 + await saveCredentialsStore(store); 132 247 } 133 248 134 249 /** 135 250 * Delete credentials for a specific identity 136 251 */ 137 252 export async function deleteCredentials(identifier?: string): Promise<boolean> { 138 - const store = await loadCredentialsStore(); 139 - const identifiers = Object.keys(store); 253 + const store = await loadCredentialsStore(); 254 + const identifiers = Object.keys(store); 140 255 141 - if (identifiers.length === 0) { 142 - return false; 143 - } 256 + if (identifiers.length === 0) { 257 + return false; 258 + } 144 259 145 - // If identifier specified, delete just that one 146 - if (identifier) { 147 - if (!store[identifier]) { 148 - return false; 149 - } 150 - delete store[identifier]; 151 - await saveCredentialsStore(store); 152 - return true; 153 - } 260 + // If identifier specified, delete just that one 261 + if (identifier) { 262 + if (!store[identifier]) { 263 + return false; 264 + } 265 + delete store[identifier]; 266 + await saveCredentialsStore(store); 267 + return true; 268 + } 154 269 155 - // If only one identity, delete it (backwards compat behavior) 156 - if (identifiers.length === 1 && identifiers[0]) { 157 - delete store[identifiers[0]]; 158 - await saveCredentialsStore(store); 159 - return true; 160 - } 270 + // If only one identity, delete it (backwards compat behavior) 271 + if (identifiers.length === 1 && identifiers[0]) { 272 + delete store[identifiers[0]]; 273 + await saveCredentialsStore(store); 274 + return true; 275 + } 161 276 162 - // Multiple identities but none specified 163 - return false; 277 + // Multiple identities but none specified 278 + return false; 164 279 } 165 280 166 281 export function getCredentialsPath(): string { 167 - return CREDENTIALS_FILE; 282 + return CREDENTIALS_FILE; 168 283 }
+337 -170
packages/cli/src/lib/markdown.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 3 import { glob } from "glob"; 4 4 import { minimatch } from "minimatch"; 5 - import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types"; 5 + import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; 6 6 7 - export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { 8 - frontmatter: PostFrontmatter; 9 - body: string; 7 + export function parseFrontmatter( 8 + content: string, 9 + mapping?: FrontmatterMapping, 10 + ): { 11 + frontmatter: PostFrontmatter; 12 + body: string; 13 + rawFrontmatter: Record<string, unknown>; 10 14 } { 11 - // Support multiple frontmatter delimiters: 12 - // --- (YAML) - Jekyll, Astro, most SSGs 13 - // +++ (TOML) - Hugo 14 - // *** - Alternative format 15 - const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 16 - const match = content.match(frontmatterRegex); 15 + // Support multiple frontmatter delimiters: 16 + // --- (YAML) - Jekyll, Astro, most SSGs 17 + // +++ (TOML) - Hugo 18 + // *** - Alternative format 19 + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 20 + const match = content.match(frontmatterRegex); 17 21 18 - if (!match) { 19 - throw new Error("Could not parse frontmatter"); 20 - } 22 + if (!match) { 23 + throw new Error("Could not parse frontmatter"); 24 + } 21 25 22 - const delimiter = match[1]; 23 - const frontmatterStr = match[2] ?? ""; 24 - const body = match[3] ?? ""; 26 + const delimiter = match[1]; 27 + const frontmatterStr = match[2] ?? ""; 28 + const body = match[3] ?? ""; 25 29 26 - // Determine format based on delimiter: 27 - // +++ uses TOML (key = value) 28 - // --- and *** use YAML (key: value) 29 - const isToml = delimiter === "+++"; 30 - const separator = isToml ? "=" : ":"; 30 + // Determine format based on delimiter: 31 + // +++ uses TOML (key = value) 32 + // --- and *** use YAML (key: value) 33 + const isToml = delimiter === "+++"; 34 + const separator = isToml ? "=" : ":"; 31 35 32 - // Parse frontmatter manually 33 - const raw: Record<string, unknown> = {}; 34 - const lines = frontmatterStr.split("\n"); 36 + // Parse frontmatter manually 37 + const raw: Record<string, unknown> = {}; 38 + const lines = frontmatterStr.split("\n"); 35 39 36 - for (const line of lines) { 37 - const sepIndex = line.indexOf(separator); 38 - if (sepIndex === -1) continue; 40 + let i = 0; 41 + while (i < lines.length) { 42 + const line = lines[i]; 43 + if (line === undefined) { 44 + i++; 45 + continue; 46 + } 47 + const sepIndex = line.indexOf(separator); 48 + if (sepIndex === -1) { 49 + i++; 50 + continue; 51 + } 39 52 40 - const key = line.slice(0, sepIndex).trim(); 41 - let value = line.slice(sepIndex + 1).trim(); 53 + const key = line.slice(0, sepIndex).trim(); 54 + let value = line.slice(sepIndex + 1).trim(); 42 55 43 - // Handle quoted strings 44 - if ( 45 - (value.startsWith('"') && value.endsWith('"')) || 46 - (value.startsWith("'") && value.endsWith("'")) 47 - ) { 48 - value = value.slice(1, -1); 49 - } 56 + // Handle quoted strings 57 + if ( 58 + (value.startsWith('"') && value.endsWith('"')) || 59 + (value.startsWith("'") && value.endsWith("'")) 60 + ) { 61 + value = value.slice(1, -1); 62 + } 50 63 51 - // Handle arrays (simple case for tags) 52 - if (value.startsWith("[") && value.endsWith("]")) { 53 - const arrayContent = value.slice(1, -1); 54 - raw[key] = arrayContent 55 - .split(",") 56 - .map((item) => item.trim().replace(/^["']|["']$/g, "")); 57 - } else if (value === "true") { 58 - raw[key] = true; 59 - } else if (value === "false") { 60 - raw[key] = false; 61 - } else { 62 - raw[key] = value; 63 - } 64 - } 64 + // Handle inline arrays (simple case for tags) 65 + if (value.startsWith("[") && value.endsWith("]")) { 66 + const arrayContent = value.slice(1, -1); 67 + raw[key] = arrayContent 68 + .split(",") 69 + .map((item) => item.trim().replace(/^["']|["']$/g, "")); 70 + } else if (value === "" && !isToml) { 71 + // Check for YAML-style multiline array (key with no value followed by - items) 72 + const arrayItems: string[] = []; 73 + let j = i + 1; 74 + while (j < lines.length) { 75 + const nextLine = lines[j]; 76 + if (nextLine === undefined) { 77 + j++; 78 + continue; 79 + } 80 + // Check if line is a list item (starts with whitespace and -) 81 + const listMatch = nextLine.match(/^\s+-\s*(.*)$/); 82 + if (listMatch && listMatch[1] !== undefined) { 83 + let itemValue = listMatch[1].trim(); 84 + // Remove quotes if present 85 + if ( 86 + (itemValue.startsWith('"') && itemValue.endsWith('"')) || 87 + (itemValue.startsWith("'") && itemValue.endsWith("'")) 88 + ) { 89 + itemValue = itemValue.slice(1, -1); 90 + } 91 + arrayItems.push(itemValue); 92 + j++; 93 + } else if (nextLine.trim() === "") { 94 + // Skip empty lines within the array 95 + j++; 96 + } else { 97 + // Hit a new key or non-list content 98 + break; 99 + } 100 + } 101 + if (arrayItems.length > 0) { 102 + raw[key] = arrayItems; 103 + i = j; 104 + continue; 105 + } else { 106 + raw[key] = value; 107 + } 108 + } else if (value === "true") { 109 + raw[key] = true; 110 + } else if (value === "false") { 111 + raw[key] = false; 112 + } else { 113 + raw[key] = value; 114 + } 115 + i++; 116 + } 65 117 66 - // Apply field mappings to normalize to standard PostFrontmatter fields 67 - const frontmatter: Record<string, unknown> = {}; 118 + // Apply field mappings to normalize to standard PostFrontmatter fields 119 + const frontmatter: Record<string, unknown> = {}; 68 120 69 - // Title mapping 70 - const titleField = mapping?.title || "title"; 71 - frontmatter.title = raw[titleField] || raw.title; 121 + // Title mapping 122 + const titleField = mapping?.title || "title"; 123 + frontmatter.title = raw[titleField] || raw.title; 72 124 73 - // Description mapping 74 - const descField = mapping?.description || "description"; 75 - frontmatter.description = raw[descField] || raw.description; 125 + // Description mapping 126 + const descField = mapping?.description || "description"; 127 + frontmatter.description = raw[descField] || raw.description; 76 128 77 - // Publish date mapping - check custom field first, then fallbacks 78 - const dateField = mapping?.publishDate; 79 - if (dateField && raw[dateField]) { 80 - frontmatter.publishDate = raw[dateField]; 81 - } else if (raw.publishDate) { 82 - frontmatter.publishDate = raw.publishDate; 83 - } else { 84 - // Fallback to common date field names 85 - const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 86 - for (const field of dateFields) { 87 - if (raw[field]) { 88 - frontmatter.publishDate = raw[field]; 89 - break; 90 - } 91 - } 92 - } 129 + // Publish date mapping - check custom field first, then fallbacks 130 + const dateField = mapping?.publishDate; 131 + if (dateField && raw[dateField]) { 132 + frontmatter.publishDate = raw[dateField]; 133 + } else if (raw.publishDate) { 134 + frontmatter.publishDate = raw.publishDate; 135 + } else { 136 + // Fallback to common date field names 137 + const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 138 + for (const field of dateFields) { 139 + if (raw[field]) { 140 + frontmatter.publishDate = raw[field]; 141 + break; 142 + } 143 + } 144 + } 145 + 146 + // Cover image mapping 147 + const coverField = mapping?.coverImage || "ogImage"; 148 + frontmatter.ogImage = raw[coverField] || raw.ogImage; 93 149 94 - // Cover image mapping 95 - const coverField = mapping?.coverImage || "ogImage"; 96 - frontmatter.ogImage = raw[coverField] || raw.ogImage; 150 + // Tags mapping 151 + const tagsField = mapping?.tags || "tags"; 152 + frontmatter.tags = raw[tagsField] || raw.tags; 97 153 98 - // Tags mapping 99 - const tagsField = mapping?.tags || "tags"; 100 - frontmatter.tags = raw[tagsField] || raw.tags; 154 + // Draft mapping 155 + const draftField = mapping?.draft || "draft"; 156 + const draftValue = raw[draftField] ?? raw.draft; 157 + if (draftValue !== undefined) { 158 + frontmatter.draft = draftValue === true || draftValue === "true"; 159 + } 101 160 102 - // Always preserve atUri (internal field) 103 - frontmatter.atUri = raw.atUri; 161 + // Always preserve atUri (internal field) 162 + frontmatter.atUri = raw.atUri; 104 163 105 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; 164 + return { 165 + frontmatter: frontmatter as unknown as PostFrontmatter, 166 + body, 167 + rawFrontmatter: raw, 168 + }; 106 169 } 107 170 108 171 export function getSlugFromFilename(filename: string): string { 109 - return filename 110 - .replace(/\.mdx?$/, "") 111 - .toLowerCase() 112 - .replace(/\s+/g, "-"); 172 + return filename 173 + .replace(/\.mdx?$/, "") 174 + .toLowerCase() 175 + .replace(/\s+/g, "-"); 176 + } 177 + 178 + export interface SlugOptions { 179 + slugField?: string; 180 + removeIndexFromSlug?: boolean; 181 + stripDatePrefix?: boolean; 182 + } 183 + 184 + export function getSlugFromOptions( 185 + relativePath: string, 186 + rawFrontmatter: Record<string, unknown>, 187 + options: SlugOptions = {}, 188 + ): string { 189 + const { 190 + slugField, 191 + removeIndexFromSlug = false, 192 + stripDatePrefix = false, 193 + } = options; 194 + 195 + let slug: string; 196 + 197 + // If slugField is set, try to get the value from frontmatter 198 + if (slugField) { 199 + const frontmatterValue = rawFrontmatter[slugField]; 200 + if (frontmatterValue && typeof frontmatterValue === "string") { 201 + // Remove leading slash if present 202 + slug = frontmatterValue 203 + .replace(/^\//, "") 204 + .toLowerCase() 205 + .replace(/\s+/g, "-"); 206 + } else { 207 + // Fallback to filepath if frontmatter field not found 208 + slug = relativePath 209 + .replace(/\.mdx?$/, "") 210 + .toLowerCase() 211 + .replace(/\s+/g, "-"); 212 + } 213 + } else { 214 + // Default: use filepath 215 + slug = relativePath 216 + .replace(/\.mdx?$/, "") 217 + .toLowerCase() 218 + .replace(/\s+/g, "-"); 219 + } 220 + 221 + // Remove /index or /_index suffix if configured 222 + if (removeIndexFromSlug) { 223 + slug = slug.replace(/\/_?index$/, ""); 224 + } 225 + 226 + // Strip Jekyll-style date prefix (YYYY-MM-DD-) from filename 227 + if (stripDatePrefix) { 228 + slug = slug.replace(/(^|\/)(\d{4}-\d{2}-\d{2})-/g, "$1"); 229 + } 230 + 231 + return slug; 113 232 } 114 233 115 234 export async function getContentHash(content: string): Promise<string> { 116 - const encoder = new TextEncoder(); 117 - const data = encoder.encode(content); 118 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); 119 - const hashArray = Array.from(new Uint8Array(hashBuffer)); 120 - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 235 + const encoder = new TextEncoder(); 236 + const data = encoder.encode(content); 237 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 238 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 239 + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 121 240 } 122 241 123 242 function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { 124 - for (const pattern of ignorePatterns) { 125 - if (minimatch(relativePath, pattern)) { 126 - return true; 127 - } 128 - } 129 - return false; 243 + for (const pattern of ignorePatterns) { 244 + if (minimatch(relativePath, pattern)) { 245 + return true; 246 + } 247 + } 248 + return false; 249 + } 250 + 251 + export interface ScanOptions { 252 + frontmatterMapping?: FrontmatterMapping; 253 + ignorePatterns?: string[]; 254 + slugField?: string; 255 + removeIndexFromSlug?: boolean; 256 + stripDatePrefix?: boolean; 130 257 } 131 258 132 259 export async function scanContentDirectory( 133 - contentDir: string, 134 - frontmatterMapping?: FrontmatterMapping, 135 - ignorePatterns: string[] = [] 260 + contentDir: string, 261 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 262 + ignorePatterns: string[] = [], 136 263 ): Promise<BlogPost[]> { 137 - const patterns = ["**/*.md", "**/*.mdx"]; 138 - const posts: BlogPost[] = []; 264 + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 265 + let options: ScanOptions; 266 + if ( 267 + frontmatterMappingOrOptions && 268 + ("frontmatterMapping" in frontmatterMappingOrOptions || 269 + "ignorePatterns" in frontmatterMappingOrOptions || 270 + "slugField" in frontmatterMappingOrOptions) 271 + ) { 272 + options = frontmatterMappingOrOptions as ScanOptions; 273 + } else { 274 + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 275 + options = { 276 + frontmatterMapping: frontmatterMappingOrOptions as 277 + | FrontmatterMapping 278 + | undefined, 279 + ignorePatterns, 280 + }; 281 + } 139 282 140 - for (const pattern of patterns) { 141 - const files = await glob(pattern, { 142 - cwd: contentDir, 143 - absolute: false, 144 - }); 283 + const { 284 + frontmatterMapping, 285 + ignorePatterns: ignore = [], 286 + slugField, 287 + removeIndexFromSlug, 288 + stripDatePrefix, 289 + } = options; 290 + 291 + const patterns = ["**/*.md", "**/*.mdx"]; 292 + const posts: BlogPost[] = []; 293 + 294 + for (const pattern of patterns) { 295 + const files = await glob(pattern, { 296 + cwd: contentDir, 297 + absolute: false, 298 + }); 145 299 146 - for (const relativePath of files) { 147 - // Skip files matching ignore patterns 148 - if (shouldIgnore(relativePath, ignorePatterns)) { 149 - continue; 150 - } 300 + for (const relativePath of files) { 301 + // Skip files matching ignore patterns 302 + if (shouldIgnore(relativePath, ignore)) { 303 + continue; 304 + } 151 305 152 - const filePath = path.join(contentDir, relativePath); 153 - const rawContent = await fs.readFile(filePath, "utf-8"); 306 + const filePath = path.join(contentDir, relativePath); 307 + const rawContent = await fs.readFile(filePath, "utf-8"); 154 308 155 - try { 156 - const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); 157 - const filename = path.basename(relativePath); 158 - const slug = getSlugFromFilename(filename); 309 + try { 310 + const { frontmatter, body, rawFrontmatter } = parseFrontmatter( 311 + rawContent, 312 + frontmatterMapping, 313 + ); 314 + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 315 + slugField, 316 + removeIndexFromSlug, 317 + stripDatePrefix, 318 + }); 159 319 160 - posts.push({ 161 - filePath, 162 - slug, 163 - frontmatter, 164 - content: body, 165 - rawContent, 166 - }); 167 - } catch (error) { 168 - console.error(`Error parsing ${relativePath}:`, error); 169 - } 170 - } 171 - } 320 + posts.push({ 321 + filePath, 322 + slug, 323 + frontmatter, 324 + content: body, 325 + rawContent, 326 + rawFrontmatter, 327 + }); 328 + } catch (error) { 329 + console.error(`Error parsing ${relativePath}:`, error); 330 + } 331 + } 332 + } 172 333 173 - // Sort by publish date (newest first) 174 - posts.sort((a, b) => { 175 - const dateA = new Date(a.frontmatter.publishDate); 176 - const dateB = new Date(b.frontmatter.publishDate); 177 - return dateB.getTime() - dateA.getTime(); 178 - }); 334 + // Sort by publish date (newest first) 335 + posts.sort((a, b) => { 336 + const dateA = new Date(a.frontmatter.publishDate); 337 + const dateB = new Date(b.frontmatter.publishDate); 338 + return dateB.getTime() - dateA.getTime(); 339 + }); 179 340 180 - return posts; 341 + return posts; 181 342 } 182 343 183 - export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string { 184 - // Detect which delimiter is used (---, +++, or ***) 185 - const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 186 - const delimiter = delimiterMatch?.[1] ?? "---"; 187 - const isToml = delimiter === "+++"; 344 + export function updateFrontmatterWithAtUri( 345 + rawContent: string, 346 + atUri: string, 347 + ): string { 348 + // Detect which delimiter is used (---, +++, or ***) 349 + const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 350 + const delimiter = delimiterMatch?.[1] ?? "---"; 351 + const isToml = delimiter === "+++"; 188 352 189 - // Format the atUri entry based on frontmatter type 190 - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 353 + // Format the atUri entry based on frontmatter type 354 + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 191 355 192 - // Check if atUri already exists in frontmatter (handle both formats) 193 - if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 194 - // Replace existing atUri (match both YAML and TOML formats) 195 - return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`); 196 - } 356 + // Check if atUri already exists in frontmatter (handle both formats) 357 + if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 358 + // Replace existing atUri (match both YAML and TOML formats) 359 + return rawContent.replace( 360 + /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, 361 + `${atUriEntry}\n`, 362 + ); 363 + } 197 364 198 - // Insert atUri before the closing delimiter 199 - const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 200 - if (frontmatterEndIndex === -1) { 201 - throw new Error("Could not find frontmatter end"); 202 - } 365 + // Insert atUri before the closing delimiter 366 + const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 367 + if (frontmatterEndIndex === -1) { 368 + throw new Error("Could not find frontmatter end"); 369 + } 203 370 204 - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 205 - const afterEnd = rawContent.slice(frontmatterEndIndex); 371 + const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 372 + const afterEnd = rawContent.slice(frontmatterEndIndex); 206 373 207 - return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 374 + return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 208 375 } 209 376 210 377 export function stripMarkdownForText(markdown: string): string { 211 - return markdown 212 - .replace(/#{1,6}\s/g, "") // Remove headers 213 - .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 214 - .replace(/\*([^*]+)\*/g, "$1") // Remove italic 215 - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 216 - .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 217 - .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 218 - .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 219 - .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 220 - .trim(); 378 + return markdown 379 + .replace(/#{1,6}\s/g, "") // Remove headers 380 + .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 381 + .replace(/\*([^*]+)\*/g, "$1") // Remove italic 382 + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 383 + .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 384 + .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 385 + .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 386 + .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 387 + .trim(); 221 388 }
+94
packages/cli/src/lib/oauth-client.ts
··· 1 + import { 2 + NodeOAuthClient, 3 + type NodeOAuthClientOptions, 4 + } from "@atproto/oauth-client-node"; 5 + import { sessionStore, stateStore } from "./oauth-store"; 6 + 7 + const CALLBACK_PORT = 4000; 8 + const CALLBACK_HOST = "127.0.0.1"; 9 + const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/oauth/callback`; 10 + 11 + // OAuth scope for Sequoia CLI - includes atproto base scope plus our collections 12 + const OAUTH_SCOPE = 13 + "atproto repo:site.standard.document repo:site.standard.publication repo:app.bsky.feed.post blob:*/*"; 14 + 15 + let oauthClient: NodeOAuthClient | null = null; 16 + 17 + // Simple lock implementation for CLI (single process, no contention) 18 + // This prevents the "No lock mechanism provided" warning 19 + const locks = new Map<string, Promise<void>>(); 20 + 21 + async function requestLock<T>( 22 + key: string, 23 + fn: () => T | PromiseLike<T>, 24 + ): Promise<T> { 25 + // Wait for any existing lock on this key 26 + while (locks.has(key)) { 27 + await locks.get(key); 28 + } 29 + 30 + // Create our lock 31 + let resolve: () => void; 32 + const lockPromise = new Promise<void>((r) => { 33 + resolve = r; 34 + }); 35 + locks.set(key, lockPromise); 36 + 37 + try { 38 + return await fn(); 39 + } finally { 40 + locks.delete(key); 41 + resolve!(); 42 + } 43 + } 44 + 45 + /** 46 + * Get or create the OAuth client singleton 47 + */ 48 + export async function getOAuthClient(): Promise<NodeOAuthClient> { 49 + if (oauthClient) { 50 + return oauthClient; 51 + } 52 + 53 + // Build client_id with required parameters 54 + const clientIdParams = new URLSearchParams(); 55 + clientIdParams.append("redirect_uri", CALLBACK_URL); 56 + clientIdParams.append("scope", OAUTH_SCOPE); 57 + 58 + const clientOptions: NodeOAuthClientOptions = { 59 + clientMetadata: { 60 + client_id: `http://localhost?${clientIdParams.toString()}`, 61 + client_name: "Sequoia CLI", 62 + client_uri: "https://github.com/stevedylandev/sequoia", 63 + redirect_uris: [CALLBACK_URL], 64 + grant_types: ["authorization_code", "refresh_token"], 65 + response_types: ["code"], 66 + token_endpoint_auth_method: "none", 67 + application_type: "web", 68 + scope: OAUTH_SCOPE, 69 + dpop_bound_access_tokens: false, 70 + }, 71 + stateStore, 72 + sessionStore, 73 + // Configure identity resolution 74 + plcDirectoryUrl: "https://plc.directory", 75 + // Provide lock mechanism to prevent warning 76 + requestLock, 77 + }; 78 + 79 + oauthClient = new NodeOAuthClient(clientOptions); 80 + 81 + return oauthClient; 82 + } 83 + 84 + export function getOAuthScope(): string { 85 + return OAUTH_SCOPE; 86 + } 87 + 88 + export function getCallbackUrl(): string { 89 + return CALLBACK_URL; 90 + } 91 + 92 + export function getCallbackPort(): number { 93 + return CALLBACK_PORT; 94 + }
+161
packages/cli/src/lib/oauth-store.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import type { 5 + NodeSavedSession, 6 + NodeSavedSessionStore, 7 + NodeSavedState, 8 + NodeSavedStateStore, 9 + } from "@atproto/oauth-client-node"; 10 + 11 + const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 12 + const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json"); 13 + 14 + interface OAuthStore { 15 + states: Record<string, NodeSavedState>; 16 + sessions: Record<string, NodeSavedSession>; 17 + handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat) 18 + } 19 + 20 + async function fileExists(filePath: string): Promise<boolean> { 21 + try { 22 + await fs.access(filePath); 23 + return true; 24 + } catch { 25 + return false; 26 + } 27 + } 28 + 29 + async function loadOAuthStore(): Promise<OAuthStore> { 30 + if (!(await fileExists(OAUTH_FILE))) { 31 + return { states: {}, sessions: {} }; 32 + } 33 + 34 + try { 35 + const content = await fs.readFile(OAUTH_FILE, "utf-8"); 36 + return JSON.parse(content) as OAuthStore; 37 + } catch { 38 + return { states: {}, sessions: {} }; 39 + } 40 + } 41 + 42 + async function saveOAuthStore(store: OAuthStore): Promise<void> { 43 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 44 + await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2)); 45 + await fs.chmod(OAUTH_FILE, 0o600); 46 + } 47 + 48 + /** 49 + * State store for PKCE flow (temporary, used during auth) 50 + */ 51 + export const stateStore: NodeSavedStateStore = { 52 + async set(key: string, state: NodeSavedState): Promise<void> { 53 + const store = await loadOAuthStore(); 54 + store.states[key] = state; 55 + await saveOAuthStore(store); 56 + }, 57 + 58 + async get(key: string): Promise<NodeSavedState | undefined> { 59 + const store = await loadOAuthStore(); 60 + return store.states[key]; 61 + }, 62 + 63 + async del(key: string): Promise<void> { 64 + const store = await loadOAuthStore(); 65 + delete store.states[key]; 66 + await saveOAuthStore(store); 67 + }, 68 + }; 69 + 70 + /** 71 + * Session store for OAuth tokens (persistent) 72 + */ 73 + export const sessionStore: NodeSavedSessionStore = { 74 + async set(sub: string, session: NodeSavedSession): Promise<void> { 75 + const store = await loadOAuthStore(); 76 + store.sessions[sub] = session; 77 + await saveOAuthStore(store); 78 + }, 79 + 80 + async get(sub: string): Promise<NodeSavedSession | undefined> { 81 + const store = await loadOAuthStore(); 82 + return store.sessions[sub]; 83 + }, 84 + 85 + async del(sub: string): Promise<void> { 86 + const store = await loadOAuthStore(); 87 + delete store.sessions[sub]; 88 + await saveOAuthStore(store); 89 + }, 90 + }; 91 + 92 + /** 93 + * List all stored OAuth session DIDs 94 + */ 95 + export async function listOAuthSessions(): Promise<string[]> { 96 + const store = await loadOAuthStore(); 97 + return Object.keys(store.sessions); 98 + } 99 + 100 + /** 101 + * Get an OAuth session by DID 102 + */ 103 + export async function getOAuthSession( 104 + did: string, 105 + ): Promise<NodeSavedSession | undefined> { 106 + const store = await loadOAuthStore(); 107 + return store.sessions[did]; 108 + } 109 + 110 + /** 111 + * Delete an OAuth session by DID 112 + */ 113 + export async function deleteOAuthSession(did: string): Promise<boolean> { 114 + const store = await loadOAuthStore(); 115 + if (!store.sessions[did]) { 116 + return false; 117 + } 118 + delete store.sessions[did]; 119 + await saveOAuthStore(store); 120 + return true; 121 + } 122 + 123 + export function getOAuthStorePath(): string { 124 + return OAUTH_FILE; 125 + } 126 + 127 + /** 128 + * Store handle for an OAuth session (DID -> handle mapping) 129 + */ 130 + export async function setOAuthHandle( 131 + did: string, 132 + handle: string, 133 + ): Promise<void> { 134 + const store = await loadOAuthStore(); 135 + if (!store.handles) { 136 + store.handles = {}; 137 + } 138 + store.handles[did] = handle; 139 + await saveOAuthStore(store); 140 + } 141 + 142 + /** 143 + * Get handle for an OAuth session by DID 144 + */ 145 + export async function getOAuthHandle(did: string): Promise<string | undefined> { 146 + const store = await loadOAuthStore(); 147 + return store.handles?.[did]; 148 + } 149 + 150 + /** 151 + * List all stored OAuth sessions with their handles 152 + */ 153 + export async function listOAuthSessionsWithHandles(): Promise< 154 + Array<{ did: string; handle?: string }> 155 + > { 156 + const store = await loadOAuthStore(); 157 + return Object.keys(store.sessions).map((did) => ({ 158 + did, 159 + handle: store.handles?.[did], 160 + })); 161 + }
+6 -6
packages/cli/src/lib/prompts.ts
··· 1 - import { isCancel, cancel } from "@clack/prompts"; 1 + import { cancel, isCancel } from "@clack/prompts"; 2 2 3 3 export function exitOnCancel<T>(value: T | symbol): T { 4 - if (isCancel(value)) { 5 - cancel("Cancelled"); 6 - process.exit(0); 7 - } 8 - return value as T; 4 + if (isCancel(value)) { 5 + cancel("Cancelled"); 6 + process.exit(0); 7 + } 8 + return value as T; 9 9 }
+62 -1
packages/cli/src/lib/types.ts
··· 4 4 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at") 5 5 coverImage?: string; // Field name for cover image (default: "ogImage") 6 6 tags?: string; // Field name for tags (default: "tags") 7 + draft?: string; // Field name for draft status (default: "draft") 8 + slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath) 9 + } 10 + 11 + // Strong reference for Bluesky post (com.atproto.repo.strongRef) 12 + export interface StrongRef { 13 + uri: string; // at:// URI format 14 + cid: string; // Content ID 15 + } 16 + 17 + // Bluesky posting configuration 18 + export interface BlueskyConfig { 19 + enabled: boolean; 20 + maxAgeDays?: number; // Only post if published within N days (default: 7) 21 + } 22 + 23 + // UI components configuration 24 + export interface UIConfig { 25 + components: string; // Directory to install UI components (default: src/components) 7 26 } 8 27 9 28 export interface PublisherConfig { ··· 18 37 identity?: string; // Which stored identity to use (matches identifier) 19 38 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 20 39 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 40 + removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 41 + stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 42 + textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 43 + bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 44 + ui?: UIConfig; // Optional UI components configuration 21 45 } 22 46 23 - export interface Credentials { 47 + // Legacy credentials format (for backward compatibility during migration) 48 + export interface LegacyCredentials { 49 + pdsUrl: string; 50 + identifier: string; 51 + password: string; 52 + } 53 + 54 + // App password credentials (explicit type) 55 + export interface AppPasswordCredentials { 56 + type: "app-password"; 24 57 pdsUrl: string; 25 58 identifier: string; 26 59 password: string; 27 60 } 28 61 62 + // OAuth credentials (references stored OAuth session) 63 + // Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID 64 + export interface OAuthCredentials { 65 + type: "oauth"; 66 + did: string; 67 + handle: string; 68 + } 69 + 70 + // Union type for all credential types 71 + export type Credentials = AppPasswordCredentials | OAuthCredentials; 72 + 73 + // Helper to check credential type 74 + export function isOAuthCredentials( 75 + creds: Credentials, 76 + ): creds is OAuthCredentials { 77 + return creds.type === "oauth"; 78 + } 79 + 80 + export function isAppPasswordCredentials( 81 + creds: Credentials, 82 + ): creds is AppPasswordCredentials { 83 + return creds.type === "app-password"; 84 + } 85 + 29 86 export interface PostFrontmatter { 30 87 title: string; 31 88 description?: string; ··· 33 90 tags?: string[]; 34 91 ogImage?: string; 35 92 atUri?: string; 93 + draft?: boolean; 36 94 } 37 95 38 96 export interface BlogPost { ··· 41 99 frontmatter: PostFrontmatter; 42 100 content: string; 43 101 rawContent: string; 102 + rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField 44 103 } 45 104 46 105 export interface BlobRef { ··· 62 121 contentHash: string; 63 122 atUri?: string; 64 123 lastPublished?: string; 124 + slug?: string; // The generated slug for this post (used by inject command) 125 + bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 65 126 } 66 127 67 128 export interface PublicationRecord {
+43
packages/cli/test.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Sequoia Comments Test</title> 7 + <!-- Link to a published document - replace with your own AT URI --> 8 + <link rel="site.standard.document" href="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v"> 9 + <style> 10 + body { 11 + font-family: system-ui, -apple-system, sans-serif; 12 + max-width: 800px; 13 + margin: 2rem auto; 14 + padding: 0 1rem; 15 + line-height: 1.6; 16 + background-color: #1A1A1A; 17 + color: #F5F3EF; 18 + } 19 + h1 { 20 + margin-bottom: 2rem; 21 + } 22 + /* Custom styling example */ 23 + :root { 24 + --sequoia-accent-color: #3A5A40; 25 + --sequoia-border-radius: 12px; 26 + --sequoia-bg-color: #1a1a1a; 27 + --sequoia-fg-color: #F5F3EF; 28 + --sequoia-border-color: #333; 29 + --sequoia-secondary-color: #8B7355; 30 + } 31 + </style> 32 + </head> 33 + <body> 34 + <h1>Blog Post Title</h1> 35 + <p>This is a test page for the sequoia-comments web component.</p> 36 + <p>The component will look for a <code>&lt;link rel="site.standard.document"&gt;</code> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.</p> 37 + 38 + <h2>Comments</h2> 39 + <sequoia-comments></sequoia-comments> 40 + 41 + <script type="module" src="./src/components/sequoia-comments.js"></script> 42 + </body> 43 + </html>
+20 -20
packages/cli/tsconfig.json
··· 1 1 { 2 - "compilerOptions": { 3 - "lib": ["ES2022"], 4 - "target": "ES2022", 5 - "module": "ESNext", 6 - "moduleResolution": "bundler", 7 - "outDir": "./dist", 8 - "rootDir": "./src", 9 - "declaration": true, 10 - "sourceMap": true, 11 - "strict": true, 12 - "skipLibCheck": true, 13 - "esModuleInterop": true, 14 - "resolveJsonModule": true, 15 - "forceConsistentCasingInFileNames": true, 16 - "noFallthroughCasesInSwitch": true, 17 - "noUncheckedIndexedAccess": true, 18 - "noUnusedLocals": false, 19 - "noUnusedParameters": false 20 - }, 21 - "include": ["src"] 2 + "compilerOptions": { 3 + "lib": ["ES2022"], 4 + "target": "ES2022", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "outDir": "./dist", 8 + "rootDir": "./src", 9 + "declaration": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "skipLibCheck": true, 13 + "esModuleInterop": true, 14 + "resolveJsonModule": true, 15 + "forceConsistentCasingInFileNames": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "noUncheckedIndexedAccess": true, 18 + "noUnusedLocals": false, 19 + "noUnusedParameters": false 20 + }, 21 + "include": ["src"] 22 22 }