···32323333Use this as an alternative to `login` when OAuth isn't available or for CI environments.
34343535+## `add`
3636+3737+```bash [Terminal]
3838+sequoia add <component>
3939+> Add a UI component to your project
4040+4141+ARGUMENTS:
4242+ component - The name of the component to add
4343+4444+FLAGS:
4545+ --help, -h - show help [optional]
4646+```
4747+4848+Available components:
4949+- `sequoia-comments` - Display Bluesky replies as comments on your blog posts
5050+5151+The component will be installed to the directory specified in `ui.components` (default: `src/components`). See the [Comments guide](/comments) for usage details.
5252+3553## `init`
36543755```bash [Terminal]
+179
docs/docs/pages/comments.mdx
···11+# Comments
22+33+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:
44+55+1. Setup your blog with `sequoia init`, and when prompted at the end to enable BlueSky posts, select `yes`.
66+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.
77+3. As people reply to the BlueSky post, the replies can be rendered as comments below your post using the Sequoia UI web component.
88+99+## Setup
1010+1111+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.
1212+1313+```bash [Terminal]
1414+sequoia add sequoia-comments
1515+```
1616+1717+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.
1818+1919+::::tip
2020+For more information on the `<link>` tags, check out the [verification guide](/verifying)
2121+::::
2222+2323+## Usage
2424+2525+Since `sequoia-comments` is a standard Web Component, it works with any framework. Choose your setup below:
2626+2727+:::code-group
2828+2929+```html [HTML]
3030+<body>
3131+ <h1>Blog Post Title</h1>
3232+ <!--Content-->
3333+ <h2>Comments</h2>
3434+3535+ <sequoia-comments></sequoia-comments>
3636+ <script type="module" src="./src/components/sequoia-comments.js"></script>
3737+</body>
3838+```
3939+4040+```tsx [React]
4141+// Import the component (registers the custom element)
4242+import './components/sequoia-comments.js';
4343+4444+function BlogPost() {
4545+ return (
4646+ <article>
4747+ <h1>Blog Post Title</h1>
4848+ {/* Content */}
4949+ <h2>Comments</h2>
5050+ <sequoia-comments />
5151+ </article>
5252+ );
5353+}
5454+```
5555+5656+```vue [Vue]
5757+<script setup>
5858+import './components/sequoia-comments.js';
5959+</script>
6060+6161+<template>
6262+ <article>
6363+ <h1>Blog Post Title</h1>
6464+ <!-- Content -->
6565+ <h2>Comments</h2>
6666+ <sequoia-comments />
6767+ </article>
6868+</template>
6969+```
7070+7171+```svelte [Svelte]
7272+<script>
7373+ import './components/sequoia-comments.js';
7474+</script>
7575+7676+<article>
7777+ <h1>Blog Post Title</h1>
7878+ <!-- Content -->
7979+ <h2>Comments</h2>
8080+ <sequoia-comments />
8181+</article>
8282+```
8383+8484+```astro [Astro]
8585+<article>
8686+ <h1>Blog Post Title</h1>
8787+ <!-- Content -->
8888+ <h2>Comments</h2>
8989+ <sequoia-comments />
9090+ <script>
9191+ import './components/sequoia-comments.js';
9292+ </script>
9393+</article>
9494+```
9595+9696+:::
9797+9898+### TypeScript Support
9999+100100+If you're using TypeScript with React, add this type declaration to avoid JSX errors:
101101+102102+```ts [custom-elements.d.ts]
103103+declare namespace JSX {
104104+ interface IntrinsicElements {
105105+ 'sequoia-comments': React.DetailedHTMLProps<
106106+ React.HTMLAttributes<HTMLElement> & {
107107+ 'document-uri'?: string;
108108+ depth?: string | number;
109109+ },
110110+ HTMLElement
111111+ >;
112112+ }
113113+}
114114+```
115115+116116+### Vue Configuration
117117+118118+For Vue, you may need to configure the compiler to recognize custom elements:
119119+120120+```ts [vite.config.ts]
121121+export default defineConfig({
122122+ plugins: [
123123+ vue({
124124+ template: {
125125+ compilerOptions: {
126126+ isCustomElement: (tag) => tag === 'sequoia-comments'
127127+ }
128128+ }
129129+ })
130130+ ]
131131+});
132132+```
133133+134134+## Configuration
135135+136136+The comments web component has several configuration options available.
137137+138138+### Attributes
139139+140140+The `<sequoia-comments>` component accepts the following attributes:
141141+142142+| Attribute | Type | Default | Description |
143143+|-----------|------|---------|-------------|
144144+| `document-uri` | `string` | - | AT Protocol URI for the document. Optional if a `<link rel="site.standard.document">` tag exists in the page head. |
145145+| `depth` | `number` | `6` | Maximum depth of nested replies to fetch. |
146146+147147+```html
148148+<!-- Use attributes for explicit control -->
149149+<sequoia-comments
150150+ document-uri="at://did:plc:example/site.standard.document/abc123"
151151+ depth="10">
152152+</sequoia-comments>
153153+```
154154+155155+### Styling
156156+157157+The component uses CSS custom properties for theming. Set these in your `:root` or parent element to customize the appearance:
158158+159159+| CSS Property | Default | Description |
160160+|--------------|---------|-------------|
161161+| `--sequoia-fg-color` | `#1f2937` | Text color |
162162+| `--sequoia-bg-color` | `#ffffff` | Background color |
163163+| `--sequoia-border-color` | `#e5e7eb` | Border color |
164164+| `--sequoia-accent-color` | `#2563eb` | Accent/link color |
165165+| `--sequoia-secondary-color` | `#6b7280` | Secondary text color (handles, timestamps) |
166166+| `--sequoia-border-radius` | `8px` | Border radius for cards and buttons |
167167+168168+### Example: Dark Theme
169169+170170+```css
171171+:root {
172172+ --sequoia-accent-color: #3A5A40;
173173+ --sequoia-border-radius: 12px;
174174+ --sequoia-bg-color: #1a1a1a;
175175+ --sequoia-fg-color: #F5F3EF;
176176+ --sequoia-border-color: #333;
177177+ --sequoia-secondary-color: #8B7355;
178178+}
179179+```
+6-1
docs/docs/pages/config.mdx
···1919| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
2020| `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) |
2121| `bluesky` | `object` | No | - | Bluesky posting configuration |
2222-| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
2222+| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) |
2323| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
2424+| `ui` | `object` | No | - | UI components configuration |
2525+| `ui.components` | `string` | No | `"src/components"` | Directory where UI components are installed |
24262527### Example
2628···4143 "bluesky": {
4244 "enabled": true,
4345 "maxAgeDays": 30
4646+ },
4747+ "ui": {
4848+ "components": "src/components"
4449 }
4550}
4651```
+6
docs/docs/pages/publishing.mdx
···6666}
6767```
68686969+## Comments
7070+7171+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.
7272+7373+To display comments on your site, use the `sequoia-comments` web component. See the [Comments guide](/comments) for setup instructions.
7474+6975## Troubleshooting
70767177- 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
···11+/**
22+ * Sequoia Comments - A Bluesky-powered comments component
33+ *
44+ * A self-contained Web Component that displays comments from Bluesky posts
55+ * linked to documents via the AT Protocol.
66+ *
77+ * Usage:
88+ * <sequoia-comments></sequoia-comments>
99+ *
1010+ * The component looks for a document URI in two places:
1111+ * 1. The `document-uri` attribute on the element
1212+ * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head
1313+ *
1414+ * Attributes:
1515+ * - document-uri: AT Protocol URI for the document (optional if link tag exists)
1616+ * - depth: Maximum depth of nested replies to fetch (default: 6)
1717+ *
1818+ * CSS Custom Properties:
1919+ * - --sequoia-fg-color: Text color (default: #1f2937)
2020+ * - --sequoia-bg-color: Background color (default: #ffffff)
2121+ * - --sequoia-border-color: Border color (default: #e5e7eb)
2222+ * - --sequoia-accent-color: Accent/link color (default: #2563eb)
2323+ * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
2424+ * - --sequoia-border-radius: Border radius (default: 8px)
2525+ */
2626+2727+// ============================================================================
2828+// Styles
2929+// ============================================================================
3030+3131+const styles = `
3232+:host {
3333+ display: block;
3434+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
3535+ color: var(--sequoia-fg-color, #1f2937);
3636+ line-height: 1.5;
3737+}
3838+3939+* {
4040+ box-sizing: border-box;
4141+}
4242+4343+.sequoia-comments-container {
4444+ max-width: 100%;
4545+}
4646+4747+.sequoia-loading,
4848+.sequoia-error,
4949+.sequoia-empty,
5050+.sequoia-warning {
5151+ padding: 1rem;
5252+ border-radius: var(--sequoia-border-radius, 8px);
5353+ text-align: center;
5454+}
5555+5656+.sequoia-loading {
5757+ background: var(--sequoia-bg-color, #ffffff);
5858+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
5959+ color: var(--sequoia-secondary-color, #6b7280);
6060+}
6161+6262+.sequoia-loading-spinner {
6363+ display: inline-block;
6464+ width: 1.25rem;
6565+ height: 1.25rem;
6666+ border: 2px solid var(--sequoia-border-color, #e5e7eb);
6767+ border-top-color: var(--sequoia-accent-color, #2563eb);
6868+ border-radius: 50%;
6969+ animation: sequoia-spin 0.8s linear infinite;
7070+ margin-right: 0.5rem;
7171+ vertical-align: middle;
7272+}
7373+7474+@keyframes sequoia-spin {
7575+ to { transform: rotate(360deg); }
7676+}
7777+7878+.sequoia-error {
7979+ background: #fef2f2;
8080+ border: 1px solid #fecaca;
8181+ color: #dc2626;
8282+}
8383+8484+.sequoia-warning {
8585+ background: #fffbeb;
8686+ border: 1px solid #fde68a;
8787+ color: #d97706;
8888+}
8989+9090+.sequoia-empty {
9191+ background: var(--sequoia-bg-color, #ffffff);
9292+ border: 1px solid var(--sequoia-border-color, #e5e7eb);
9393+ color: var(--sequoia-secondary-color, #6b7280);
9494+}
9595+9696+.sequoia-comments-header {
9797+ display: flex;
9898+ justify-content: space-between;
9999+ align-items: center;
100100+ margin-bottom: 1rem;
101101+ padding-bottom: 0.75rem;
102102+}
103103+104104+.sequoia-comments-title {
105105+ font-size: 1.125rem;
106106+ font-weight: 600;
107107+ margin: 0;
108108+}
109109+110110+.sequoia-reply-button {
111111+ display: inline-flex;
112112+ align-items: center;
113113+ gap: 0.375rem;
114114+ padding: 0.5rem 1rem;
115115+ background: var(--sequoia-accent-color, #2563eb);
116116+ color: #ffffff;
117117+ border: none;
118118+ border-radius: var(--sequoia-border-radius, 8px);
119119+ font-size: 0.875rem;
120120+ font-weight: 500;
121121+ cursor: pointer;
122122+ text-decoration: none;
123123+ transition: background-color 0.15s ease;
124124+}
125125+126126+.sequoia-reply-button:hover {
127127+ background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
128128+}
129129+130130+.sequoia-reply-button svg {
131131+ width: 1rem;
132132+ height: 1rem;
133133+}
134134+135135+.sequoia-comments-list {
136136+ display: flex;
137137+ flex-direction: column;
138138+}
139139+140140+.sequoia-thread {
141141+ border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
142142+ padding-bottom: 1rem;
143143+}
144144+145145+.sequoia-thread + .sequoia-thread {
146146+ margin-top: 0.5rem;
147147+}
148148+149149+.sequoia-thread:last-child {
150150+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
151151+}
152152+153153+.sequoia-comment {
154154+ display: flex;
155155+ gap: 0.75rem;
156156+ padding-top: 1rem;
157157+}
158158+159159+.sequoia-comment-avatar-column {
160160+ display: flex;
161161+ flex-direction: column;
162162+ align-items: center;
163163+ flex-shrink: 0;
164164+ width: 2.5rem;
165165+ position: relative;
166166+}
167167+168168+.sequoia-comment-avatar {
169169+ width: 2.5rem;
170170+ height: 2.5rem;
171171+ border-radius: 50%;
172172+ background: var(--sequoia-border-color, #e5e7eb);
173173+ object-fit: cover;
174174+ flex-shrink: 0;
175175+ position: relative;
176176+ z-index: 1;
177177+}
178178+179179+.sequoia-comment-avatar-placeholder {
180180+ width: 2.5rem;
181181+ height: 2.5rem;
182182+ border-radius: 50%;
183183+ background: var(--sequoia-border-color, #e5e7eb);
184184+ display: flex;
185185+ align-items: center;
186186+ justify-content: center;
187187+ flex-shrink: 0;
188188+ color: var(--sequoia-secondary-color, #6b7280);
189189+ font-weight: 600;
190190+ font-size: 1rem;
191191+ position: relative;
192192+ z-index: 1;
193193+}
194194+195195+.sequoia-thread-line {
196196+ position: absolute;
197197+ top: 2.5rem;
198198+ bottom: calc(-1rem - 0.5rem);
199199+ left: 50%;
200200+ transform: translateX(-50%);
201201+ width: 2px;
202202+ background: var(--sequoia-border-color, #e5e7eb);
203203+}
204204+205205+.sequoia-comment-content {
206206+ flex: 1;
207207+ min-width: 0;
208208+}
209209+210210+.sequoia-comment-header {
211211+ display: flex;
212212+ align-items: baseline;
213213+ gap: 0.5rem;
214214+ margin-bottom: 0.25rem;
215215+ flex-wrap: wrap;
216216+}
217217+218218+.sequoia-comment-author {
219219+ font-weight: 600;
220220+ color: var(--sequoia-fg-color, #1f2937);
221221+ text-decoration: none;
222222+ overflow: hidden;
223223+ text-overflow: ellipsis;
224224+ white-space: nowrap;
225225+}
226226+227227+.sequoia-comment-author:hover {
228228+ color: var(--sequoia-accent-color, #2563eb);
229229+}
230230+231231+.sequoia-comment-handle {
232232+ font-size: 0.875rem;
233233+ color: var(--sequoia-secondary-color, #6b7280);
234234+ overflow: hidden;
235235+ text-overflow: ellipsis;
236236+ white-space: nowrap;
237237+}
238238+239239+.sequoia-comment-time {
240240+ font-size: 0.875rem;
241241+ color: var(--sequoia-secondary-color, #6b7280);
242242+ flex-shrink: 0;
243243+}
244244+245245+.sequoia-comment-time::before {
246246+ content: "ยท";
247247+ margin-right: 0.5rem;
248248+}
249249+250250+.sequoia-comment-text {
251251+ margin: 0;
252252+ white-space: pre-wrap;
253253+ word-wrap: break-word;
254254+}
255255+256256+.sequoia-comment-text a {
257257+ color: var(--sequoia-accent-color, #2563eb);
258258+ text-decoration: none;
259259+}
260260+261261+.sequoia-comment-text a:hover {
262262+ text-decoration: underline;
263263+}
264264+265265+.sequoia-bsky-logo {
266266+ width: 1rem;
267267+ height: 1rem;
268268+}
269269+`;
270270+271271+// ============================================================================
272272+// Utility Functions
273273+// ============================================================================
274274+275275+/**
276276+ * Format a relative time string (e.g., "2 hours ago")
277277+ * @param {string} dateString - ISO date string
278278+ * @returns {string} Formatted relative time
279279+ */
280280+function formatRelativeTime(dateString) {
281281+ const date = new Date(dateString);
282282+ const now = new Date();
283283+ const diffMs = now.getTime() - date.getTime();
284284+ const diffSeconds = Math.floor(diffMs / 1000);
285285+ const diffMinutes = Math.floor(diffSeconds / 60);
286286+ const diffHours = Math.floor(diffMinutes / 60);
287287+ const diffDays = Math.floor(diffHours / 24);
288288+ const diffWeeks = Math.floor(diffDays / 7);
289289+ const diffMonths = Math.floor(diffDays / 30);
290290+ const diffYears = Math.floor(diffDays / 365);
291291+292292+ if (diffSeconds < 60) {
293293+ return "just now";
294294+ }
295295+ if (diffMinutes < 60) {
296296+ return `${diffMinutes}m ago`;
297297+ }
298298+ if (diffHours < 24) {
299299+ return `${diffHours}h ago`;
300300+ }
301301+ if (diffDays < 7) {
302302+ return `${diffDays}d ago`;
303303+ }
304304+ if (diffWeeks < 4) {
305305+ return `${diffWeeks}w ago`;
306306+ }
307307+ if (diffMonths < 12) {
308308+ return `${diffMonths}mo ago`;
309309+ }
310310+ return `${diffYears}y ago`;
311311+}
312312+313313+/**
314314+ * Escape HTML special characters
315315+ * @param {string} text - Text to escape
316316+ * @returns {string} Escaped HTML
317317+ */
318318+function escapeHtml(text) {
319319+ const div = document.createElement("div");
320320+ div.textContent = text;
321321+ return div.innerHTML;
322322+}
323323+324324+/**
325325+ * Convert post text with facets to HTML
326326+ * @param {string} text - Post text
327327+ * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
328328+ * @returns {string} HTML string with links
329329+ */
330330+function renderTextWithFacets(text, facets) {
331331+ if (!facets || facets.length === 0) {
332332+ return escapeHtml(text);
333333+ }
334334+335335+ // Convert text to bytes for proper indexing
336336+ const encoder = new TextEncoder();
337337+ const decoder = new TextDecoder();
338338+ const textBytes = encoder.encode(text);
339339+340340+ // Sort facets by start index
341341+ const sortedFacets = [...facets].sort(
342342+ (a, b) => a.index.byteStart - b.index.byteStart,
343343+ );
344344+345345+ let result = "";
346346+ let lastEnd = 0;
347347+348348+ for (const facet of sortedFacets) {
349349+ const { byteStart, byteEnd } = facet.index;
350350+351351+ // Add text before this facet
352352+ if (byteStart > lastEnd) {
353353+ const beforeBytes = textBytes.slice(lastEnd, byteStart);
354354+ result += escapeHtml(decoder.decode(beforeBytes));
355355+ }
356356+357357+ // Get the facet text
358358+ const facetBytes = textBytes.slice(byteStart, byteEnd);
359359+ const facetText = decoder.decode(facetBytes);
360360+361361+ // Find the first renderable feature
362362+ const feature = facet.features[0];
363363+ if (feature) {
364364+ if (feature.$type === "app.bsky.richtext.facet#link") {
365365+ result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
366366+ } else if (feature.$type === "app.bsky.richtext.facet#mention") {
367367+ result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
368368+ } else if (feature.$type === "app.bsky.richtext.facet#tag") {
369369+ result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
370370+ } else {
371371+ result += escapeHtml(facetText);
372372+ }
373373+ } else {
374374+ result += escapeHtml(facetText);
375375+ }
376376+377377+ lastEnd = byteEnd;
378378+ }
379379+380380+ // Add remaining text
381381+ if (lastEnd < textBytes.length) {
382382+ const remainingBytes = textBytes.slice(lastEnd);
383383+ result += escapeHtml(decoder.decode(remainingBytes));
384384+ }
385385+386386+ return result;
387387+}
388388+389389+/**
390390+ * Get initials from a name for avatar placeholder
391391+ * @param {string} name - Display name
392392+ * @returns {string} Initials (1-2 characters)
393393+ */
394394+function getInitials(name) {
395395+ const parts = name.trim().split(/\s+/);
396396+ if (parts.length >= 2) {
397397+ return (parts[0][0] + parts[1][0]).toUpperCase();
398398+ }
399399+ return name.substring(0, 2).toUpperCase();
400400+}
401401+402402+// ============================================================================
403403+// AT Protocol Client Functions
404404+// ============================================================================
405405+406406+/**
407407+ * Parse an AT URI into its components
408408+ * Format: at://did/collection/rkey
409409+ * @param {string} atUri - AT Protocol URI
410410+ * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
411411+ */
412412+function parseAtUri(atUri) {
413413+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
414414+ if (!match) return null;
415415+ return {
416416+ did: match[1],
417417+ collection: match[2],
418418+ rkey: match[3],
419419+ };
420420+}
421421+422422+/**
423423+ * Resolve a DID to its PDS URL
424424+ * Supports did:plc and did:web methods
425425+ * @param {string} did - Decentralized Identifier
426426+ * @returns {Promise<string>} PDS URL
427427+ */
428428+async function resolvePDS(did) {
429429+ let pdsUrl;
430430+431431+ if (did.startsWith("did:plc:")) {
432432+ // Fetch DID document from plc.directory
433433+ const didDocUrl = `https://plc.directory/${did}`;
434434+ const didDocResponse = await fetch(didDocUrl);
435435+ if (!didDocResponse.ok) {
436436+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
437437+ }
438438+ const didDoc = await didDocResponse.json();
439439+440440+ // Find the PDS service endpoint
441441+ const pdsService = didDoc.service?.find(
442442+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
443443+ );
444444+ pdsUrl = pdsService?.serviceEndpoint;
445445+ } else if (did.startsWith("did:web:")) {
446446+ // For did:web, fetch the DID document from the domain
447447+ const domain = did.replace("did:web:", "");
448448+ const didDocUrl = `https://${domain}/.well-known/did.json`;
449449+ const didDocResponse = await fetch(didDocUrl);
450450+ if (!didDocResponse.ok) {
451451+ throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
452452+ }
453453+ const didDoc = await didDocResponse.json();
454454+455455+ const pdsService = didDoc.service?.find(
456456+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
457457+ );
458458+ pdsUrl = pdsService?.serviceEndpoint;
459459+ } else {
460460+ throw new Error(`Unsupported DID method: ${did}`);
461461+ }
462462+463463+ if (!pdsUrl) {
464464+ throw new Error("Could not find PDS URL for user");
465465+ }
466466+467467+ return pdsUrl;
468468+}
469469+470470+/**
471471+ * Fetch a record from a PDS using the public API
472472+ * @param {string} did - DID of the repository owner
473473+ * @param {string} collection - Collection name
474474+ * @param {string} rkey - Record key
475475+ * @returns {Promise<any>} Record value
476476+ */
477477+async function getRecord(did, collection, rkey) {
478478+ const pdsUrl = await resolvePDS(did);
479479+480480+ const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
481481+ url.searchParams.set("repo", did);
482482+ url.searchParams.set("collection", collection);
483483+ url.searchParams.set("rkey", rkey);
484484+485485+ const response = await fetch(url.toString());
486486+ if (!response.ok) {
487487+ throw new Error(`Failed to fetch record: ${response.status}`);
488488+ }
489489+490490+ const data = await response.json();
491491+ return data.value;
492492+}
493493+494494+/**
495495+ * Fetch a document record from its AT URI
496496+ * @param {string} atUri - AT Protocol URI for the document
497497+ * @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
498498+ */
499499+async function getDocument(atUri) {
500500+ const parsed = parseAtUri(atUri);
501501+ if (!parsed) {
502502+ throw new Error(`Invalid AT URI: ${atUri}`);
503503+ }
504504+505505+ return getRecord(parsed.did, parsed.collection, parsed.rkey);
506506+}
507507+508508+/**
509509+ * Fetch a post thread from the public Bluesky API
510510+ * @param {string} postUri - AT Protocol URI for the post
511511+ * @param {number} [depth=6] - Maximum depth of replies to fetch
512512+ * @returns {Promise<ThreadViewPost>} Thread view post
513513+ */
514514+async function getPostThread(postUri, depth = 6) {
515515+ const url = new URL(
516516+ "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
517517+ );
518518+ url.searchParams.set("uri", postUri);
519519+ url.searchParams.set("depth", depth.toString());
520520+521521+ const response = await fetch(url.toString());
522522+ if (!response.ok) {
523523+ throw new Error(`Failed to fetch post thread: ${response.status}`);
524524+ }
525525+526526+ const data = await response.json();
527527+528528+ if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
529529+ throw new Error("Post not found or blocked");
530530+ }
531531+532532+ return data.thread;
533533+}
534534+535535+/**
536536+ * Build a Bluesky app URL for a post
537537+ * @param {string} postUri - AT Protocol URI for the post
538538+ * @returns {string} Bluesky app URL
539539+ */
540540+function buildBskyAppUrl(postUri) {
541541+ const parsed = parseAtUri(postUri);
542542+ if (!parsed) {
543543+ throw new Error(`Invalid post URI: ${postUri}`);
544544+ }
545545+546546+ return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
547547+}
548548+549549+/**
550550+ * Type guard for ThreadViewPost
551551+ * @param {any} post - Post to check
552552+ * @returns {boolean} True if post is a ThreadViewPost
553553+ */
554554+function isThreadViewPost(post) {
555555+ return post?.$type === "app.bsky.feed.defs#threadViewPost";
556556+}
557557+558558+// ============================================================================
559559+// Bluesky Icon
560560+// ============================================================================
561561+562562+const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
563563+ <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"/>
564564+</svg>`;
565565+566566+// ============================================================================
567567+// Web Component
568568+// ============================================================================
569569+570570+// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
571571+const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
572572+573573+class SequoiaComments extends BaseElement {
574574+ constructor() {
575575+ super();
576576+ this.shadow = this.attachShadow({ mode: "open" });
577577+ this.state = { type: "loading" };
578578+ this.abortController = null;
579579+ }
580580+581581+ static get observedAttributes() {
582582+ return ["document-uri", "depth"];
583583+ }
584584+585585+ connectedCallback() {
586586+ this.render();
587587+ this.loadComments();
588588+ }
589589+590590+ disconnectedCallback() {
591591+ this.abortController?.abort();
592592+ }
593593+594594+ attributeChangedCallback() {
595595+ if (this.isConnected) {
596596+ this.loadComments();
597597+ }
598598+ }
599599+600600+ get documentUri() {
601601+ // First check attribute
602602+ const attrUri = this.getAttribute("document-uri");
603603+ if (attrUri) {
604604+ return attrUri;
605605+ }
606606+607607+ // Then scan for link tag in document head
608608+ const linkTag = document.querySelector(
609609+ 'link[rel="site.standard.document"]',
610610+ );
611611+ return linkTag?.href ?? null;
612612+ }
613613+614614+ get depth() {
615615+ const depthAttr = this.getAttribute("depth");
616616+ return depthAttr ? parseInt(depthAttr, 10) : 6;
617617+ }
618618+619619+ async loadComments() {
620620+ // Cancel any in-flight request
621621+ this.abortController?.abort();
622622+ this.abortController = new AbortController();
623623+624624+ this.state = { type: "loading" };
625625+ this.render();
626626+627627+ const docUri = this.documentUri;
628628+ if (!docUri) {
629629+ this.state = { type: "no-document" };
630630+ this.render();
631631+ return;
632632+ }
633633+634634+ try {
635635+ // Fetch the document record
636636+ const document = await getDocument(docUri);
637637+638638+ // Check if document has a Bluesky post reference
639639+ if (!document.bskyPostRef) {
640640+ this.state = { type: "no-comments-enabled" };
641641+ this.render();
642642+ return;
643643+ }
644644+645645+ const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
646646+647647+ // Fetch the post thread
648648+ const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
649649+650650+ // Check if there are any replies
651651+ const replies = thread.replies?.filter(isThreadViewPost) ?? [];
652652+ if (replies.length === 0) {
653653+ this.state = { type: "empty", postUrl };
654654+ this.render();
655655+ return;
656656+ }
657657+658658+ this.state = { type: "loaded", thread, postUrl };
659659+ this.render();
660660+ } catch (error) {
661661+ const message =
662662+ error instanceof Error ? error.message : "Failed to load comments";
663663+ this.state = { type: "error", message };
664664+ this.render();
665665+ }
666666+ }
667667+668668+ render() {
669669+ const styleTag = `<style>${styles}</style>`;
670670+671671+ switch (this.state.type) {
672672+ case "loading":
673673+ this.shadow.innerHTML = `
674674+ ${styleTag}
675675+ <div class="sequoia-comments-container">
676676+ <div class="sequoia-loading">
677677+ <span class="sequoia-loading-spinner"></span>
678678+ Loading comments...
679679+ </div>
680680+ </div>
681681+ `;
682682+ break;
683683+684684+ case "no-document":
685685+ this.shadow.innerHTML = `
686686+ ${styleTag}
687687+ <div class="sequoia-comments-container">
688688+ <div class="sequoia-warning">
689689+ No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
690690+ </div>
691691+ </div>
692692+ `;
693693+ break;
694694+695695+ case "no-comments-enabled":
696696+ this.shadow.innerHTML = `
697697+ ${styleTag}
698698+ <div class="sequoia-comments-container">
699699+ <div class="sequoia-empty">
700700+ Comments are not enabled for this post.
701701+ </div>
702702+ </div>
703703+ `;
704704+ break;
705705+706706+ case "empty":
707707+ this.shadow.innerHTML = `
708708+ ${styleTag}
709709+ <div class="sequoia-comments-container">
710710+ <div class="sequoia-comments-header">
711711+ <h3 class="sequoia-comments-title">Comments</h3>
712712+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
713713+ ${BLUESKY_ICON}
714714+ Reply on Bluesky
715715+ </a>
716716+ </div>
717717+ <div class="sequoia-empty">
718718+ No comments yet. Be the first to reply on Bluesky!
719719+ </div>
720720+ </div>
721721+ `;
722722+ break;
723723+724724+ case "error":
725725+ this.shadow.innerHTML = `
726726+ ${styleTag}
727727+ <div class="sequoia-comments-container">
728728+ <div class="sequoia-error">
729729+ Failed to load comments: ${escapeHtml(this.state.message)}
730730+ </div>
731731+ </div>
732732+ `;
733733+ break;
734734+735735+ case "loaded": {
736736+ const replies =
737737+ this.state.thread.replies?.filter(isThreadViewPost) ?? [];
738738+ const threadsHtml = replies
739739+ .map((reply) => this.renderThread(reply))
740740+ .join("");
741741+ const commentCount = this.countComments(replies);
742742+743743+ this.shadow.innerHTML = `
744744+ ${styleTag}
745745+ <div class="sequoia-comments-container">
746746+ <div class="sequoia-comments-header">
747747+ <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
748748+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
749749+ ${BLUESKY_ICON}
750750+ Reply on Bluesky
751751+ </a>
752752+ </div>
753753+ <div class="sequoia-comments-list">
754754+ ${threadsHtml}
755755+ </div>
756756+ </div>
757757+ `;
758758+ break;
759759+ }
760760+ }
761761+ }
762762+763763+ /**
764764+ * Flatten a thread into a linear list of comments
765765+ * @param {ThreadViewPost} thread - Thread to flatten
766766+ * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
767767+ */
768768+ flattenThread(thread) {
769769+ const result = [];
770770+ const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
771771+772772+ result.push({
773773+ post: thread.post,
774774+ hasMoreReplies: nestedReplies.length > 0,
775775+ });
776776+777777+ // Recursively flatten nested replies
778778+ for (const reply of nestedReplies) {
779779+ result.push(...this.flattenThread(reply));
780780+ }
781781+782782+ return result;
783783+ }
784784+785785+ /**
786786+ * Render a complete thread (top-level comment + all nested replies)
787787+ */
788788+ renderThread(thread) {
789789+ const flatComments = this.flattenThread(thread);
790790+ const commentsHtml = flatComments
791791+ .map((item, index) =>
792792+ this.renderComment(item.post, item.hasMoreReplies, index),
793793+ )
794794+ .join("");
795795+796796+ return `<div class="sequoia-thread">${commentsHtml}</div>`;
797797+ }
798798+799799+ /**
800800+ * Render a single comment
801801+ * @param {any} post - Post data
802802+ * @param {boolean} showThreadLine - Whether to show the connecting thread line
803803+ * @param {number} _index - Index in the flattened thread (0 = top-level)
804804+ */
805805+ renderComment(post, showThreadLine = false, _index = 0) {
806806+ const author = post.author;
807807+ const displayName = author.displayName || author.handle;
808808+ const avatarHtml = author.avatar
809809+ ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
810810+ : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
811811+812812+ const profileUrl = `https://bsky.app/profile/${author.did}`;
813813+ const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
814814+ const timeAgo = formatRelativeTime(post.record.createdAt);
815815+ const threadLineHtml = showThreadLine
816816+ ? '<div class="sequoia-thread-line"></div>'
817817+ : "";
818818+819819+ return `
820820+ <div class="sequoia-comment">
821821+ <div class="sequoia-comment-avatar-column">
822822+ ${avatarHtml}
823823+ ${threadLineHtml}
824824+ </div>
825825+ <div class="sequoia-comment-content">
826826+ <div class="sequoia-comment-header">
827827+ <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
828828+ ${escapeHtml(displayName)}
829829+ </a>
830830+ <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
831831+ <span class="sequoia-comment-time">${timeAgo}</span>
832832+ </div>
833833+ <p class="sequoia-comment-text">${textHtml}</p>
834834+ </div>
835835+ </div>
836836+ `;
837837+ }
838838+839839+ countComments(replies) {
840840+ let count = 0;
841841+ for (const reply of replies) {
842842+ count += 1;
843843+ const nested = reply.replies?.filter(isThreadViewPost) ?? [];
844844+ count += this.countComments(nested);
845845+ }
846846+ return count;
847847+ }
848848+}
849849+850850+// Register the custom element
851851+if (typeof customElements !== "undefined") {
852852+ customElements.define("sequoia-comments", SequoiaComments);
853853+}
854854+855855+// Export for module usage
856856+export { SequoiaComments };