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

Compare changes

Choose any two refs to compare.

+2183 -20
-1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 - packages/ui
+18 -2
CHANGELOG.md
··· 1 - ## [0.3.3.] - 2026-02-04 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 2 18 3 19 ### โš™๏ธ Miscellaneous Tasks 4 20 5 21 - Cleaned up remaining auth implementations 6 22 - Format 7 - 23 + - Release 0.3.3 8 24 ## [0.3.2] - 2026-02-05 9 25 10 26 ### ๐Ÿ› Bug Fixes
+6
docs/docs/pages/blog/introducing-sequoia.mdx
··· 52 52 bun i -g sequoia-cli 53 53 ``` 54 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>
+18
docs/docs/pages/cli-reference.mdx
··· 32 32 33 33 Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 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 + 35 53 ## `init` 36 54 37 55 ```bash [Terminal]
+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 + ```
+6 -1
docs/docs/pages/config.mdx
··· 19 19 | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 20 | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 21 | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 - | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) | 23 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 | 24 26 25 27 ### Example 26 28 ··· 41 43 "bluesky": { 42 44 "enabled": true, 43 45 "maxAgeDays": 30 46 + }, 47 + "ui": { 48 + "components": "src/components" 44 49 } 45 50 } 46 51 ```
+6
docs/docs/pages/publishing.mdx
··· 66 66 } 67 67 ``` 68 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. 74 + 69 75 ## Troubleshooting 70 76 71 77 - If you have files in your markdown directory that should be ignored, use the [`ignore` array in the config](/config#ignoring-files).
+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 ],
+2 -2
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.3.3", 3 + "version": "0.4.0", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js" ··· 16 16 "scripts": { 17 17 "lint": "biome lint --write", 18 18 "format": "biome format --write", 19 - "build": "bun build src/index.ts --target node --outdir dist", 19 + "build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/", 20 20 "dev": "bun run build && bun link", 21 21 "deploy": "bun run build && bun publish" 22 22 },
+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 + });
+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 };
+3 -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"; ··· 35 36 36 37 > https://tangled.org/stevedylan.dev/sequoia 37 38 `, 38 - version: "0.3.3", 39 + version: "0.4.0", 39 40 cmds: { 41 + add: addCommand, 40 42 auth: authCommand, 41 43 init: initCommand, 42 44 inject: injectCommand,
+6
packages/cli/src/lib/types.ts
··· 20 20 maxAgeDays?: number; // Only post if published within N days (default: 7) 21 21 } 22 22 23 + // UI components configuration 24 + export interface UIConfig { 25 + components: string; // Directory to install UI components (default: src/components) 26 + } 27 + 23 28 export interface PublisherConfig { 24 29 siteUrl: string; 25 30 contentDir: string; ··· 36 41 stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 37 42 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 38 43 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 44 + ui?: UIConfig; // Optional UI components configuration 39 45 } 40 46 41 47 // Legacy credentials format (for backward compatibility during migration)
+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>