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

Add Comments UI Component #19

merged opened by stevedylan.dev targeting main from feat/ui

This PR adds a new UI sequoia-comments web component that can render comments for a site.standard.document if the bskyPostRef exists in the record. By default the web component will look for the atUri of the document in the <link> tags used for verification. It can also take the URI as a parameter:

<!-- Use attributes for explicit control -->
<sequoia-comments
  document-uri="at://did:plc:example/site.standard.document/abc123"
  depth="10">
</sequoia-comments>

The component can also be customized with the following CSS variables

:root {
  --sequoia-accent-color: #3A5A40;
  --sequoia-border-radius: 12px;
  --sequoia-bg-color: #1a1a1a;
  --sequoia-fg-color: #F5F3EF;
  --sequoia-border-color: #333;
  --sequoia-secondary-color: #8B7355;
}

The PR includes updates to the docs with the command to add the component, and a new page for how the components work and can be configured.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3meceyycsnj22
+2104 -17
Diff #1
-1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 - packages/ui
+6
docs/docs/pages/blog/introducing-sequoia.mdx
··· 52 52 bun i -g sequoia-cli 53 53 ``` 54 54 ::: 55 + 56 + <script type="module" src="/sequoia-comments.js"></script> 57 + <sequoia-comments 58 + document-uri="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v" 59 + depth="2" 60 + ></sequoia-comments>
+18
docs/docs/pages/cli-reference.mdx
··· 32 32 33 33 Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 34 35 + ## `add` 36 + 37 + ```bash [Terminal] 38 + sequoia add <component> 39 + > Add a UI component to your project 40 + 41 + ARGUMENTS: 42 + component - The name of the component to add 43 + 44 + FLAGS: 45 + --help, -h - show help [optional] 46 + ``` 47 + 48 + Available components: 49 + - `sequoia-comments` - Display Bluesky replies as comments on your blog posts 50 + 51 + The component will be installed to the directory specified in `ui.components` (default: `src/components`). See the [Comments guide](/comments) for usage details. 52 + 35 53 ## `init` 36 54 37 55 ```bash [Terminal]
+179
docs/docs/pages/comments.mdx
··· 1 + # Comments 2 + 3 + Sequoia has a small UI trick up its sleeve that lets you easily display comments on your blog posts through Bluesky posts. This is the general flow: 4 + 5 + 1. Setup your blog with `sequoia init`, and when prompted at the end to enable BlueSky posts, select `yes`. 6 + 2. When you run `sequoia publish` the CLI will publish a BlueSky post and link it to your `site.standard.document` record for your post. 7 + 3. As people reply to the BlueSky post, the replies can be rendered as comments below your post using the Sequoia UI web component. 8 + 9 + ## Setup 10 + 11 + Run the following command in your project to install the comments web component. It will ask you where you would like to store the component file. 12 + 13 + ```bash [Terminal] 14 + sequoia add sequoia-comments 15 + ``` 16 + 17 + The web component will look for the `<link rel="site.standard.document" href="atUri"/>` in your HTML head, then using the `atUri` fetch the post and the replies. 18 + 19 + ::::tip 20 + For more information on the `<link>` tags, check out the [verification guide](/verifying) 21 + :::: 22 + 23 + ## Usage 24 + 25 + Since `sequoia-comments` is a standard Web Component, it works with any framework. Choose your setup below: 26 + 27 + :::code-group 28 + 29 + ```html [HTML] 30 + <body> 31 + <h1>Blog Post Title</h1> 32 + <!--Content--> 33 + <h2>Comments</h2> 34 + 35 + <sequoia-comments></sequoia-comments> 36 + <script type="module" src="./src/components/sequoia-comments.js"></script> 37 + </body> 38 + ``` 39 + 40 + ```tsx [React] 41 + // Import the component (registers the custom element) 42 + import './components/sequoia-comments.js'; 43 + 44 + function BlogPost() { 45 + return ( 46 + <article> 47 + <h1>Blog Post Title</h1> 48 + {/* Content */} 49 + <h2>Comments</h2> 50 + <sequoia-comments /> 51 + </article> 52 + ); 53 + } 54 + ``` 55 + 56 + ```vue [Vue] 57 + <script setup> 58 + import './components/sequoia-comments.js'; 59 + </script> 60 + 61 + <template> 62 + <article> 63 + <h1>Blog Post Title</h1> 64 + <!-- Content --> 65 + <h2>Comments</h2> 66 + <sequoia-comments /> 67 + </article> 68 + </template> 69 + ``` 70 + 71 + ```svelte [Svelte] 72 + <script> 73 + import './components/sequoia-comments.js'; 74 + </script> 75 + 76 + <article> 77 + <h1>Blog Post Title</h1> 78 + <!-- Content --> 79 + <h2>Comments</h2> 80 + <sequoia-comments /> 81 + </article> 82 + ``` 83 + 84 + ```astro [Astro] 85 + <article> 86 + <h1>Blog Post Title</h1> 87 + <!-- Content --> 88 + <h2>Comments</h2> 89 + <sequoia-comments /> 90 + <script> 91 + import './components/sequoia-comments.js'; 92 + </script> 93 + </article> 94 + ``` 95 + 96 + ::: 97 + 98 + ### TypeScript Support 99 + 100 + If you're using TypeScript with React, add this type declaration to avoid JSX errors: 101 + 102 + ```ts [custom-elements.d.ts] 103 + declare namespace JSX { 104 + interface IntrinsicElements { 105 + 'sequoia-comments': React.DetailedHTMLProps< 106 + React.HTMLAttributes<HTMLElement> & { 107 + 'document-uri'?: string; 108 + depth?: string | number; 109 + }, 110 + HTMLElement 111 + >; 112 + } 113 + } 114 + ``` 115 + 116 + ### Vue Configuration 117 + 118 + For Vue, you may need to configure the compiler to recognize custom elements: 119 + 120 + ```ts [vite.config.ts] 121 + export default defineConfig({ 122 + plugins: [ 123 + vue({ 124 + template: { 125 + compilerOptions: { 126 + isCustomElement: (tag) => tag === 'sequoia-comments' 127 + } 128 + } 129 + }) 130 + ] 131 + }); 132 + ``` 133 + 134 + ## Configuration 135 + 136 + The comments web component has several configuration options available. 137 + 138 + ### Attributes 139 + 140 + The `<sequoia-comments>` component accepts the following attributes: 141 + 142 + | Attribute | Type | Default | Description | 143 + |-----------|------|---------|-------------| 144 + | `document-uri` | `string` | - | AT Protocol URI for the document. Optional if a `<link rel="site.standard.document">` tag exists in the page head. | 145 + | `depth` | `number` | `6` | Maximum depth of nested replies to fetch. | 146 + 147 + ```html 148 + <!-- Use attributes for explicit control --> 149 + <sequoia-comments 150 + document-uri="at://did:plc:example/site.standard.document/abc123" 151 + depth="10"> 152 + </sequoia-comments> 153 + ``` 154 + 155 + ### Styling 156 + 157 + The component uses CSS custom properties for theming. Set these in your `:root` or parent element to customize the appearance: 158 + 159 + | CSS Property | Default | Description | 160 + |--------------|---------|-------------| 161 + | `--sequoia-fg-color` | `#1f2937` | Text color | 162 + | `--sequoia-bg-color` | `#ffffff` | Background color | 163 + | `--sequoia-border-color` | `#e5e7eb` | Border color | 164 + | `--sequoia-accent-color` | `#2563eb` | Accent/link color | 165 + | `--sequoia-secondary-color` | `#6b7280` | Secondary text color (handles, timestamps) | 166 + | `--sequoia-border-radius` | `8px` | Border radius for cards and buttons | 167 + 168 + ### Example: Dark Theme 169 + 170 + ```css 171 + :root { 172 + --sequoia-accent-color: #3A5A40; 173 + --sequoia-border-radius: 12px; 174 + --sequoia-bg-color: #1a1a1a; 175 + --sequoia-fg-color: #F5F3EF; 176 + --sequoia-border-color: #333; 177 + --sequoia-secondary-color: #8B7355; 178 + } 179 + ```
+6 -1
docs/docs/pages/config.mdx
··· 19 19 | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 20 | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 21 | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 - | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents (also enables [comments](/comments)) | 23 23 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 24 + | `ui` | `object` | No | - | UI components configuration | 25 + | `ui.components` | `string` | No | `"src/components"` | Directory where UI components are installed | 24 26 25 27 ### Example 26 28 ··· 41 43 "bluesky": { 42 44 "enabled": true, 43 45 "maxAgeDays": 30 46 + }, 47 + "ui": { 48 + "components": "src/components" 44 49 } 45 50 } 46 51 ```
+6
docs/docs/pages/publishing.mdx
··· 66 66 } 67 67 ``` 68 68 69 + ## Comments 70 + 71 + When Bluesky posting is enabled, Sequoia links each published document to its corresponding Bluesky post. This enables comments on your blog posts through Bluesky replies. 72 + 73 + To display comments on your site, use the `sequoia-comments` web component. See the [Comments guide](/comments) for setup instructions. 74 + 69 75 ## Troubleshooting 70 76 71 77 - If you have files in your markdown directory that should be ignored, use the [`ignore` array in the config](/config#ignoring-files).
+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 + }
+1
docs/vocs.config.ts
··· 34 34 items: [ 35 35 { text: "Setup", link: "/setup" }, 36 36 { text: "Publishing", link: "/publishing" }, 37 + { text: "Comments", link: "/comments" }, 37 38 { text: "Verifying", link: "/verifying" }, 38 39 { text: "Workflows", link: "/workflows" }, 39 40 ],
+1 -1
packages/cli/package.json
··· 16 16 "scripts": { 17 17 "lint": "biome lint --write", 18 18 "format": "biome format --write", 19 - "build": "bun build src/index.ts --target node --outdir dist", 19 + "build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/", 20 20 "dev": "bun run build && bun link", 21 21 "deploy": "bun run build && bun publish" 22 22 },
+157
packages/cli/src/commands/add.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { existsSync } from "node:fs"; 3 + import * as path from "node:path"; 4 + import { command, positional, string } from "cmd-ts"; 5 + import { intro, outro, text, spinner, log, note } from "@clack/prompts"; 6 + import { fileURLToPath } from "node:url"; 7 + import { dirname } from "node:path"; 8 + import { findConfig, loadConfig } from "../lib/config"; 9 + import type { PublisherConfig } from "../lib/types"; 10 + 11 + const __filename = fileURLToPath(import.meta.url); 12 + const __dirname = dirname(__filename); 13 + const COMPONENTS_DIR = path.join(__dirname, "components"); 14 + 15 + const DEFAULT_COMPONENTS_PATH = "src/components"; 16 + 17 + const AVAILABLE_COMPONENTS = ["sequoia-comments"]; 18 + 19 + export const addCommand = command({ 20 + name: "add", 21 + description: "Add a UI component to your project", 22 + args: { 23 + componentName: positional({ 24 + type: string, 25 + displayName: "component", 26 + description: "The name of the component to add", 27 + }), 28 + }, 29 + handler: async ({ componentName }) => { 30 + intro("Add Sequoia Component"); 31 + 32 + // Validate component name 33 + if (!AVAILABLE_COMPONENTS.includes(componentName)) { 34 + log.error(`Component '${componentName}' not found`); 35 + log.info("Available components:"); 36 + for (const comp of AVAILABLE_COMPONENTS) { 37 + log.info(` - ${comp}`); 38 + } 39 + process.exit(1); 40 + } 41 + 42 + // Try to load existing config 43 + const configPath = await findConfig(); 44 + let config: PublisherConfig | null = null; 45 + let componentsDir = DEFAULT_COMPONENTS_PATH; 46 + 47 + if (configPath) { 48 + try { 49 + config = await loadConfig(configPath); 50 + if (config.ui?.components) { 51 + componentsDir = config.ui.components; 52 + } 53 + } catch { 54 + // Config exists but may be incomplete - that's ok for UI components 55 + } 56 + } 57 + 58 + // If no UI config, prompt for components directory 59 + if (!config?.ui?.components) { 60 + log.info("No UI configuration found in sequoia.json"); 61 + 62 + const inputPath = await text({ 63 + message: "Where would you like to install components?", 64 + placeholder: DEFAULT_COMPONENTS_PATH, 65 + defaultValue: DEFAULT_COMPONENTS_PATH, 66 + }); 67 + 68 + if (inputPath === Symbol.for("cancel")) { 69 + outro("Cancelled"); 70 + process.exit(0); 71 + } 72 + 73 + componentsDir = inputPath as string; 74 + 75 + // Update or create config with UI settings 76 + if (configPath) { 77 + const s = spinner(); 78 + s.start("Updating sequoia.json..."); 79 + try { 80 + const configContent = await fs.readFile(configPath, "utf-8"); 81 + const existingConfig = JSON.parse(configContent); 82 + existingConfig.ui = { components: componentsDir }; 83 + await fs.writeFile( 84 + configPath, 85 + JSON.stringify(existingConfig, null, 2), 86 + "utf-8", 87 + ); 88 + s.stop("Updated sequoia.json with UI configuration"); 89 + } catch (error) { 90 + s.stop("Failed to update sequoia.json"); 91 + log.warn(`Could not update config: ${error}`); 92 + } 93 + } else { 94 + // Create minimal config just for UI 95 + const s = spinner(); 96 + s.start("Creating sequoia.json..."); 97 + const minimalConfig = { 98 + ui: { components: componentsDir }, 99 + }; 100 + await fs.writeFile( 101 + path.join(process.cwd(), "sequoia.json"), 102 + JSON.stringify(minimalConfig, null, 2), 103 + "utf-8", 104 + ); 105 + s.stop("Created sequoia.json with UI configuration"); 106 + } 107 + } 108 + 109 + // Resolve components directory 110 + const resolvedComponentsDir = path.isAbsolute(componentsDir) 111 + ? componentsDir 112 + : path.join(process.cwd(), componentsDir); 113 + 114 + // Create components directory if it doesn't exist 115 + if (!existsSync(resolvedComponentsDir)) { 116 + const s = spinner(); 117 + s.start(`Creating ${componentsDir} directory...`); 118 + await fs.mkdir(resolvedComponentsDir, { recursive: true }); 119 + s.stop(`Created ${componentsDir}`); 120 + } 121 + 122 + // Copy the component 123 + const sourceFile = path.join(COMPONENTS_DIR, `${componentName}.js`); 124 + const destFile = path.join(resolvedComponentsDir, `${componentName}.js`); 125 + 126 + if (!existsSync(sourceFile)) { 127 + log.error(`Component source file not found: ${sourceFile}`); 128 + log.info("This may be a build issue. Try reinstalling sequoia-cli."); 129 + process.exit(1); 130 + } 131 + 132 + const s = spinner(); 133 + s.start(`Installing ${componentName}...`); 134 + 135 + try { 136 + const componentCode = await fs.readFile(sourceFile, "utf-8"); 137 + await fs.writeFile(destFile, componentCode, "utf-8"); 138 + s.stop(`Installed ${componentName}`); 139 + } catch (error) { 140 + s.stop("Failed to install component"); 141 + log.error(`Error: ${error}`); 142 + process.exit(1); 143 + } 144 + 145 + // Show usage instructions 146 + note( 147 + `Add to your HTML:\n\n` + 148 + `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 149 + `<${componentName}></${componentName}>\n\n` + 150 + `The component will automatically read the document URI from:\n` + 151 + `<link rel="site.standard.document" href="at://...">`, 152 + "Usage", 153 + ); 154 + 155 + outro(`${componentName} added successfully!`); 156 + }, 157 + });
+856
packages/cli/src/components/sequoia-comments.js
··· 1 + /** 2 + * Sequoia Comments - A Bluesky-powered comments component 3 + * 4 + * A self-contained Web Component that displays comments from Bluesky posts 5 + * linked to documents via the AT Protocol. 6 + * 7 + * Usage: 8 + * <sequoia-comments></sequoia-comments> 9 + * 10 + * The component looks for a document URI in two places: 11 + * 1. The `document-uri` attribute on the element 12 + * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head 13 + * 14 + * Attributes: 15 + * - document-uri: AT Protocol URI for the document (optional if link tag exists) 16 + * - depth: Maximum depth of nested replies to fetch (default: 6) 17 + * 18 + * CSS Custom Properties: 19 + * - --sequoia-fg-color: Text color (default: #1f2937) 20 + * - --sequoia-bg-color: Background color (default: #ffffff) 21 + * - --sequoia-border-color: Border color (default: #e5e7eb) 22 + * - --sequoia-accent-color: Accent/link color (default: #2563eb) 23 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 24 + * - --sequoia-border-radius: Border radius (default: 8px) 25 + */ 26 + 27 + // ============================================================================ 28 + // Styles 29 + // ============================================================================ 30 + 31 + const styles = ` 32 + :host { 33 + display: block; 34 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 35 + color: var(--sequoia-fg-color, #1f2937); 36 + line-height: 1.5; 37 + } 38 + 39 + * { 40 + box-sizing: border-box; 41 + } 42 + 43 + .sequoia-comments-container { 44 + max-width: 100%; 45 + } 46 + 47 + .sequoia-loading, 48 + .sequoia-error, 49 + .sequoia-empty, 50 + .sequoia-warning { 51 + padding: 1rem; 52 + border-radius: var(--sequoia-border-radius, 8px); 53 + text-align: center; 54 + } 55 + 56 + .sequoia-loading { 57 + background: var(--sequoia-bg-color, #ffffff); 58 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 59 + color: var(--sequoia-secondary-color, #6b7280); 60 + } 61 + 62 + .sequoia-loading-spinner { 63 + display: inline-block; 64 + width: 1.25rem; 65 + height: 1.25rem; 66 + border: 2px solid var(--sequoia-border-color, #e5e7eb); 67 + border-top-color: var(--sequoia-accent-color, #2563eb); 68 + border-radius: 50%; 69 + animation: sequoia-spin 0.8s linear infinite; 70 + margin-right: 0.5rem; 71 + vertical-align: middle; 72 + } 73 + 74 + @keyframes sequoia-spin { 75 + to { transform: rotate(360deg); } 76 + } 77 + 78 + .sequoia-error { 79 + background: #fef2f2; 80 + border: 1px solid #fecaca; 81 + color: #dc2626; 82 + } 83 + 84 + .sequoia-warning { 85 + background: #fffbeb; 86 + border: 1px solid #fde68a; 87 + color: #d97706; 88 + } 89 + 90 + .sequoia-empty { 91 + background: var(--sequoia-bg-color, #ffffff); 92 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 93 + color: var(--sequoia-secondary-color, #6b7280); 94 + } 95 + 96 + .sequoia-comments-header { 97 + display: flex; 98 + justify-content: space-between; 99 + align-items: center; 100 + margin-bottom: 1rem; 101 + padding-bottom: 0.75rem; 102 + } 103 + 104 + .sequoia-comments-title { 105 + font-size: 1.125rem; 106 + font-weight: 600; 107 + margin: 0; 108 + } 109 + 110 + .sequoia-reply-button { 111 + display: inline-flex; 112 + align-items: center; 113 + gap: 0.375rem; 114 + padding: 0.5rem 1rem; 115 + background: var(--sequoia-accent-color, #2563eb); 116 + color: #ffffff; 117 + border: none; 118 + border-radius: var(--sequoia-border-radius, 8px); 119 + font-size: 0.875rem; 120 + font-weight: 500; 121 + cursor: pointer; 122 + text-decoration: none; 123 + transition: background-color 0.15s ease; 124 + } 125 + 126 + .sequoia-reply-button:hover { 127 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 128 + } 129 + 130 + .sequoia-reply-button svg { 131 + width: 1rem; 132 + height: 1rem; 133 + } 134 + 135 + .sequoia-comments-list { 136 + display: flex; 137 + flex-direction: column; 138 + } 139 + 140 + .sequoia-thread { 141 + border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 142 + padding-bottom: 1rem; 143 + } 144 + 145 + .sequoia-thread + .sequoia-thread { 146 + margin-top: 0.5rem; 147 + } 148 + 149 + .sequoia-thread:last-child { 150 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 151 + } 152 + 153 + .sequoia-comment { 154 + display: flex; 155 + gap: 0.75rem; 156 + padding-top: 1rem; 157 + } 158 + 159 + .sequoia-comment-avatar-column { 160 + display: flex; 161 + flex-direction: column; 162 + align-items: center; 163 + flex-shrink: 0; 164 + width: 2.5rem; 165 + position: relative; 166 + } 167 + 168 + .sequoia-comment-avatar { 169 + width: 2.5rem; 170 + height: 2.5rem; 171 + border-radius: 50%; 172 + background: var(--sequoia-border-color, #e5e7eb); 173 + object-fit: cover; 174 + flex-shrink: 0; 175 + position: relative; 176 + z-index: 1; 177 + } 178 + 179 + .sequoia-comment-avatar-placeholder { 180 + width: 2.5rem; 181 + height: 2.5rem; 182 + border-radius: 50%; 183 + background: var(--sequoia-border-color, #e5e7eb); 184 + display: flex; 185 + align-items: center; 186 + justify-content: center; 187 + flex-shrink: 0; 188 + color: var(--sequoia-secondary-color, #6b7280); 189 + font-weight: 600; 190 + font-size: 1rem; 191 + position: relative; 192 + z-index: 1; 193 + } 194 + 195 + .sequoia-thread-line { 196 + position: absolute; 197 + top: 2.5rem; 198 + bottom: calc(-1rem - 0.5rem); 199 + left: 50%; 200 + transform: translateX(-50%); 201 + width: 2px; 202 + background: var(--sequoia-border-color, #e5e7eb); 203 + } 204 + 205 + .sequoia-comment-content { 206 + flex: 1; 207 + min-width: 0; 208 + } 209 + 210 + .sequoia-comment-header { 211 + display: flex; 212 + align-items: baseline; 213 + gap: 0.5rem; 214 + margin-bottom: 0.25rem; 215 + flex-wrap: wrap; 216 + } 217 + 218 + .sequoia-comment-author { 219 + font-weight: 600; 220 + color: var(--sequoia-fg-color, #1f2937); 221 + text-decoration: none; 222 + overflow: hidden; 223 + text-overflow: ellipsis; 224 + white-space: nowrap; 225 + } 226 + 227 + .sequoia-comment-author:hover { 228 + color: var(--sequoia-accent-color, #2563eb); 229 + } 230 + 231 + .sequoia-comment-handle { 232 + font-size: 0.875rem; 233 + color: var(--sequoia-secondary-color, #6b7280); 234 + overflow: hidden; 235 + text-overflow: ellipsis; 236 + white-space: nowrap; 237 + } 238 + 239 + .sequoia-comment-time { 240 + font-size: 0.875rem; 241 + color: var(--sequoia-secondary-color, #6b7280); 242 + flex-shrink: 0; 243 + } 244 + 245 + .sequoia-comment-time::before { 246 + content: "·"; 247 + margin-right: 0.5rem; 248 + } 249 + 250 + .sequoia-comment-text { 251 + margin: 0; 252 + white-space: pre-wrap; 253 + word-wrap: break-word; 254 + } 255 + 256 + .sequoia-comment-text a { 257 + color: var(--sequoia-accent-color, #2563eb); 258 + text-decoration: none; 259 + } 260 + 261 + .sequoia-comment-text a:hover { 262 + text-decoration: underline; 263 + } 264 + 265 + .sequoia-bsky-logo { 266 + width: 1rem; 267 + height: 1rem; 268 + } 269 + `; 270 + 271 + // ============================================================================ 272 + // Utility Functions 273 + // ============================================================================ 274 + 275 + /** 276 + * Format a relative time string (e.g., "2 hours ago") 277 + * @param {string} dateString - ISO date string 278 + * @returns {string} Formatted relative time 279 + */ 280 + function formatRelativeTime(dateString) { 281 + const date = new Date(dateString); 282 + const now = new Date(); 283 + const diffMs = now.getTime() - date.getTime(); 284 + const diffSeconds = Math.floor(diffMs / 1000); 285 + const diffMinutes = Math.floor(diffSeconds / 60); 286 + const diffHours = Math.floor(diffMinutes / 60); 287 + const diffDays = Math.floor(diffHours / 24); 288 + const diffWeeks = Math.floor(diffDays / 7); 289 + const diffMonths = Math.floor(diffDays / 30); 290 + const diffYears = Math.floor(diffDays / 365); 291 + 292 + if (diffSeconds < 60) { 293 + return "just now"; 294 + } 295 + if (diffMinutes < 60) { 296 + return `${diffMinutes}m ago`; 297 + } 298 + if (diffHours < 24) { 299 + return `${diffHours}h ago`; 300 + } 301 + if (diffDays < 7) { 302 + return `${diffDays}d ago`; 303 + } 304 + if (diffWeeks < 4) { 305 + return `${diffWeeks}w ago`; 306 + } 307 + if (diffMonths < 12) { 308 + return `${diffMonths}mo ago`; 309 + } 310 + return `${diffYears}y ago`; 311 + } 312 + 313 + /** 314 + * Escape HTML special characters 315 + * @param {string} text - Text to escape 316 + * @returns {string} Escaped HTML 317 + */ 318 + function escapeHtml(text) { 319 + const div = document.createElement("div"); 320 + div.textContent = text; 321 + return div.innerHTML; 322 + } 323 + 324 + /** 325 + * Convert post text with facets to HTML 326 + * @param {string} text - Post text 327 + * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 328 + * @returns {string} HTML string with links 329 + */ 330 + function renderTextWithFacets(text, facets) { 331 + if (!facets || facets.length === 0) { 332 + return escapeHtml(text); 333 + } 334 + 335 + // Convert text to bytes for proper indexing 336 + const encoder = new TextEncoder(); 337 + const decoder = new TextDecoder(); 338 + const textBytes = encoder.encode(text); 339 + 340 + // Sort facets by start index 341 + const sortedFacets = [...facets].sort( 342 + (a, b) => a.index.byteStart - b.index.byteStart, 343 + ); 344 + 345 + let result = ""; 346 + let lastEnd = 0; 347 + 348 + for (const facet of sortedFacets) { 349 + const { byteStart, byteEnd } = facet.index; 350 + 351 + // Add text before this facet 352 + if (byteStart > lastEnd) { 353 + const beforeBytes = textBytes.slice(lastEnd, byteStart); 354 + result += escapeHtml(decoder.decode(beforeBytes)); 355 + } 356 + 357 + // Get the facet text 358 + const facetBytes = textBytes.slice(byteStart, byteEnd); 359 + const facetText = decoder.decode(facetBytes); 360 + 361 + // Find the first renderable feature 362 + const feature = facet.features[0]; 363 + if (feature) { 364 + if (feature.$type === "app.bsky.richtext.facet#link") { 365 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 366 + } else if (feature.$type === "app.bsky.richtext.facet#mention") { 367 + result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 368 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 369 + result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 370 + } else { 371 + result += escapeHtml(facetText); 372 + } 373 + } else { 374 + result += escapeHtml(facetText); 375 + } 376 + 377 + lastEnd = byteEnd; 378 + } 379 + 380 + // Add remaining text 381 + if (lastEnd < textBytes.length) { 382 + const remainingBytes = textBytes.slice(lastEnd); 383 + result += escapeHtml(decoder.decode(remainingBytes)); 384 + } 385 + 386 + return result; 387 + } 388 + 389 + /** 390 + * Get initials from a name for avatar placeholder 391 + * @param {string} name - Display name 392 + * @returns {string} Initials (1-2 characters) 393 + */ 394 + function getInitials(name) { 395 + const parts = name.trim().split(/\s+/); 396 + if (parts.length >= 2) { 397 + return (parts[0][0] + parts[1][0]).toUpperCase(); 398 + } 399 + return name.substring(0, 2).toUpperCase(); 400 + } 401 + 402 + // ============================================================================ 403 + // AT Protocol Client Functions 404 + // ============================================================================ 405 + 406 + /** 407 + * Parse an AT URI into its components 408 + * Format: at://did/collection/rkey 409 + * @param {string} atUri - AT Protocol URI 410 + * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 411 + */ 412 + function parseAtUri(atUri) { 413 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 414 + if (!match) return null; 415 + return { 416 + did: match[1], 417 + collection: match[2], 418 + rkey: match[3], 419 + }; 420 + } 421 + 422 + /** 423 + * Resolve a DID to its PDS URL 424 + * Supports did:plc and did:web methods 425 + * @param {string} did - Decentralized Identifier 426 + * @returns {Promise<string>} PDS URL 427 + */ 428 + async function resolvePDS(did) { 429 + let pdsUrl; 430 + 431 + if (did.startsWith("did:plc:")) { 432 + // Fetch DID document from plc.directory 433 + const didDocUrl = `https://plc.directory/${did}`; 434 + const didDocResponse = await fetch(didDocUrl); 435 + if (!didDocResponse.ok) { 436 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 437 + } 438 + const didDoc = await didDocResponse.json(); 439 + 440 + // Find the PDS service endpoint 441 + const pdsService = didDoc.service?.find( 442 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 443 + ); 444 + pdsUrl = pdsService?.serviceEndpoint; 445 + } else if (did.startsWith("did:web:")) { 446 + // For did:web, fetch the DID document from the domain 447 + const domain = did.replace("did:web:", ""); 448 + const didDocUrl = `https://${domain}/.well-known/did.json`; 449 + const didDocResponse = await fetch(didDocUrl); 450 + if (!didDocResponse.ok) { 451 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 452 + } 453 + const didDoc = await didDocResponse.json(); 454 + 455 + const pdsService = didDoc.service?.find( 456 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 457 + ); 458 + pdsUrl = pdsService?.serviceEndpoint; 459 + } else { 460 + throw new Error(`Unsupported DID method: ${did}`); 461 + } 462 + 463 + if (!pdsUrl) { 464 + throw new Error("Could not find PDS URL for user"); 465 + } 466 + 467 + return pdsUrl; 468 + } 469 + 470 + /** 471 + * Fetch a record from a PDS using the public API 472 + * @param {string} did - DID of the repository owner 473 + * @param {string} collection - Collection name 474 + * @param {string} rkey - Record key 475 + * @returns {Promise<any>} Record value 476 + */ 477 + async function getRecord(did, collection, rkey) { 478 + const pdsUrl = await resolvePDS(did); 479 + 480 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 481 + url.searchParams.set("repo", did); 482 + url.searchParams.set("collection", collection); 483 + url.searchParams.set("rkey", rkey); 484 + 485 + const response = await fetch(url.toString()); 486 + if (!response.ok) { 487 + throw new Error(`Failed to fetch record: ${response.status}`); 488 + } 489 + 490 + const data = await response.json(); 491 + return data.value; 492 + } 493 + 494 + /** 495 + * Fetch a document record from its AT URI 496 + * @param {string} atUri - AT Protocol URI for the document 497 + * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record 498 + */ 499 + async function getDocument(atUri) { 500 + const parsed = parseAtUri(atUri); 501 + if (!parsed) { 502 + throw new Error(`Invalid AT URI: ${atUri}`); 503 + } 504 + 505 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 506 + } 507 + 508 + /** 509 + * Fetch a post thread from the public Bluesky API 510 + * @param {string} postUri - AT Protocol URI for the post 511 + * @param {number} [depth=6] - Maximum depth of replies to fetch 512 + * @returns {Promise<ThreadViewPost>} Thread view post 513 + */ 514 + async function getPostThread(postUri, depth = 6) { 515 + const url = new URL( 516 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 517 + ); 518 + url.searchParams.set("uri", postUri); 519 + url.searchParams.set("depth", depth.toString()); 520 + 521 + const response = await fetch(url.toString()); 522 + if (!response.ok) { 523 + throw new Error(`Failed to fetch post thread: ${response.status}`); 524 + } 525 + 526 + const data = await response.json(); 527 + 528 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 529 + throw new Error("Post not found or blocked"); 530 + } 531 + 532 + return data.thread; 533 + } 534 + 535 + /** 536 + * Build a Bluesky app URL for a post 537 + * @param {string} postUri - AT Protocol URI for the post 538 + * @returns {string} Bluesky app URL 539 + */ 540 + function buildBskyAppUrl(postUri) { 541 + const parsed = parseAtUri(postUri); 542 + if (!parsed) { 543 + throw new Error(`Invalid post URI: ${postUri}`); 544 + } 545 + 546 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 547 + } 548 + 549 + /** 550 + * Type guard for ThreadViewPost 551 + * @param {any} post - Post to check 552 + * @returns {boolean} True if post is a ThreadViewPost 553 + */ 554 + function isThreadViewPost(post) { 555 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 556 + } 557 + 558 + // ============================================================================ 559 + // Bluesky Icon 560 + // ============================================================================ 561 + 562 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 563 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 564 + </svg>`; 565 + 566 + // ============================================================================ 567 + // Web Component 568 + // ============================================================================ 569 + 570 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 571 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 572 + 573 + class SequoiaComments extends BaseElement { 574 + constructor() { 575 + super(); 576 + this.shadow = this.attachShadow({ mode: "open" }); 577 + this.state = { type: "loading" }; 578 + this.abortController = null; 579 + } 580 + 581 + static get observedAttributes() { 582 + return ["document-uri", "depth"]; 583 + } 584 + 585 + connectedCallback() { 586 + this.render(); 587 + this.loadComments(); 588 + } 589 + 590 + disconnectedCallback() { 591 + this.abortController?.abort(); 592 + } 593 + 594 + attributeChangedCallback() { 595 + if (this.isConnected) { 596 + this.loadComments(); 597 + } 598 + } 599 + 600 + get documentUri() { 601 + // First check attribute 602 + const attrUri = this.getAttribute("document-uri"); 603 + if (attrUri) { 604 + return attrUri; 605 + } 606 + 607 + // Then scan for link tag in document head 608 + const linkTag = document.querySelector( 609 + 'link[rel="site.standard.document"]', 610 + ); 611 + return linkTag?.href ?? null; 612 + } 613 + 614 + get depth() { 615 + const depthAttr = this.getAttribute("depth"); 616 + return depthAttr ? parseInt(depthAttr, 10) : 6; 617 + } 618 + 619 + async loadComments() { 620 + // Cancel any in-flight request 621 + this.abortController?.abort(); 622 + this.abortController = new AbortController(); 623 + 624 + this.state = { type: "loading" }; 625 + this.render(); 626 + 627 + const docUri = this.documentUri; 628 + if (!docUri) { 629 + this.state = { type: "no-document" }; 630 + this.render(); 631 + return; 632 + } 633 + 634 + try { 635 + // Fetch the document record 636 + const document = await getDocument(docUri); 637 + 638 + // Check if document has a Bluesky post reference 639 + if (!document.bskyPostRef) { 640 + this.state = { type: "no-comments-enabled" }; 641 + this.render(); 642 + return; 643 + } 644 + 645 + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 646 + 647 + // Fetch the post thread 648 + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 649 + 650 + // Check if there are any replies 651 + const replies = thread.replies?.filter(isThreadViewPost) ?? []; 652 + if (replies.length === 0) { 653 + this.state = { type: "empty", postUrl }; 654 + this.render(); 655 + return; 656 + } 657 + 658 + this.state = { type: "loaded", thread, postUrl }; 659 + this.render(); 660 + } catch (error) { 661 + const message = 662 + error instanceof Error ? error.message : "Failed to load comments"; 663 + this.state = { type: "error", message }; 664 + this.render(); 665 + } 666 + } 667 + 668 + render() { 669 + const styleTag = `<style>${styles}</style>`; 670 + 671 + switch (this.state.type) { 672 + case "loading": 673 + this.shadow.innerHTML = ` 674 + ${styleTag} 675 + <div class="sequoia-comments-container"> 676 + <div class="sequoia-loading"> 677 + <span class="sequoia-loading-spinner"></span> 678 + Loading comments... 679 + </div> 680 + </div> 681 + `; 682 + break; 683 + 684 + case "no-document": 685 + this.shadow.innerHTML = ` 686 + ${styleTag} 687 + <div class="sequoia-comments-container"> 688 + <div class="sequoia-warning"> 689 + No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 690 + </div> 691 + </div> 692 + `; 693 + break; 694 + 695 + case "no-comments-enabled": 696 + this.shadow.innerHTML = ` 697 + ${styleTag} 698 + <div class="sequoia-comments-container"> 699 + <div class="sequoia-empty"> 700 + Comments are not enabled for this post. 701 + </div> 702 + </div> 703 + `; 704 + break; 705 + 706 + case "empty": 707 + this.shadow.innerHTML = ` 708 + ${styleTag} 709 + <div class="sequoia-comments-container"> 710 + <div class="sequoia-comments-header"> 711 + <h3 class="sequoia-comments-title">Comments</h3> 712 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 713 + ${BLUESKY_ICON} 714 + Reply on Bluesky 715 + </a> 716 + </div> 717 + <div class="sequoia-empty"> 718 + No comments yet. Be the first to reply on Bluesky! 719 + </div> 720 + </div> 721 + `; 722 + break; 723 + 724 + case "error": 725 + this.shadow.innerHTML = ` 726 + ${styleTag} 727 + <div class="sequoia-comments-container"> 728 + <div class="sequoia-error"> 729 + Failed to load comments: ${escapeHtml(this.state.message)} 730 + </div> 731 + </div> 732 + `; 733 + break; 734 + 735 + case "loaded": { 736 + const replies = 737 + this.state.thread.replies?.filter(isThreadViewPost) ?? []; 738 + const threadsHtml = replies 739 + .map((reply) => this.renderThread(reply)) 740 + .join(""); 741 + const commentCount = this.countComments(replies); 742 + 743 + this.shadow.innerHTML = ` 744 + ${styleTag} 745 + <div class="sequoia-comments-container"> 746 + <div class="sequoia-comments-header"> 747 + <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 748 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 749 + ${BLUESKY_ICON} 750 + Reply on Bluesky 751 + </a> 752 + </div> 753 + <div class="sequoia-comments-list"> 754 + ${threadsHtml} 755 + </div> 756 + </div> 757 + `; 758 + break; 759 + } 760 + } 761 + } 762 + 763 + /** 764 + * Flatten a thread into a linear list of comments 765 + * @param {ThreadViewPost} thread - Thread to flatten 766 + * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 767 + */ 768 + flattenThread(thread) { 769 + const result = []; 770 + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 771 + 772 + result.push({ 773 + post: thread.post, 774 + hasMoreReplies: nestedReplies.length > 0, 775 + }); 776 + 777 + // Recursively flatten nested replies 778 + for (const reply of nestedReplies) { 779 + result.push(...this.flattenThread(reply)); 780 + } 781 + 782 + return result; 783 + } 784 + 785 + /** 786 + * Render a complete thread (top-level comment + all nested replies) 787 + */ 788 + renderThread(thread) { 789 + const flatComments = this.flattenThread(thread); 790 + const commentsHtml = flatComments 791 + .map((item, index) => 792 + this.renderComment(item.post, item.hasMoreReplies, index), 793 + ) 794 + .join(""); 795 + 796 + return `<div class="sequoia-thread">${commentsHtml}</div>`; 797 + } 798 + 799 + /** 800 + * Render a single comment 801 + * @param {any} post - Post data 802 + * @param {boolean} showThreadLine - Whether to show the connecting thread line 803 + * @param {number} _index - Index in the flattened thread (0 = top-level) 804 + */ 805 + renderComment(post, showThreadLine = false, _index = 0) { 806 + const author = post.author; 807 + const displayName = author.displayName || author.handle; 808 + const avatarHtml = author.avatar 809 + ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 810 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 811 + 812 + const profileUrl = `https://bsky.app/profile/${author.did}`; 813 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 814 + const timeAgo = formatRelativeTime(post.record.createdAt); 815 + const threadLineHtml = showThreadLine 816 + ? '<div class="sequoia-thread-line"></div>' 817 + : ""; 818 + 819 + return ` 820 + <div class="sequoia-comment"> 821 + <div class="sequoia-comment-avatar-column"> 822 + ${avatarHtml} 823 + ${threadLineHtml} 824 + </div> 825 + <div class="sequoia-comment-content"> 826 + <div class="sequoia-comment-header"> 827 + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 828 + ${escapeHtml(displayName)} 829 + </a> 830 + <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 831 + <span class="sequoia-comment-time">${timeAgo}</span> 832 + </div> 833 + <p class="sequoia-comment-text">${textHtml}</p> 834 + </div> 835 + </div> 836 + `; 837 + } 838 + 839 + countComments(replies) { 840 + let count = 0; 841 + for (const reply of replies) { 842 + count += 1; 843 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 844 + count += this.countComments(nested); 845 + } 846 + return count; 847 + } 848 + } 849 + 850 + // Register the custom element 851 + if (typeof customElements !== "undefined") { 852 + customElements.define("sequoia-comments", SequoiaComments); 853 + } 854 + 855 + // Export for module usage 856 + export { SequoiaComments };
+3 -1
packages/cli/src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 3 3 import { run, subcommands } from "cmd-ts"; 4 + import { addCommand } from "./commands/add"; 4 5 import { authCommand } from "./commands/auth"; 5 6 import { initCommand } from "./commands/init"; 6 7 import { injectCommand } from "./commands/inject"; ··· 35 36 36 37 > https://tangled.org/stevedylan.dev/sequoia 37 38 `, 38 - version: "0.3.3", 39 + version: "0.4.0", 39 40 cmds: { 41 + add: addCommand, 40 42 auth: authCommand, 41 43 init: initCommand, 42 44 inject: injectCommand,
+6
packages/cli/src/lib/types.ts
··· 20 20 maxAgeDays?: number; // Only post if published within N days (default: 7) 21 21 } 22 22 23 + // UI components configuration 24 + export interface UIConfig { 25 + components: string; // Directory to install UI components (default: src/components) 26 + } 27 + 23 28 export interface PublisherConfig { 24 29 siteUrl: string; 25 30 contentDir: string; ··· 36 41 stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 37 42 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 38 43 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 44 + ui?: UIConfig; // Optional UI components configuration 39 45 } 40 46 41 47 // Legacy credentials format (for backward compatibility during migration)
+43
packages/cli/test.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Sequoia Comments Test</title> 7 + <!-- Link to a published document - replace with your own AT URI --> 8 + <link rel="site.standard.document" href="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v"> 9 + <style> 10 + body { 11 + font-family: system-ui, -apple-system, sans-serif; 12 + max-width: 800px; 13 + margin: 2rem auto; 14 + padding: 0 1rem; 15 + line-height: 1.6; 16 + background-color: #1A1A1A; 17 + color: #F5F3EF; 18 + } 19 + h1 { 20 + margin-bottom: 2rem; 21 + } 22 + /* Custom styling example */ 23 + :root { 24 + --sequoia-accent-color: #3A5A40; 25 + --sequoia-border-radius: 12px; 26 + --sequoia-bg-color: #1a1a1a; 27 + --sequoia-fg-color: #F5F3EF; 28 + --sequoia-border-color: #333; 29 + --sequoia-secondary-color: #8B7355; 30 + } 31 + </style> 32 + </head> 33 + <body> 34 + <h1>Blog Post Title</h1> 35 + <p>This is a test page for the sequoia-comments web component.</p> 36 + <p>The component will look for a <code>&lt;link rel="site.standard.document"&gt;</code> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.</p> 37 + 38 + <h2>Comments</h2> 39 + <sequoia-comments></sequoia-comments> 40 + 41 + <script type="module" src="./src/components/sequoia-comments.js"></script> 42 + </body> 43 + </html>

History

2 rounds 0 comments
sign up or login to add to the discussion
8 commits
expand
feat: initial ui components
chore: small updates
chore: refactored package into existing cli
chore: tested comments in docs
chore: updated thread style and added test.html
chore: update docs
chore: updated comments
chore: lint
1/1 success
expand
expand 0 comments
pull request successfully merged
7 commits
expand
feat: initial ui components
chore: small updates
chore: refactored package into existing cli
chore: tested comments in docs
chore: updated thread style and added test.html
chore: update docs
chore: updated comments
1/1 failed
expand
expand 0 comments