A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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 * Custom reply button:
15 * Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons.
16 * It stays in the light DOM, so your page CSS applies to it normally.
17 * Only practical with post-uri, since that's the only time the URL is known at authoring time:
18 * <sequoia-comments post-uri="https://bsky.app/profile/.../post/...">
19 * <a slot="reply-button" href="https://bsky.app/profile/.../post/...">Reply</a>
20 * </sequoia-comments>
21 *
22 * Attributes:
23 * - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup
24 * - document-uri: AT Protocol URI for the document (optional if link tag exists)
25 * - depth: Maximum depth of nested replies to fetch (default: 6)
26 * - hide: Set to "auto" to hide if no document link is detected
27 *
28 * CSS Custom Properties:
29 * - --sequoia-fg-color: Text color (default: #1f2937)
30 * - --sequoia-bg-color: Background color (default: #ffffff)
31 * - --sequoia-border-color: Border color (default: #e5e7eb)
32 * - --sequoia-accent-color: Accent/link color (default: #2563eb)
33 * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
34 * - --sequoia-font-family: Font family (default: system-ui stack)
35 * - --sequoia-border-radius: Border radius (default: 8px)
36 */
37
38// ============================================================================
39// Styles
40// ============================================================================
41
42const styles = `
43:host {
44 display: block;
45 font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
46 color: var(--sequoia-fg-color, #1f2937);
47 line-height: 1.5;
48}
49
50* {
51 box-sizing: border-box;
52}
53
54.sequoia-comments-container {
55 max-width: 100%;
56}
57
58.sequoia-loading,
59.sequoia-error,
60.sequoia-empty,
61.sequoia-warning {
62 padding: 1rem;
63 border-radius: var(--sequoia-border-radius, 8px);
64 text-align: center;
65}
66
67.sequoia-loading {
68 background: var(--sequoia-bg-color, #ffffff);
69 border: 1px solid var(--sequoia-border-color, #e5e7eb);
70 color: var(--sequoia-secondary-color, #6b7280);
71}
72
73.sequoia-loading-spinner {
74 display: inline-block;
75 width: 1.25rem;
76 height: 1.25rem;
77 border: 2px solid var(--sequoia-border-color, #e5e7eb);
78 border-top-color: var(--sequoia-accent-color, #2563eb);
79 border-radius: 50%;
80 animation: sequoia-spin 0.8s linear infinite;
81 margin-right: 0.5rem;
82 vertical-align: middle;
83}
84
85@keyframes sequoia-spin {
86 to { transform: rotate(360deg); }
87}
88
89.sequoia-error {
90 background: #fef2f2;
91 border: 1px solid #fecaca;
92 color: #dc2626;
93}
94
95.sequoia-warning {
96 background: #fffbeb;
97 border: 1px solid #fde68a;
98 color: #d97706;
99}
100
101.sequoia-empty {
102 background: var(--sequoia-bg-color, #ffffff);
103 border: 1px solid var(--sequoia-border-color, #e5e7eb);
104 color: var(--sequoia-secondary-color, #6b7280);
105}
106
107.sequoia-comments-header {
108 display: flex;
109 justify-content: space-between;
110 align-items: center;
111 margin-bottom: 1rem;
112 padding-bottom: 0.75rem;
113}
114
115.sequoia-comments-title {
116 font-size: 1.125rem;
117 font-weight: 600;
118 margin: 0;
119}
120
121.sequoia-reply-button {
122 display: inline-flex;
123 align-items: center;
124 gap: 0.375rem;
125 padding: 0.5rem 1rem;
126 border: none;
127 border-radius: var(--sequoia-border-radius, 15px);
128 font-size: 0.875rem;
129 font-weight: 500;
130 cursor: pointer;
131 text-decoration: none;
132 transition: background-color 0.15s ease;
133 margin-left:10px;
134}
135
136.sequoia-reply-bluesky {
137 background: var(--sequoia-accent-color, #2563eb);
138 color: #ffffff;
139}
140
141.sequoia-reply-blacksky {
142 background: var(--sequoia-accent-color, #6060E9);
143 color: #ffffff;
144}
145
146.sequoia-reply-bluesky:hover {
147 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
148}
149
150.sequoia-reply-blacksky:hover {
151 background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black);
152}
153
154.sequoia-reply-button svg {
155 width: 1rem;
156 height: 1rem;
157}
158
159.sequoia-comments-list {
160 display: flex;
161 flex-direction: column;
162}
163
164.sequoia-thread {
165 border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
166 padding-bottom: 1rem;
167}
168
169.sequoia-thread + .sequoia-thread {
170 margin-top: 0.5rem;
171}
172
173.sequoia-thread:last-child {
174 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
175}
176
177.sequoia-comment {
178 display: flex;
179 gap: 0.75rem;
180 padding-top: 1rem;
181}
182
183.sequoia-comment-avatar-column {
184 display: flex;
185 flex-direction: column;
186 align-items: center;
187 flex-shrink: 0;
188 width: 2.5rem;
189 position: relative;
190}
191
192.sequoia-comment-avatar {
193 width: 2.5rem;
194 height: 2.5rem;
195 border-radius: 50%;
196 background: var(--sequoia-border-color, #e5e7eb);
197 object-fit: cover;
198 flex-shrink: 0;
199 position: relative;
200 z-index: 1;
201}
202
203.sequoia-comment-avatar-placeholder {
204 width: 2.5rem;
205 height: 2.5rem;
206 border-radius: 50%;
207 background: var(--sequoia-border-color, #e5e7eb);
208 display: flex;
209 align-items: center;
210 justify-content: center;
211 flex-shrink: 0;
212 color: var(--sequoia-secondary-color, #6b7280);
213 font-weight: 600;
214 font-size: 1rem;
215 position: relative;
216 z-index: 1;
217}
218
219.sequoia-thread-line {
220 position: absolute;
221 top: 2.5rem;
222 bottom: calc(-1rem - 0.5rem);
223 left: 50%;
224 transform: translateX(-50%);
225 width: 2px;
226 background: var(--sequoia-border-color, #e5e7eb);
227}
228
229.sequoia-comment-content {
230 flex: 1;
231 min-width: 0;
232}
233
234.sequoia-comment-header {
235 display: flex;
236 align-items: baseline;
237 gap: 0.5rem;
238 margin-bottom: 0.25rem;
239 flex-wrap: wrap;
240}
241
242.sequoia-comment-author {
243 font-weight: 600;
244 color: var(--sequoia-fg-color, #1f2937);
245 text-decoration: none;
246 overflow: hidden;
247 text-overflow: ellipsis;
248 white-space: nowrap;
249}
250
251.sequoia-comment-author:hover {
252 color: var(--sequoia-accent-color, #2563eb);
253}
254
255.sequoia-comment-handle {
256 font-size: 0.875rem;
257 color: var(--sequoia-secondary-color, #6b7280);
258 overflow: hidden;
259 text-overflow: ellipsis;
260 white-space: nowrap;
261}
262
263.sequoia-comment-handle::after {
264 content: "·";
265 margin-left: 0.5rem;
266}
267
268.sequoia-comment-time {
269 font-size: 0.875rem;
270 color: var(--sequoia-secondary-color, #6b7280);
271 flex-shrink: 0;
272}
273
274.sequoia-comment-text {
275 margin: 0;
276 white-space: pre-wrap;
277 word-wrap: break-word;
278}
279
280.sequoia-comment-text a {
281 color: var(--sequoia-accent-color, #2563eb);
282 text-decoration: none;
283}
284
285.sequoia-comment-text a:hover {
286 text-decoration: underline;
287}
288
289.sequoia-bsky-logo {
290 width: 1rem;
291 height: 1rem;
292}
293
294.sequoia-quotes-section {
295 margin-top: 1.75rem;
296}
297
298.sequoia-quotes-header {
299 font-size: 0.75rem;
300 font-weight: 600;
301 color: var(--sequoia-secondary-color, #6b7280);
302 letter-spacing: 0.05em;
303 text-transform: uppercase;
304 margin: 0;
305 padding-bottom: 0.75rem;
306 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
307}
308
309a.sequoia-comment-time {
310 text-decoration: none;
311 color: var(--sequoia-secondary-color, #6b7280);
312}
313
314a.sequoia-comment-time:hover {
315 text-decoration: underline;
316}
317`;
318
319// ============================================================================
320// Utility Functions
321// ============================================================================
322
323/**
324 * Format a relative time string (e.g., "2 hours ago")
325 * @param {string} dateString - ISO date string
326 * @returns {string} Formatted relative time
327 */
328function formatRelativeTime(dateString) {
329 const date = new Date(dateString);
330 const now = new Date();
331 const diffMs = now.getTime() - date.getTime();
332 const diffSeconds = Math.floor(diffMs / 1000);
333 const diffMinutes = Math.floor(diffSeconds / 60);
334 const diffHours = Math.floor(diffMinutes / 60);
335 const diffDays = Math.floor(diffHours / 24);
336 const diffWeeks = Math.floor(diffDays / 7);
337 const diffMonths = Math.floor(diffDays / 30);
338 const diffYears = Math.floor(diffDays / 365);
339
340 if (diffSeconds < 60) {
341 return "just now";
342 }
343 if (diffMinutes < 60) {
344 return `${diffMinutes}m ago`;
345 }
346 if (diffHours < 24) {
347 return `${diffHours}h ago`;
348 }
349 if (diffDays < 7) {
350 return `${diffDays}d ago`;
351 }
352 if (diffWeeks < 4) {
353 return `${diffWeeks}w ago`;
354 }
355 if (diffMonths < 12) {
356 return `${diffMonths}mo ago`;
357 }
358 return `${diffYears}y ago`;
359}
360
361/**
362 * Escape HTML special characters
363 * @param {string} text - Text to escape
364 * @returns {string} Escaped HTML
365 */
366function escapeHtml(text) {
367 const div = document.createElement("div");
368 div.textContent = text;
369 return div.innerHTML;
370}
371
372/**
373 * Convert post text with facets to HTML
374 * @param {string} text - Post text
375 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
376 * @returns {string} HTML string with links
377 */
378function renderTextWithFacets(text, facets) {
379 if (!facets || facets.length === 0) {
380 return escapeHtml(text);
381 }
382
383 // Convert text to bytes for proper indexing
384 const encoder = new TextEncoder();
385 const decoder = new TextDecoder();
386 const textBytes = encoder.encode(text);
387
388 // Sort facets by start index
389 const sortedFacets = [...facets].sort(
390 (a, b) => a.index.byteStart - b.index.byteStart,
391 );
392
393 let result = "";
394 let lastEnd = 0;
395
396 for (const facet of sortedFacets) {
397 const { byteStart, byteEnd } = facet.index;
398
399 // Add text before this facet
400 if (byteStart > lastEnd) {
401 const beforeBytes = textBytes.slice(lastEnd, byteStart);
402 result += escapeHtml(decoder.decode(beforeBytes));
403 }
404
405 // Get the facet text
406 const facetBytes = textBytes.slice(byteStart, byteEnd);
407 const facetText = decoder.decode(facetBytes);
408
409 // Find the first renderable feature
410 const feature = facet.features[0];
411 if (feature) {
412 if (feature.$type === "app.bsky.richtext.facet#link") {
413 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
414 } else if (feature.$type === "app.bsky.richtext.facet#mention") {
415 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
416 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
417 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
418 } else {
419 result += escapeHtml(facetText);
420 }
421 } else {
422 result += escapeHtml(facetText);
423 }
424
425 lastEnd = byteEnd;
426 }
427
428 // Add remaining text
429 if (lastEnd < textBytes.length) {
430 const remainingBytes = textBytes.slice(lastEnd);
431 result += escapeHtml(decoder.decode(remainingBytes));
432 }
433
434 return result;
435}
436
437/**
438 * Get initials from a name for avatar placeholder
439 * @param {string} name - Display name
440 * @returns {string} Initials (1-2 characters)
441 */
442function getInitials(name) {
443 const parts = name.trim().split(/\s+/);
444 if (parts.length >= 2) {
445 return (parts[0][0] + parts[1][0]).toUpperCase();
446 }
447 return name.substring(0, 2).toUpperCase();
448}
449
450// ============================================================================
451// AT Protocol Client Functions
452// ============================================================================
453
454/**
455 * Parse an AT URI into its components
456 * Format: at://did/collection/rkey
457 * @param {string} atUri - AT Protocol URI
458 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
459 */
460function parseAtUri(atUri) {
461 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
462 if (!match) return null;
463 return {
464 did: match[1],
465 collection: match[2],
466 rkey: match[3],
467 };
468}
469
470/**
471 * Resolve a DID to its PDS URL
472 * Supports did:plc and did:web methods
473 * @param {string} did - Decentralized Identifier
474 * @returns {Promise<string>} PDS URL
475 */
476async function resolvePDS(did) {
477 let pdsUrl;
478
479 if (did.startsWith("did:plc:")) {
480 // Fetch DID document from plc.directory
481 const didDocUrl = `https://plc.directory/${did}`;
482 const didDocResponse = await fetch(didDocUrl);
483 if (!didDocResponse.ok) {
484 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
485 }
486 const didDoc = await didDocResponse.json();
487
488 // Find the PDS service endpoint
489 const pdsService = didDoc.service?.find(
490 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
491 );
492 pdsUrl = pdsService?.serviceEndpoint;
493 } else if (did.startsWith("did:web:")) {
494 // For did:web, fetch the DID document from the domain
495 const domain = did.replace("did:web:", "");
496 const didDocUrl = `https://${domain}/.well-known/did.json`;
497 const didDocResponse = await fetch(didDocUrl);
498 if (!didDocResponse.ok) {
499 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
500 }
501 const didDoc = await didDocResponse.json();
502
503 const pdsService = didDoc.service?.find(
504 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
505 );
506 pdsUrl = pdsService?.serviceEndpoint;
507 } else {
508 throw new Error(`Unsupported DID method: ${did}`);
509 }
510
511 if (!pdsUrl) {
512 throw new Error("Could not find PDS URL for user");
513 }
514
515 return pdsUrl;
516}
517
518/**
519 * Fetch a record from a PDS using the public API
520 * @param {string} did - DID of the repository owner
521 * @param {string} collection - Collection name
522 * @param {string} rkey - Record key
523 * @returns {Promise<any>} Record value
524 */
525async function getRecord(did, collection, rkey) {
526 const pdsUrl = await resolvePDS(did);
527
528 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
529 url.searchParams.set("repo", did);
530 url.searchParams.set("collection", collection);
531 url.searchParams.set("rkey", rkey);
532
533 const response = await fetch(url.toString());
534 if (!response.ok) {
535 throw new Error(`Failed to fetch record: ${response.status}`);
536 }
537
538 const data = await response.json();
539 return data.value;
540}
541
542/**
543 * Fetch a document record from its AT URI
544 * @param {string} atUri - AT Protocol URI for the document
545 * @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
546 */
547async function getDocument(atUri) {
548 const parsed = parseAtUri(atUri);
549 if (!parsed) {
550 throw new Error(`Invalid AT URI: ${atUri}`);
551 }
552
553 return getRecord(parsed.did, parsed.collection, parsed.rkey);
554}
555
556/**
557 * Fetch a post thread from the public Bluesky API
558 * @param {string} postUri - AT Protocol URI for the post
559 * @param {number} [depth=6] - Maximum depth of replies to fetch
560 * @returns {Promise<ThreadViewPost>} Thread view post
561 */
562async function getPostThread(postUri, depth = 6) {
563 const url = new URL(
564 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
565 );
566 url.searchParams.set("uri", postUri);
567 url.searchParams.set("depth", depth.toString());
568
569 const response = await fetch(url.toString());
570 if (!response.ok) {
571 throw new Error(`Failed to fetch post thread: ${response.status}`);
572 }
573
574 const data = await response.json();
575
576 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
577 throw new Error("Post not found or blocked");
578 }
579
580 return data.thread;
581}
582
583/**
584 * Build a Bluesky app URL for a post
585 * @param {string} postUri - AT Protocol URI for the post
586 * @returns {string} Bluesky app URL
587 */
588function buildBskyAppUrl(postUri) {
589 const parsed = parseAtUri(postUri);
590 if (!parsed) {
591 throw new Error(`Invalid post URI: ${postUri}`);
592 }
593
594 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
595}
596
597/**
598 * Build a Blacksky app URL for a post
599 * @param {string} postUri - AT Protocol URI for the post
600 * @returns {string} Blacksky app URL
601 */
602function buildBlackskyAppUrl(postUri) {
603 const parsed = parseAtUri(postUri);
604 if (!parsed) {
605 throw new Error(`Invalid post URI: ${postUri}`);
606 }
607
608 return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`;
609}
610
611/**
612 * Type guard for ThreadViewPost
613 * @param {any} post - Post to check
614 * @returns {boolean} True if post is a ThreadViewPost
615 */
616function isThreadViewPost(post) {
617 return post?.$type === "app.bsky.feed.defs#threadViewPost";
618}
619
620/**
621 * Fetch all quote posts for a given post URI, paginating through all results.
622 * Uses the public Bluesky AppView — gaps are expected for posts from
623 * less-connected PDS instances.
624 * @param {string} postUri - AT Protocol URI for the post
625 * @returns {Promise<Array>} Array of PostView objects
626 */
627/**
628 * Normalise a user-supplied post reference to an AT-URI.
629 * Accepts:
630 * - AT-URIs as-is: at://did:plc:.../app.bsky.feed.post/rkey
631 * - bsky.app post URLs: https://bsky.app/profile/<handle-or-did>/post/<rkey>
632 * When the profile segment is already a DID no network request is made.
633 * @param {string} uriOrUrl
634 * @returns {Promise<string>} AT-URI
635 */
636async function resolvePostUri(uriOrUrl) {
637 if (uriOrUrl.startsWith("at://")) return uriOrUrl;
638
639 const match = uriOrUrl.match(
640 /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/,
641 );
642 if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`);
643
644 const [, handleOrDid, rkey] = match;
645
646 let did = handleOrDid;
647 if (!handleOrDid.startsWith("did:")) {
648 const url = new URL(
649 "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle",
650 );
651 url.searchParams.set("handle", handleOrDid);
652 const response = await fetch(url.toString());
653 if (!response.ok)
654 throw new Error(`Failed to resolve handle: ${response.status}`);
655 did = (await response.json()).did;
656 }
657
658 return `at://${did}/app.bsky.feed.post/${rkey}`;
659}
660
661async function getQuotes(postUri) {
662 const quotes = [];
663 let cursor;
664
665 do {
666 const url = new URL(
667 "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes",
668 );
669 url.searchParams.set("uri", postUri);
670 url.searchParams.set("limit", "100");
671 if (cursor) url.searchParams.set("cursor", cursor);
672
673 const response = await fetch(url.toString());
674 if (!response.ok) {
675 throw new Error(`Failed to fetch quotes: ${response.status}`);
676 }
677
678 const data = await response.json();
679 quotes.push(...(data.posts ?? []));
680 cursor = data.cursor;
681 } while (cursor);
682
683 return quotes;
684}
685
686// ============================================================================
687// Bluesky Icon
688// ============================================================================
689
690const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
691 <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"/>
692</svg>`;
693const BLACKSKY_ICON =
694 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z" fill="white"></path><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z" fill="white"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z" fill="white"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z" fill="white"/></svg>';
695
696// ============================================================================
697// Web Component
698// ============================================================================
699
700// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
701const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
702
703class SequoiaComments extends BaseElement {
704 constructor() {
705 super();
706 const shadow = this.attachShadow({ mode: "open" });
707
708 const styleTag = document.createElement("style");
709 shadow.appendChild(styleTag);
710 styleTag.innerText = styles;
711
712 const container = document.createElement("div");
713 shadow.appendChild(container);
714 container.className = "sequoia-comments-container";
715 container.part = "container";
716
717 this.commentsContainer = container;
718 this.state = { type: "loading" };
719 this.abortController = null;
720 }
721
722 static get observedAttributes() {
723 return ["post-uri", "document-uri", "depth", "hide"];
724 }
725
726 connectedCallback() {
727 this.initialized = true;
728 this.render();
729 this.loadComments();
730 }
731
732 disconnectedCallback() {
733 this.abortController?.abort();
734 }
735
736 attributeChangedCallback() {
737 // attributeChangedCallback fires for pre-existing attributes during
738 // element upgrade, *before* connectedCallback — skip until we've done
739 // the initial load, otherwise every attribute triggers a duplicate fetch.
740 if (this.initialized) {
741 this.loadComments();
742 }
743 }
744
745 get documentUri() {
746 // First check attribute
747 const attrUri = this.getAttribute("document-uri");
748 if (attrUri) {
749 return attrUri;
750 }
751
752 // Then scan for link tag in document head
753 const linkTag = document.querySelector(
754 'link[rel="site.standard.document"]',
755 );
756 return linkTag?.href ?? null;
757 }
758
759 get depth() {
760 const depthAttr = this.getAttribute("depth");
761 return depthAttr ? parseInt(depthAttr, 10) : 6;
762 }
763
764 get hide() {
765 const hideAttr = this.getAttribute("hide");
766 return hideAttr === "auto";
767 }
768
769 async loadComments() {
770 // Cancel any in-flight request
771 this.abortController?.abort();
772 this.abortController = new AbortController();
773
774 this.state = { type: "loading" };
775 this.render();
776
777 try {
778 // Resolve the post URI — either directly from the attribute or via the
779 // document record (which requires a PDS roundtrip)
780 const rawPostUri = this.getAttribute("post-uri");
781 let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null;
782 if (!postUri) {
783 const docUri = this.documentUri;
784 if (!docUri) {
785 this.state = { type: "no-document" };
786 this.render();
787 return;
788 }
789
790 const document = await getDocument(docUri);
791 if (!document.bskyPostRef) {
792 this.state = { type: "no-comments-enabled" };
793 this.render();
794 return;
795 }
796
797 postUri = document.bskyPostRef.uri;
798 }
799
800 const postUrl = buildBskyAppUrl(postUri);
801 const blackskyPostUrl = buildBlackskyAppUrl(postUri);
802
803 // Fetch thread and quotes in parallel; quote failures degrade gracefully
804 const [threadResult, quotesResult] = await Promise.allSettled([
805 getPostThread(postUri, this.depth),
806 getQuotes(postUri),
807 ]);
808
809 if (threadResult.status === "rejected") {
810 throw threadResult.reason;
811 }
812
813 const thread = threadResult.value;
814 const quotes =
815 quotesResult.status === "fulfilled" ? quotesResult.value : [];
816
817 const replies = thread.replies?.filter(isThreadViewPost) ?? [];
818 if (replies.length === 0 && quotes.length === 0) {
819 this.state = { type: "empty", postUrl, blackskyPostUrl };
820 this.render();
821 return;
822 }
823
824 this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl };
825 this.render();
826 } catch (error) {
827 const message =
828 error instanceof Error ? error.message : "Failed to load comments";
829 this.state = { type: "error", message };
830 this.render();
831 }
832 }
833
834 render() {
835 switch (this.state.type) {
836 case "loading":
837 this.commentsContainer.innerHTML = `
838 <div class="sequoia-loading">
839 <span class="sequoia-loading-spinner"></span>
840 Loading comments...
841 </div>
842 `;
843 break;
844
845 case "no-document":
846 this.commentsContainer.innerHTML = `
847 <div class="sequoia-warning">
848 No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
849 </div>
850 `;
851 if (this.hide) {
852 this.commentsContainer.style.display = "none";
853 }
854 break;
855
856 case "no-comments-enabled":
857 this.commentsContainer.innerHTML = `
858 <div class="sequoia-empty">
859 Comments are not enabled for this post.
860 </div>
861 `;
862 break;
863
864 case "empty":
865 this.commentsContainer.innerHTML = `
866 <div class="sequoia-comments-header">
867 <h3 class="sequoia-comments-title">Comments</h3>
868 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
869 </div>
870 <div class="sequoia-empty">
871 No comments yet. Be the first to reply on Bluesky!
872 </div>
873 `;
874 break;
875
876 case "error":
877 this.commentsContainer.innerHTML = `
878 <div class="sequoia-error">
879 Failed to load comments: ${escapeHtml(this.state.message)}
880 </div>
881 `;
882 break;
883
884 case "loaded": {
885 const replies =
886 this.state.thread.replies?.filter(isThreadViewPost) ?? [];
887 const quotes = this.state.quotes ?? [];
888 const threadsHtml = replies
889 .map((reply) => this.renderThread(reply))
890 .join("");
891 const commentCount = this.countComments(replies);
892 const titleText =
893 commentCount > 0
894 ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}`
895 : "Comments";
896 const quotesHtml = this.renderQuotesSection(quotes);
897
898 this.commentsContainer.innerHTML = `
899 <div class="sequoia-comments-header">
900 <h3 class="sequoia-comments-title">${titleText}</h3>
901 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
902 </div>
903 <div class="sequoia-comments-list">
904 ${threadsHtml}
905 </div>
906 ${quotesHtml}
907 `;
908 break;
909 }
910 }
911 }
912
913 /**
914 * Flatten a thread into a linear list of comments
915 * @param {ThreadViewPost} thread - Thread to flatten
916 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
917 */
918 flattenThread(thread) {
919 const result = [];
920 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
921
922 result.push({
923 post: thread.post,
924 hasMoreReplies: nestedReplies.length > 0,
925 });
926
927 // Recursively flatten nested replies
928 for (const reply of nestedReplies) {
929 result.push(...this.flattenThread(reply));
930 }
931
932 return result;
933 }
934
935 /**
936 * Render the reply-button slot. Any element with slot="reply-button" in the
937 * light DOM is projected here and remains styleable by external CSS.
938 * The default Bluesky/Blacksky buttons are used as fallback content.
939 */
940 renderReplyButtons(postUrl, blackskyPostUrl) {
941 return `
942 <slot name="reply-button">
943 <a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
944 ${BLUESKY_ICON}
945 </a>
946 <a href="${escapeHtml(blackskyPostUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
947 ${BLACKSKY_ICON}
948 </a>
949 </slot>
950 `;
951 }
952
953 /**
954 * Render a complete thread (top-level comment + all nested replies)
955 */
956 renderThread(thread) {
957 const flatComments = this.flattenThread(thread);
958 const commentsHtml = flatComments
959 .map((item, index) =>
960 this.renderComment(item.post, item.hasMoreReplies, index),
961 )
962 .join("");
963
964 return `<div class="sequoia-thread">${commentsHtml}</div>`;
965 }
966
967 /**
968 * Render a section of quote posts below the replies
969 * @param {Array} quotes - Array of PostView objects from getQuotes
970 */
971 renderQuotesSection(quotes) {
972 if (quotes.length === 0) return "";
973
974 const quotesHtml = quotes
975 .map((post) => {
976 return `<div class="sequoia-thread">${this.renderComment(post, false, 0)}</div>`;
977 })
978 .join("");
979
980 return `
981 <div class="sequoia-quotes-section">
982 <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4>
983 <div class="sequoia-comments-list">
984 ${quotesHtml}
985 </div>
986 </div>
987 `;
988 }
989
990 /**
991 * Render a single comment
992 * @param {any} post - Post data
993 * @param {boolean} showThreadLine - Whether to show the connecting thread line
994 * @param {number} _index - Index in the flattened thread (0 = top-level)
995 */
996 renderComment(post, showThreadLine = false, _index = 0) {
997 const author = post.author;
998 const displayName = author.displayName || author.handle;
999 const avatarHtml = author.avatar
1000 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
1001 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
1002
1003 const profileUrl = `https://bsky.app/profile/${author.did}`;
1004 const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
1005 const timeAgo = formatRelativeTime(post.record.createdAt);
1006 const timeHtml = `<a href="${escapeHtml(buildBskyAppUrl(post.uri))}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>`;
1007 const threadLineHtml = showThreadLine
1008 ? '<div class="sequoia-thread-line"></div>'
1009 : "";
1010
1011 return `
1012 <div class="sequoia-comment">
1013 <div class="sequoia-comment-avatar-column">
1014 ${avatarHtml}
1015 ${threadLineHtml}
1016 </div>
1017 <div class="sequoia-comment-content">
1018 <div class="sequoia-comment-header">
1019 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
1020 ${escapeHtml(displayName)}
1021 </a>
1022 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
1023 ${timeHtml}
1024 </div>
1025 <p class="sequoia-comment-text">${textHtml}</p>
1026 </div>
1027 </div>
1028 `;
1029 }
1030
1031 countComments(replies) {
1032 let count = 0;
1033 for (const reply of replies) {
1034 count += 1;
1035 const nested = reply.replies?.filter(isThreadViewPost) ?? [];
1036 count += this.countComments(nested);
1037 }
1038 return count;
1039 }
1040}
1041
1042// Register the custom element
1043if (typeof customElements !== "undefined") {
1044 customElements.define("sequoia-comments", SequoiaComments);
1045}
1046
1047// Export for module usage
1048export { SequoiaComments };