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

chore: tested comments in docs

authored by stevedylan.dev and committed by tangled.org 6f2a9dde c3f94619

+828 -13
+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>
+796
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 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 103 + } 104 + 105 + .sequoia-comments-title { 106 + font-size: 1.125rem; 107 + font-weight: 600; 108 + margin: 0; 109 + } 110 + 111 + .sequoia-reply-button { 112 + display: inline-flex; 113 + align-items: center; 114 + gap: 0.375rem; 115 + padding: 0.5rem 1rem; 116 + background: var(--sequoia-accent-color, #2563eb); 117 + color: #ffffff; 118 + border: none; 119 + border-radius: var(--sequoia-border-radius, 8px); 120 + font-size: 0.875rem; 121 + font-weight: 500; 122 + cursor: pointer; 123 + text-decoration: none; 124 + transition: background-color 0.15s ease; 125 + } 126 + 127 + .sequoia-reply-button:hover { 128 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 129 + } 130 + 131 + .sequoia-reply-button svg { 132 + width: 1rem; 133 + height: 1rem; 134 + } 135 + 136 + .sequoia-comments-list { 137 + display: flex; 138 + flex-direction: column; 139 + gap: 0; 140 + } 141 + 142 + .sequoia-comment { 143 + padding: 1rem; 144 + background: var(--sequoia-bg-color, #ffffff); 145 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 146 + border-radius: var(--sequoia-border-radius, 8px); 147 + margin-bottom: 0.75rem; 148 + } 149 + 150 + .sequoia-comment-header { 151 + display: flex; 152 + align-items: center; 153 + gap: 0.75rem; 154 + margin-bottom: 0.5rem; 155 + } 156 + 157 + .sequoia-comment-avatar { 158 + width: 2.5rem; 159 + height: 2.5rem; 160 + border-radius: 50%; 161 + background: var(--sequoia-border-color, #e5e7eb); 162 + object-fit: cover; 163 + flex-shrink: 0; 164 + } 165 + 166 + .sequoia-comment-avatar-placeholder { 167 + width: 2.5rem; 168 + height: 2.5rem; 169 + border-radius: 50%; 170 + background: var(--sequoia-border-color, #e5e7eb); 171 + display: flex; 172 + align-items: center; 173 + justify-content: center; 174 + flex-shrink: 0; 175 + color: var(--sequoia-secondary-color, #6b7280); 176 + font-weight: 600; 177 + font-size: 1rem; 178 + } 179 + 180 + .sequoia-comment-meta { 181 + display: flex; 182 + flex-direction: column; 183 + min-width: 0; 184 + } 185 + 186 + .sequoia-comment-author { 187 + font-weight: 600; 188 + color: var(--sequoia-fg-color, #1f2937); 189 + text-decoration: none; 190 + overflow: hidden; 191 + text-overflow: ellipsis; 192 + white-space: nowrap; 193 + } 194 + 195 + .sequoia-comment-author:hover { 196 + color: var(--sequoia-accent-color, #2563eb); 197 + } 198 + 199 + .sequoia-comment-handle { 200 + font-size: 0.875rem; 201 + color: var(--sequoia-secondary-color, #6b7280); 202 + overflow: hidden; 203 + text-overflow: ellipsis; 204 + white-space: nowrap; 205 + } 206 + 207 + .sequoia-comment-time { 208 + font-size: 0.75rem; 209 + color: var(--sequoia-secondary-color, #6b7280); 210 + margin-left: auto; 211 + flex-shrink: 0; 212 + } 213 + 214 + .sequoia-comment-text { 215 + margin: 0; 216 + white-space: pre-wrap; 217 + word-wrap: break-word; 218 + } 219 + 220 + .sequoia-comment-text a { 221 + color: var(--sequoia-accent-color, #2563eb); 222 + text-decoration: none; 223 + } 224 + 225 + .sequoia-comment-text a:hover { 226 + text-decoration: underline; 227 + } 228 + 229 + .sequoia-comment-replies { 230 + margin-top: 0.75rem; 231 + margin-left: 1.5rem; 232 + padding-left: 1rem; 233 + border-left: 2px solid var(--sequoia-border-color, #e5e7eb); 234 + } 235 + 236 + .sequoia-comment-replies .sequoia-comment { 237 + margin-bottom: 0.5rem; 238 + } 239 + 240 + .sequoia-comment-replies .sequoia-comment:last-child { 241 + margin-bottom: 0; 242 + } 243 + 244 + .sequoia-bsky-logo { 245 + width: 1rem; 246 + height: 1rem; 247 + } 248 + `; 249 + 250 + // ============================================================================ 251 + // Utility Functions 252 + // ============================================================================ 253 + 254 + /** 255 + * Format a relative time string (e.g., "2 hours ago") 256 + * @param {string} dateString - ISO date string 257 + * @returns {string} Formatted relative time 258 + */ 259 + function formatRelativeTime(dateString) { 260 + const date = new Date(dateString); 261 + const now = new Date(); 262 + const diffMs = now.getTime() - date.getTime(); 263 + const diffSeconds = Math.floor(diffMs / 1000); 264 + const diffMinutes = Math.floor(diffSeconds / 60); 265 + const diffHours = Math.floor(diffMinutes / 60); 266 + const diffDays = Math.floor(diffHours / 24); 267 + const diffWeeks = Math.floor(diffDays / 7); 268 + const diffMonths = Math.floor(diffDays / 30); 269 + const diffYears = Math.floor(diffDays / 365); 270 + 271 + if (diffSeconds < 60) { 272 + return "just now"; 273 + } 274 + if (diffMinutes < 60) { 275 + return `${diffMinutes}m ago`; 276 + } 277 + if (diffHours < 24) { 278 + return `${diffHours}h ago`; 279 + } 280 + if (diffDays < 7) { 281 + return `${diffDays}d ago`; 282 + } 283 + if (diffWeeks < 4) { 284 + return `${diffWeeks}w ago`; 285 + } 286 + if (diffMonths < 12) { 287 + return `${diffMonths}mo ago`; 288 + } 289 + return `${diffYears}y ago`; 290 + } 291 + 292 + /** 293 + * Escape HTML special characters 294 + * @param {string} text - Text to escape 295 + * @returns {string} Escaped HTML 296 + */ 297 + function escapeHtml(text) { 298 + const div = document.createElement("div"); 299 + div.textContent = text; 300 + return div.innerHTML; 301 + } 302 + 303 + /** 304 + * Convert post text with facets to HTML 305 + * @param {string} text - Post text 306 + * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 307 + * @returns {string} HTML string with links 308 + */ 309 + function renderTextWithFacets(text, facets) { 310 + if (!facets || facets.length === 0) { 311 + return escapeHtml(text); 312 + } 313 + 314 + // Convert text to bytes for proper indexing 315 + const encoder = new TextEncoder(); 316 + const decoder = new TextDecoder(); 317 + const textBytes = encoder.encode(text); 318 + 319 + // Sort facets by start index 320 + const sortedFacets = [...facets].sort( 321 + (a, b) => a.index.byteStart - b.index.byteStart 322 + ); 323 + 324 + let result = ""; 325 + let lastEnd = 0; 326 + 327 + for (const facet of sortedFacets) { 328 + const { byteStart, byteEnd } = facet.index; 329 + 330 + // Add text before this facet 331 + if (byteStart > lastEnd) { 332 + const beforeBytes = textBytes.slice(lastEnd, byteStart); 333 + result += escapeHtml(decoder.decode(beforeBytes)); 334 + } 335 + 336 + // Get the facet text 337 + const facetBytes = textBytes.slice(byteStart, byteEnd); 338 + const facetText = decoder.decode(facetBytes); 339 + 340 + // Find the first renderable feature 341 + const feature = facet.features[0]; 342 + if (feature) { 343 + if (feature.$type === "app.bsky.richtext.facet#link") { 344 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 345 + } else if (feature.$type === "app.bsky.richtext.facet#mention") { 346 + result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 347 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 348 + result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 349 + } else { 350 + result += escapeHtml(facetText); 351 + } 352 + } else { 353 + result += escapeHtml(facetText); 354 + } 355 + 356 + lastEnd = byteEnd; 357 + } 358 + 359 + // Add remaining text 360 + if (lastEnd < textBytes.length) { 361 + const remainingBytes = textBytes.slice(lastEnd); 362 + result += escapeHtml(decoder.decode(remainingBytes)); 363 + } 364 + 365 + return result; 366 + } 367 + 368 + /** 369 + * Get initials from a name for avatar placeholder 370 + * @param {string} name - Display name 371 + * @returns {string} Initials (1-2 characters) 372 + */ 373 + function getInitials(name) { 374 + const parts = name.trim().split(/\s+/); 375 + if (parts.length >= 2) { 376 + return (parts[0][0] + parts[1][0]).toUpperCase(); 377 + } 378 + return name.substring(0, 2).toUpperCase(); 379 + } 380 + 381 + // ============================================================================ 382 + // AT Protocol Client Functions 383 + // ============================================================================ 384 + 385 + /** 386 + * Parse an AT URI into its components 387 + * Format: at://did/collection/rkey 388 + * @param {string} atUri - AT Protocol URI 389 + * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 390 + */ 391 + function parseAtUri(atUri) { 392 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 393 + if (!match) return null; 394 + return { 395 + did: match[1], 396 + collection: match[2], 397 + rkey: match[3], 398 + }; 399 + } 400 + 401 + /** 402 + * Resolve a DID to its PDS URL 403 + * Supports did:plc and did:web methods 404 + * @param {string} did - Decentralized Identifier 405 + * @returns {Promise<string>} PDS URL 406 + */ 407 + async function resolvePDS(did) { 408 + let pdsUrl; 409 + 410 + if (did.startsWith("did:plc:")) { 411 + // Fetch DID document from plc.directory 412 + const didDocUrl = `https://plc.directory/${did}`; 413 + const didDocResponse = await fetch(didDocUrl); 414 + if (!didDocResponse.ok) { 415 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 416 + } 417 + const didDoc = await didDocResponse.json(); 418 + 419 + // Find the PDS service endpoint 420 + const pdsService = didDoc.service?.find( 421 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" 422 + ); 423 + pdsUrl = pdsService?.serviceEndpoint; 424 + } else if (did.startsWith("did:web:")) { 425 + // For did:web, fetch the DID document from the domain 426 + const domain = did.replace("did:web:", ""); 427 + const didDocUrl = `https://${domain}/.well-known/did.json`; 428 + const didDocResponse = await fetch(didDocUrl); 429 + if (!didDocResponse.ok) { 430 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 431 + } 432 + const didDoc = await didDocResponse.json(); 433 + 434 + const pdsService = didDoc.service?.find( 435 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" 436 + ); 437 + pdsUrl = pdsService?.serviceEndpoint; 438 + } else { 439 + throw new Error(`Unsupported DID method: ${did}`); 440 + } 441 + 442 + if (!pdsUrl) { 443 + throw new Error("Could not find PDS URL for user"); 444 + } 445 + 446 + return pdsUrl; 447 + } 448 + 449 + /** 450 + * Fetch a record from a PDS using the public API 451 + * @param {string} did - DID of the repository owner 452 + * @param {string} collection - Collection name 453 + * @param {string} rkey - Record key 454 + * @returns {Promise<any>} Record value 455 + */ 456 + async function getRecord(did, collection, rkey) { 457 + const pdsUrl = await resolvePDS(did); 458 + 459 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 460 + url.searchParams.set("repo", did); 461 + url.searchParams.set("collection", collection); 462 + url.searchParams.set("rkey", rkey); 463 + 464 + const response = await fetch(url.toString()); 465 + if (!response.ok) { 466 + throw new Error(`Failed to fetch record: ${response.status}`); 467 + } 468 + 469 + const data = await response.json(); 470 + return data.value; 471 + } 472 + 473 + /** 474 + * Fetch a document record from its AT URI 475 + * @param {string} atUri - AT Protocol URI for the document 476 + * @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 477 + */ 478 + async function getDocument(atUri) { 479 + const parsed = parseAtUri(atUri); 480 + if (!parsed) { 481 + throw new Error(`Invalid AT URI: ${atUri}`); 482 + } 483 + 484 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 485 + } 486 + 487 + /** 488 + * Fetch a post thread from the public Bluesky API 489 + * @param {string} postUri - AT Protocol URI for the post 490 + * @param {number} [depth=6] - Maximum depth of replies to fetch 491 + * @returns {Promise<ThreadViewPost>} Thread view post 492 + */ 493 + async function getPostThread(postUri, depth = 6) { 494 + const url = new URL( 495 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread" 496 + ); 497 + url.searchParams.set("uri", postUri); 498 + url.searchParams.set("depth", depth.toString()); 499 + 500 + const response = await fetch(url.toString()); 501 + if (!response.ok) { 502 + throw new Error(`Failed to fetch post thread: ${response.status}`); 503 + } 504 + 505 + const data = await response.json(); 506 + 507 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 508 + throw new Error("Post not found or blocked"); 509 + } 510 + 511 + return data.thread; 512 + } 513 + 514 + /** 515 + * Build a Bluesky app URL for a post 516 + * @param {string} postUri - AT Protocol URI for the post 517 + * @returns {string} Bluesky app URL 518 + */ 519 + function buildBskyAppUrl(postUri) { 520 + const parsed = parseAtUri(postUri); 521 + if (!parsed) { 522 + throw new Error(`Invalid post URI: ${postUri}`); 523 + } 524 + 525 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 526 + } 527 + 528 + /** 529 + * Type guard for ThreadViewPost 530 + * @param {any} post - Post to check 531 + * @returns {boolean} True if post is a ThreadViewPost 532 + */ 533 + function isThreadViewPost(post) { 534 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 535 + } 536 + 537 + // ============================================================================ 538 + // Bluesky Icon 539 + // ============================================================================ 540 + 541 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 542 + <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"/> 543 + </svg>`; 544 + 545 + // ============================================================================ 546 + // Web Component 547 + // ============================================================================ 548 + 549 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 550 + const BaseElement = 551 + typeof HTMLElement !== "undefined" 552 + ? HTMLElement 553 + : class {}; 554 + 555 + class SequoiaComments extends BaseElement { 556 + constructor() { 557 + super(); 558 + this.shadow = this.attachShadow({ mode: "open" }); 559 + this.state = { type: "loading" }; 560 + this.abortController = null; 561 + } 562 + 563 + static get observedAttributes() { 564 + return ["document-uri", "depth"]; 565 + } 566 + 567 + connectedCallback() { 568 + this.render(); 569 + this.loadComments(); 570 + } 571 + 572 + disconnectedCallback() { 573 + this.abortController?.abort(); 574 + } 575 + 576 + attributeChangedCallback() { 577 + if (this.isConnected) { 578 + this.loadComments(); 579 + } 580 + } 581 + 582 + get documentUri() { 583 + // First check attribute 584 + const attrUri = this.getAttribute("document-uri"); 585 + if (attrUri) { 586 + return attrUri; 587 + } 588 + 589 + // Then scan for link tag in document head 590 + const linkTag = document.querySelector( 591 + 'link[rel="site.standard.document"]' 592 + ); 593 + return linkTag?.href ?? null; 594 + } 595 + 596 + get depth() { 597 + const depthAttr = this.getAttribute("depth"); 598 + return depthAttr ? parseInt(depthAttr, 10) : 6; 599 + } 600 + 601 + async loadComments() { 602 + // Cancel any in-flight request 603 + this.abortController?.abort(); 604 + this.abortController = new AbortController(); 605 + 606 + this.state = { type: "loading" }; 607 + this.render(); 608 + 609 + const docUri = this.documentUri; 610 + if (!docUri) { 611 + this.state = { type: "no-document" }; 612 + this.render(); 613 + return; 614 + } 615 + 616 + try { 617 + // Fetch the document record 618 + const document = await getDocument(docUri); 619 + 620 + // Check if document has a Bluesky post reference 621 + if (!document.bskyPostRef) { 622 + this.state = { type: "no-comments-enabled" }; 623 + this.render(); 624 + return; 625 + } 626 + 627 + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 628 + 629 + // Fetch the post thread 630 + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 631 + 632 + // Check if there are any replies 633 + const replies = thread.replies?.filter(isThreadViewPost) ?? []; 634 + if (replies.length === 0) { 635 + this.state = { type: "empty", postUrl }; 636 + this.render(); 637 + return; 638 + } 639 + 640 + this.state = { type: "loaded", thread, postUrl }; 641 + this.render(); 642 + } catch (error) { 643 + const message = 644 + error instanceof Error ? error.message : "Failed to load comments"; 645 + this.state = { type: "error", message }; 646 + this.render(); 647 + } 648 + } 649 + 650 + render() { 651 + const styleTag = `<style>${styles}</style>`; 652 + 653 + switch (this.state.type) { 654 + case "loading": 655 + this.shadow.innerHTML = ` 656 + ${styleTag} 657 + <div class="sequoia-comments-container"> 658 + <div class="sequoia-loading"> 659 + <span class="sequoia-loading-spinner"></span> 660 + Loading comments... 661 + </div> 662 + </div> 663 + `; 664 + break; 665 + 666 + case "no-document": 667 + this.shadow.innerHTML = ` 668 + ${styleTag} 669 + <div class="sequoia-comments-container"> 670 + <div class="sequoia-warning"> 671 + No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 672 + </div> 673 + </div> 674 + `; 675 + break; 676 + 677 + case "no-comments-enabled": 678 + this.shadow.innerHTML = ` 679 + ${styleTag} 680 + <div class="sequoia-comments-container"> 681 + <div class="sequoia-empty"> 682 + Comments are not enabled for this post. 683 + </div> 684 + </div> 685 + `; 686 + break; 687 + 688 + case "empty": 689 + this.shadow.innerHTML = ` 690 + ${styleTag} 691 + <div class="sequoia-comments-container"> 692 + <div class="sequoia-comments-header"> 693 + <h3 class="sequoia-comments-title">Comments</h3> 694 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 695 + ${BLUESKY_ICON} 696 + Reply on Bluesky 697 + </a> 698 + </div> 699 + <div class="sequoia-empty"> 700 + No comments yet. Be the first to reply on Bluesky! 701 + </div> 702 + </div> 703 + `; 704 + break; 705 + 706 + case "error": 707 + this.shadow.innerHTML = ` 708 + ${styleTag} 709 + <div class="sequoia-comments-container"> 710 + <div class="sequoia-error"> 711 + Failed to load comments: ${escapeHtml(this.state.message)} 712 + </div> 713 + </div> 714 + `; 715 + break; 716 + 717 + case "loaded": { 718 + const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; 719 + const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); 720 + const commentCount = this.countComments(replies); 721 + 722 + this.shadow.innerHTML = ` 723 + ${styleTag} 724 + <div class="sequoia-comments-container"> 725 + <div class="sequoia-comments-header"> 726 + <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 727 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 728 + ${BLUESKY_ICON} 729 + Reply on Bluesky 730 + </a> 731 + </div> 732 + <div class="sequoia-comments-list"> 733 + ${commentsHtml} 734 + </div> 735 + </div> 736 + `; 737 + break; 738 + } 739 + } 740 + } 741 + 742 + renderComment(thread) { 743 + const { post } = thread; 744 + const author = post.author; 745 + const displayName = author.displayName || author.handle; 746 + const avatarHtml = author.avatar 747 + ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 748 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 749 + 750 + const profileUrl = `https://bsky.app/profile/${author.did}`; 751 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 752 + const timeAgo = formatRelativeTime(post.record.createdAt); 753 + 754 + // Render nested replies 755 + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 756 + const repliesHtml = 757 + nestedReplies.length > 0 758 + ? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>` 759 + : ""; 760 + 761 + return ` 762 + <div class="sequoia-comment"> 763 + <div class="sequoia-comment-header"> 764 + ${avatarHtml} 765 + <div class="sequoia-comment-meta"> 766 + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 767 + ${escapeHtml(displayName)} 768 + </a> 769 + <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 770 + </div> 771 + <span class="sequoia-comment-time">${timeAgo}</span> 772 + </div> 773 + <p class="sequoia-comment-text">${textHtml}</p> 774 + ${repliesHtml} 775 + </div> 776 + `; 777 + } 778 + 779 + countComments(replies) { 780 + let count = 0; 781 + for (const reply of replies) { 782 + count += 1; 783 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 784 + count += this.countComments(nested); 785 + } 786 + return count; 787 + } 788 + } 789 + 790 + // Register the custom element 791 + if (typeof customElements !== "undefined") { 792 + customElements.define("sequoia-comments", SequoiaComments); 793 + } 794 + 795 + // Export for module usage 796 + 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 + }