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

feat: initial ui components

authored by stevedylan.dev and committed by tangled.org 9ceb04a7 aa3a3938

+1057 -1
-1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 - packages/ui
+3
packages/ui/.gitignore
··· 1 + dist/ 2 + node_modules/ 3 + test-site/
+37
packages/ui/biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "includes": ["**", "!!**/dist"] 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true, 19 + "style": { 20 + "noNonNullAssertion": "off" 21 + } 22 + } 23 + }, 24 + "javascript": { 25 + "formatter": { 26 + "quoteStyle": "double" 27 + } 28 + }, 29 + "assist": { 30 + "enabled": true, 31 + "actions": { 32 + "source": { 33 + "organizeImports": "on" 34 + } 35 + } 36 + } 37 + }
+28
packages/ui/package.json
··· 1 + { 2 + "name": "sequoia-ui", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "files": [ 6 + "dist", 7 + "README.md" 8 + ], 9 + "main": "./dist/index.js", 10 + "exports": { 11 + ".": "./dist/index.js", 12 + "./comments": "./dist/index.js" 13 + }, 14 + "scripts": { 15 + "lint": "biome lint --write", 16 + "format": "biome format --write", 17 + "build": "bun build src/index.ts --outdir dist --target browser && bun build src/index.ts --outfile dist/sequoia-comments.iife.js --target browser --format iife --minify", 18 + "dev": "bun run build", 19 + "deploy": "bun run build && bun publish --access public" 20 + }, 21 + "devDependencies": { 22 + "@biomejs/biome": "^2.3.13", 23 + "@types/node": "^20" 24 + }, 25 + "peerDependencies": { 26 + "typescript": "^5" 27 + } 28 + }
+11
packages/ui/src/components/sequoia-comments/index.ts
··· 1 + import { SequoiaComments } from "./sequoia-comments"; 2 + 3 + // Register the custom element if not already registered 4 + if ( 5 + typeof customElements !== "undefined" && 6 + !customElements.get("sequoia-comments") 7 + ) { 8 + customElements.define("sequoia-comments", SequoiaComments); 9 + } 10 + 11 + export { SequoiaComments };
+270
packages/ui/src/components/sequoia-comments/sequoia-comments.ts
··· 1 + import { 2 + buildBskyAppUrl, 3 + getDocument, 4 + getPostThread, 5 + } from "../../lib/atproto-client"; 6 + import type { ThreadViewPost } from "../../types/bluesky"; 7 + import { isThreadViewPost } from "../../types/bluesky"; 8 + import { styles } from "./styles"; 9 + import { formatRelativeTime, getInitials, renderTextWithFacets } from "./utils"; 10 + 11 + /** 12 + * Component state 13 + */ 14 + type State = 15 + | { type: "loading" } 16 + | { type: "loaded"; thread: ThreadViewPost; postUrl: string } 17 + | { type: "no-document" } 18 + | { type: "no-comments-enabled" } 19 + | { type: "empty"; postUrl: string } 20 + | { type: "error"; message: string }; 21 + 22 + /** 23 + * Bluesky butterfly SVG icon 24 + */ 25 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 26 + <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"/> 27 + </svg>`; 28 + 29 + export class SequoiaComments extends HTMLElement { 30 + private shadow: ShadowRoot; 31 + private state: State = { type: "loading" }; 32 + private abortController: AbortController | null = null; 33 + 34 + static get observedAttributes(): string[] { 35 + return ["document-uri", "depth"]; 36 + } 37 + 38 + constructor() { 39 + super(); 40 + this.shadow = this.attachShadow({ mode: "open" }); 41 + } 42 + 43 + connectedCallback(): void { 44 + this.render(); 45 + this.loadComments(); 46 + } 47 + 48 + disconnectedCallback(): void { 49 + this.abortController?.abort(); 50 + } 51 + 52 + attributeChangedCallback(): void { 53 + if (this.isConnected) { 54 + this.loadComments(); 55 + } 56 + } 57 + 58 + private get documentUri(): string | null { 59 + // First check attribute 60 + const attrUri = this.getAttribute("document-uri"); 61 + if (attrUri) { 62 + return attrUri; 63 + } 64 + 65 + // Then scan for link tag in document head 66 + const linkTag = document.querySelector<HTMLLinkElement>( 67 + 'link[rel="site.standard.document"]', 68 + ); 69 + return linkTag?.href ?? null; 70 + } 71 + 72 + private get depth(): number { 73 + const depthAttr = this.getAttribute("depth"); 74 + return depthAttr ? Number.parseInt(depthAttr, 10) : 6; 75 + } 76 + 77 + private async loadComments(): Promise<void> { 78 + // Cancel any in-flight request 79 + this.abortController?.abort(); 80 + this.abortController = new AbortController(); 81 + 82 + this.state = { type: "loading" }; 83 + this.render(); 84 + 85 + const docUri = this.documentUri; 86 + if (!docUri) { 87 + this.state = { type: "no-document" }; 88 + this.render(); 89 + return; 90 + } 91 + 92 + try { 93 + // Fetch the document record 94 + const document = await getDocument(docUri); 95 + 96 + // Check if document has a Bluesky post reference 97 + if (!document.bskyPostRef) { 98 + this.state = { type: "no-comments-enabled" }; 99 + this.render(); 100 + return; 101 + } 102 + 103 + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 104 + 105 + // Fetch the post thread 106 + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 107 + 108 + // Check if there are any replies 109 + const replies = thread.replies?.filter(isThreadViewPost) ?? []; 110 + if (replies.length === 0) { 111 + this.state = { type: "empty", postUrl }; 112 + this.render(); 113 + return; 114 + } 115 + 116 + this.state = { type: "loaded", thread, postUrl }; 117 + this.render(); 118 + } catch (error) { 119 + const message = 120 + error instanceof Error ? error.message : "Failed to load comments"; 121 + this.state = { type: "error", message }; 122 + this.render(); 123 + } 124 + } 125 + 126 + private render(): void { 127 + const styleTag = `<style>${styles}</style>`; 128 + 129 + switch (this.state.type) { 130 + case "loading": 131 + this.shadow.innerHTML = ` 132 + ${styleTag} 133 + <div class="sequoia-comments-container"> 134 + <div class="sequoia-loading"> 135 + <span class="sequoia-loading-spinner"></span> 136 + Loading comments... 137 + </div> 138 + </div> 139 + `; 140 + break; 141 + 142 + case "no-document": 143 + this.shadow.innerHTML = ` 144 + ${styleTag} 145 + <div class="sequoia-comments-container"> 146 + <div class="sequoia-warning"> 147 + No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 148 + </div> 149 + </div> 150 + `; 151 + break; 152 + 153 + case "no-comments-enabled": 154 + this.shadow.innerHTML = ` 155 + ${styleTag} 156 + <div class="sequoia-comments-container"> 157 + <div class="sequoia-empty"> 158 + Comments are not enabled for this post. 159 + </div> 160 + </div> 161 + `; 162 + break; 163 + 164 + case "empty": 165 + this.shadow.innerHTML = ` 166 + ${styleTag} 167 + <div class="sequoia-comments-container"> 168 + <div class="sequoia-comments-header"> 169 + <h3 class="sequoia-comments-title">Comments</h3> 170 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 171 + ${BLUESKY_ICON} 172 + Reply on Bluesky 173 + </a> 174 + </div> 175 + <div class="sequoia-empty"> 176 + No comments yet. Be the first to reply on Bluesky! 177 + </div> 178 + </div> 179 + `; 180 + break; 181 + 182 + case "error": 183 + this.shadow.innerHTML = ` 184 + ${styleTag} 185 + <div class="sequoia-comments-container"> 186 + <div class="sequoia-error"> 187 + Failed to load comments: ${this.escapeHtml(this.state.message)} 188 + </div> 189 + </div> 190 + `; 191 + break; 192 + 193 + case "loaded": { 194 + const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; 195 + const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); 196 + const commentCount = this.countComments(replies); 197 + 198 + this.shadow.innerHTML = ` 199 + ${styleTag} 200 + <div class="sequoia-comments-container"> 201 + <div class="sequoia-comments-header"> 202 + <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 203 + <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 204 + ${BLUESKY_ICON} 205 + Reply on Bluesky 206 + </a> 207 + </div> 208 + <div class="sequoia-comments-list"> 209 + ${commentsHtml} 210 + </div> 211 + </div> 212 + `; 213 + break; 214 + } 215 + } 216 + } 217 + 218 + private renderComment(thread: ThreadViewPost): string { 219 + const { post } = thread; 220 + const author = post.author; 221 + const displayName = author.displayName || author.handle; 222 + const avatarHtml = author.avatar 223 + ? `<img class="sequoia-comment-avatar" src="${this.escapeHtml(author.avatar)}" alt="${this.escapeHtml(displayName)}" loading="lazy" />` 224 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 225 + 226 + const profileUrl = `https://bsky.app/profile/${author.did}`; 227 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 228 + const timeAgo = formatRelativeTime(post.record.createdAt); 229 + 230 + // Render nested replies 231 + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 232 + const repliesHtml = 233 + nestedReplies.length > 0 234 + ? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>` 235 + : ""; 236 + 237 + return ` 238 + <div class="sequoia-comment"> 239 + <div class="sequoia-comment-header"> 240 + ${avatarHtml} 241 + <div class="sequoia-comment-meta"> 242 + <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 243 + ${this.escapeHtml(displayName)} 244 + </a> 245 + <span class="sequoia-comment-handle">@${this.escapeHtml(author.handle)}</span> 246 + </div> 247 + <span class="sequoia-comment-time">${timeAgo}</span> 248 + </div> 249 + <p class="sequoia-comment-text">${textHtml}</p> 250 + ${repliesHtml} 251 + </div> 252 + `; 253 + } 254 + 255 + private countComments(replies: ThreadViewPost[]): number { 256 + let count = 0; 257 + for (const reply of replies) { 258 + count += 1; 259 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 260 + count += this.countComments(nested); 261 + } 262 + return count; 263 + } 264 + 265 + private escapeHtml(text: string): string { 266 + const div = document.createElement("div"); 267 + div.textContent = text; 268 + return div.innerHTML; 269 + } 270 + }
+218
packages/ui/src/components/sequoia-comments/styles.ts
··· 1 + export const styles = ` 2 + :host { 3 + display: block; 4 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 5 + color: var(--sequoia-fg-color, #1f2937); 6 + line-height: 1.5; 7 + } 8 + 9 + * { 10 + box-sizing: border-box; 11 + } 12 + 13 + .sequoia-comments-container { 14 + max-width: 100%; 15 + } 16 + 17 + .sequoia-loading, 18 + .sequoia-error, 19 + .sequoia-empty, 20 + .sequoia-warning { 21 + padding: 1rem; 22 + border-radius: var(--sequoia-border-radius, 8px); 23 + text-align: center; 24 + } 25 + 26 + .sequoia-loading { 27 + background: var(--sequoia-bg-color, #ffffff); 28 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 29 + color: var(--sequoia-secondary-color, #6b7280); 30 + } 31 + 32 + .sequoia-loading-spinner { 33 + display: inline-block; 34 + width: 1.25rem; 35 + height: 1.25rem; 36 + border: 2px solid var(--sequoia-border-color, #e5e7eb); 37 + border-top-color: var(--sequoia-accent-color, #2563eb); 38 + border-radius: 50%; 39 + animation: sequoia-spin 0.8s linear infinite; 40 + margin-right: 0.5rem; 41 + vertical-align: middle; 42 + } 43 + 44 + @keyframes sequoia-spin { 45 + to { transform: rotate(360deg); } 46 + } 47 + 48 + .sequoia-error { 49 + background: #fef2f2; 50 + border: 1px solid #fecaca; 51 + color: #dc2626; 52 + } 53 + 54 + .sequoia-warning { 55 + background: #fffbeb; 56 + border: 1px solid #fde68a; 57 + color: #d97706; 58 + } 59 + 60 + .sequoia-empty { 61 + background: var(--sequoia-bg-color, #ffffff); 62 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 63 + color: var(--sequoia-secondary-color, #6b7280); 64 + } 65 + 66 + .sequoia-comments-header { 67 + display: flex; 68 + justify-content: space-between; 69 + align-items: center; 70 + margin-bottom: 1rem; 71 + padding-bottom: 0.75rem; 72 + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 73 + } 74 + 75 + .sequoia-comments-title { 76 + font-size: 1.125rem; 77 + font-weight: 600; 78 + margin: 0; 79 + } 80 + 81 + .sequoia-reply-button { 82 + display: inline-flex; 83 + align-items: center; 84 + gap: 0.375rem; 85 + padding: 0.5rem 1rem; 86 + background: var(--sequoia-accent-color, #2563eb); 87 + color: #ffffff; 88 + border: none; 89 + border-radius: var(--sequoia-border-radius, 8px); 90 + font-size: 0.875rem; 91 + font-weight: 500; 92 + cursor: pointer; 93 + text-decoration: none; 94 + transition: background-color 0.15s ease; 95 + } 96 + 97 + .sequoia-reply-button:hover { 98 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 99 + } 100 + 101 + .sequoia-reply-button svg { 102 + width: 1rem; 103 + height: 1rem; 104 + } 105 + 106 + .sequoia-comments-list { 107 + display: flex; 108 + flex-direction: column; 109 + gap: 0; 110 + } 111 + 112 + .sequoia-comment { 113 + padding: 1rem; 114 + background: var(--sequoia-bg-color, #ffffff); 115 + border: 1px solid var(--sequoia-border-color, #e5e7eb); 116 + border-radius: var(--sequoia-border-radius, 8px); 117 + margin-bottom: 0.75rem; 118 + } 119 + 120 + .sequoia-comment-header { 121 + display: flex; 122 + align-items: center; 123 + gap: 0.75rem; 124 + margin-bottom: 0.5rem; 125 + } 126 + 127 + .sequoia-comment-avatar { 128 + width: 2.5rem; 129 + height: 2.5rem; 130 + border-radius: 50%; 131 + background: var(--sequoia-border-color, #e5e7eb); 132 + object-fit: cover; 133 + flex-shrink: 0; 134 + } 135 + 136 + .sequoia-comment-avatar-placeholder { 137 + width: 2.5rem; 138 + height: 2.5rem; 139 + border-radius: 50%; 140 + background: var(--sequoia-border-color, #e5e7eb); 141 + display: flex; 142 + align-items: center; 143 + justify-content: center; 144 + flex-shrink: 0; 145 + color: var(--sequoia-secondary-color, #6b7280); 146 + font-weight: 600; 147 + font-size: 1rem; 148 + } 149 + 150 + .sequoia-comment-meta { 151 + display: flex; 152 + flex-direction: column; 153 + min-width: 0; 154 + } 155 + 156 + .sequoia-comment-author { 157 + font-weight: 600; 158 + color: var(--sequoia-fg-color, #1f2937); 159 + text-decoration: none; 160 + overflow: hidden; 161 + text-overflow: ellipsis; 162 + white-space: nowrap; 163 + } 164 + 165 + .sequoia-comment-author:hover { 166 + color: var(--sequoia-accent-color, #2563eb); 167 + } 168 + 169 + .sequoia-comment-handle { 170 + font-size: 0.875rem; 171 + color: var(--sequoia-secondary-color, #6b7280); 172 + overflow: hidden; 173 + text-overflow: ellipsis; 174 + white-space: nowrap; 175 + } 176 + 177 + .sequoia-comment-time { 178 + font-size: 0.75rem; 179 + color: var(--sequoia-secondary-color, #6b7280); 180 + margin-left: auto; 181 + flex-shrink: 0; 182 + } 183 + 184 + .sequoia-comment-text { 185 + margin: 0; 186 + white-space: pre-wrap; 187 + word-wrap: break-word; 188 + } 189 + 190 + .sequoia-comment-text a { 191 + color: var(--sequoia-accent-color, #2563eb); 192 + text-decoration: none; 193 + } 194 + 195 + .sequoia-comment-text a:hover { 196 + text-decoration: underline; 197 + } 198 + 199 + .sequoia-comment-replies { 200 + margin-top: 0.75rem; 201 + margin-left: 1.5rem; 202 + padding-left: 1rem; 203 + border-left: 2px solid var(--sequoia-border-color, #e5e7eb); 204 + } 205 + 206 + .sequoia-comment-replies .sequoia-comment { 207 + margin-bottom: 0.5rem; 208 + } 209 + 210 + .sequoia-comment-replies .sequoia-comment:last-child { 211 + margin-bottom: 0; 212 + } 213 + 214 + .sequoia-bsky-logo { 215 + width: 1rem; 216 + height: 1rem; 217 + } 218 + `;
+127
packages/ui/src/components/sequoia-comments/utils.ts
··· 1 + /** 2 + * Format a relative time string (e.g., "2 hours ago") 3 + */ 4 + export function formatRelativeTime(dateString: string): string { 5 + const date = new Date(dateString); 6 + const now = new Date(); 7 + const diffMs = now.getTime() - date.getTime(); 8 + const diffSeconds = Math.floor(diffMs / 1000); 9 + const diffMinutes = Math.floor(diffSeconds / 60); 10 + const diffHours = Math.floor(diffMinutes / 60); 11 + const diffDays = Math.floor(diffHours / 24); 12 + const diffWeeks = Math.floor(diffDays / 7); 13 + const diffMonths = Math.floor(diffDays / 30); 14 + const diffYears = Math.floor(diffDays / 365); 15 + 16 + if (diffSeconds < 60) { 17 + return "just now"; 18 + } 19 + if (diffMinutes < 60) { 20 + return `${diffMinutes}m ago`; 21 + } 22 + if (diffHours < 24) { 23 + return `${diffHours}h ago`; 24 + } 25 + if (diffDays < 7) { 26 + return `${diffDays}d ago`; 27 + } 28 + if (diffWeeks < 4) { 29 + return `${diffWeeks}w ago`; 30 + } 31 + if (diffMonths < 12) { 32 + return `${diffMonths}mo ago`; 33 + } 34 + return `${diffYears}y ago`; 35 + } 36 + 37 + /** 38 + * Escape HTML special characters 39 + */ 40 + export function escapeHtml(text: string): string { 41 + const div = document.createElement("div"); 42 + div.textContent = text; 43 + return div.innerHTML; 44 + } 45 + 46 + /** 47 + * Convert post text with facets to HTML 48 + */ 49 + export function renderTextWithFacets( 50 + text: string, 51 + facets?: Array<{ 52 + index: { byteStart: number; byteEnd: number }; 53 + features: Array< 54 + | { $type: "app.bsky.richtext.facet#link"; uri: string } 55 + | { $type: "app.bsky.richtext.facet#mention"; did: string } 56 + | { $type: "app.bsky.richtext.facet#tag"; tag: string } 57 + >; 58 + }>, 59 + ): string { 60 + if (!facets || facets.length === 0) { 61 + return escapeHtml(text); 62 + } 63 + 64 + // Convert text to bytes for proper indexing 65 + const encoder = new TextEncoder(); 66 + const decoder = new TextDecoder(); 67 + const textBytes = encoder.encode(text); 68 + 69 + // Sort facets by start index 70 + const sortedFacets = [...facets].sort( 71 + (a, b) => a.index.byteStart - b.index.byteStart, 72 + ); 73 + 74 + let result = ""; 75 + let lastEnd = 0; 76 + 77 + for (const facet of sortedFacets) { 78 + const { byteStart, byteEnd } = facet.index; 79 + 80 + // Add text before this facet 81 + if (byteStart > lastEnd) { 82 + const beforeBytes = textBytes.slice(lastEnd, byteStart); 83 + result += escapeHtml(decoder.decode(beforeBytes)); 84 + } 85 + 86 + // Get the facet text 87 + const facetBytes = textBytes.slice(byteStart, byteEnd); 88 + const facetText = decoder.decode(facetBytes); 89 + 90 + // Find the first renderable feature 91 + const feature = facet.features[0]; 92 + if (feature) { 93 + if (feature.$type === "app.bsky.richtext.facet#link") { 94 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 95 + } else if (feature.$type === "app.bsky.richtext.facet#mention") { 96 + result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 97 + } else if (feature.$type === "app.bsky.richtext.facet#tag") { 98 + result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 99 + } else { 100 + result += escapeHtml(facetText); 101 + } 102 + } else { 103 + result += escapeHtml(facetText); 104 + } 105 + 106 + lastEnd = byteEnd; 107 + } 108 + 109 + // Add remaining text 110 + if (lastEnd < textBytes.length) { 111 + const remainingBytes = textBytes.slice(lastEnd); 112 + result += escapeHtml(decoder.decode(remainingBytes)); 113 + } 114 + 115 + return result; 116 + } 117 + 118 + /** 119 + * Get initials from a name for avatar placeholder 120 + */ 121 + export function getInitials(name: string): string { 122 + const parts = name.trim().split(/\s+/); 123 + if (parts.length >= 2) { 124 + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); 125 + } 126 + return name.substring(0, 2).toUpperCase(); 127 + }
+26
packages/ui/src/index.ts
··· 1 + // Components 2 + export { SequoiaComments } from "./components/sequoia-comments"; 3 + 4 + // AT Protocol client utilities 5 + export { 6 + parseAtUri, 7 + resolvePDS, 8 + getRecord, 9 + getDocument, 10 + getPostThread, 11 + buildBskyAppUrl, 12 + } from "./lib/atproto-client"; 13 + 14 + // Types 15 + export type { 16 + StrongRef, 17 + ProfileViewBasic, 18 + PostRecord, 19 + PostView, 20 + ThreadViewPost, 21 + BlockedPost, 22 + NotFoundPost, 23 + DocumentRecord, 24 + } from "./types/bluesky"; 25 + 26 + export { isThreadViewPost } from "./types/bluesky";
+144
packages/ui/src/lib/atproto-client.ts
··· 1 + import type { 2 + DIDDocument, 3 + DocumentRecord, 4 + GetPostThreadResponse, 5 + GetRecordResponse, 6 + ThreadViewPost, 7 + } from "../types/bluesky"; 8 + 9 + /** 10 + * Parse an AT URI into its components 11 + * Format: at://did/collection/rkey 12 + */ 13 + export function parseAtUri( 14 + atUri: string, 15 + ): { did: string; collection: string; rkey: string } | null { 16 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 17 + if (!match) return null; 18 + return { 19 + did: match[1]!, 20 + collection: match[2]!, 21 + rkey: match[3]!, 22 + }; 23 + } 24 + 25 + /** 26 + * Resolve a DID to its PDS URL 27 + * Supports did:plc and did:web methods 28 + */ 29 + export async function resolvePDS(did: string): Promise<string> { 30 + let pdsUrl: string | undefined; 31 + 32 + if (did.startsWith("did:plc:")) { 33 + // Fetch DID document from plc.directory 34 + const didDocUrl = `https://plc.directory/${did}`; 35 + const didDocResponse = await fetch(didDocUrl); 36 + if (!didDocResponse.ok) { 37 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 38 + } 39 + const didDoc: DIDDocument = await didDocResponse.json(); 40 + 41 + // Find the PDS service endpoint 42 + const pdsService = didDoc.service?.find( 43 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 44 + ); 45 + pdsUrl = pdsService?.serviceEndpoint; 46 + } else if (did.startsWith("did:web:")) { 47 + // For did:web, fetch the DID document from the domain 48 + const domain = did.replace("did:web:", ""); 49 + const didDocUrl = `https://${domain}/.well-known/did.json`; 50 + const didDocResponse = await fetch(didDocUrl); 51 + if (!didDocResponse.ok) { 52 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 53 + } 54 + const didDoc: DIDDocument = await didDocResponse.json(); 55 + 56 + const pdsService = didDoc.service?.find( 57 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 58 + ); 59 + pdsUrl = pdsService?.serviceEndpoint; 60 + } else { 61 + throw new Error(`Unsupported DID method: ${did}`); 62 + } 63 + 64 + if (!pdsUrl) { 65 + throw new Error("Could not find PDS URL for user"); 66 + } 67 + 68 + return pdsUrl; 69 + } 70 + 71 + /** 72 + * Fetch a record from a PDS using the public API 73 + */ 74 + export async function getRecord<T>( 75 + did: string, 76 + collection: string, 77 + rkey: string, 78 + ): Promise<T> { 79 + const pdsUrl = await resolvePDS(did); 80 + 81 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 82 + url.searchParams.set("repo", did); 83 + url.searchParams.set("collection", collection); 84 + url.searchParams.set("rkey", rkey); 85 + 86 + const response = await fetch(url.toString()); 87 + if (!response.ok) { 88 + throw new Error(`Failed to fetch record: ${response.status}`); 89 + } 90 + 91 + const data: GetRecordResponse<T> = await response.json(); 92 + return data.value; 93 + } 94 + 95 + /** 96 + * Fetch a document record from its AT URI 97 + */ 98 + export async function getDocument(atUri: string): Promise<DocumentRecord> { 99 + const parsed = parseAtUri(atUri); 100 + if (!parsed) { 101 + throw new Error(`Invalid AT URI: ${atUri}`); 102 + } 103 + 104 + return getRecord<DocumentRecord>(parsed.did, parsed.collection, parsed.rkey); 105 + } 106 + 107 + /** 108 + * Fetch a post thread from the public Bluesky API 109 + */ 110 + export async function getPostThread( 111 + postUri: string, 112 + depth = 6, 113 + ): Promise<ThreadViewPost> { 114 + const url = new URL( 115 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 116 + ); 117 + url.searchParams.set("uri", postUri); 118 + url.searchParams.set("depth", depth.toString()); 119 + 120 + const response = await fetch(url.toString()); 121 + if (!response.ok) { 122 + throw new Error(`Failed to fetch post thread: ${response.status}`); 123 + } 124 + 125 + const data: GetPostThreadResponse = await response.json(); 126 + 127 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 128 + throw new Error("Post not found or blocked"); 129 + } 130 + 131 + return data.thread as ThreadViewPost; 132 + } 133 + 134 + /** 135 + * Build a Bluesky app URL for a post 136 + */ 137 + export function buildBskyAppUrl(postUri: string): string { 138 + const parsed = parseAtUri(postUri); 139 + if (!parsed) { 140 + throw new Error(`Invalid post URI: ${postUri}`); 141 + } 142 + 143 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 144 + }
+133
packages/ui/src/types/bluesky.ts
··· 1 + /** 2 + * Strong reference for AT Protocol records (com.atproto.repo.strongRef) 3 + */ 4 + export interface StrongRef { 5 + uri: string; // at:// URI format 6 + cid: string; // Content ID 7 + } 8 + 9 + /** 10 + * Basic profile view from Bluesky API 11 + */ 12 + export interface ProfileViewBasic { 13 + did: string; 14 + handle: string; 15 + displayName?: string; 16 + avatar?: string; 17 + } 18 + 19 + /** 20 + * Post record content from app.bsky.feed.post 21 + */ 22 + export interface PostRecord { 23 + $type: "app.bsky.feed.post"; 24 + text: string; 25 + createdAt: string; 26 + reply?: { 27 + root: StrongRef; 28 + parent: StrongRef; 29 + }; 30 + facets?: Array<{ 31 + index: { byteStart: number; byteEnd: number }; 32 + features: Array< 33 + | { $type: "app.bsky.richtext.facet#link"; uri: string } 34 + | { $type: "app.bsky.richtext.facet#mention"; did: string } 35 + | { $type: "app.bsky.richtext.facet#tag"; tag: string } 36 + >; 37 + }>; 38 + } 39 + 40 + /** 41 + * Post view from Bluesky API 42 + */ 43 + export interface PostView { 44 + uri: string; 45 + cid: string; 46 + author: ProfileViewBasic; 47 + record: PostRecord; 48 + replyCount?: number; 49 + repostCount?: number; 50 + likeCount?: number; 51 + indexedAt: string; 52 + } 53 + 54 + /** 55 + * Thread view post from app.bsky.feed.getPostThread 56 + */ 57 + export interface ThreadViewPost { 58 + $type: "app.bsky.feed.defs#threadViewPost"; 59 + post: PostView; 60 + parent?: ThreadViewPost | BlockedPost | NotFoundPost; 61 + replies?: Array<ThreadViewPost | BlockedPost | NotFoundPost>; 62 + } 63 + 64 + /** 65 + * Blocked post placeholder 66 + */ 67 + export interface BlockedPost { 68 + $type: "app.bsky.feed.defs#blockedPost"; 69 + uri: string; 70 + blocked: true; 71 + } 72 + 73 + /** 74 + * Not found post placeholder 75 + */ 76 + export interface NotFoundPost { 77 + $type: "app.bsky.feed.defs#notFoundPost"; 78 + uri: string; 79 + notFound: true; 80 + } 81 + 82 + /** 83 + * Type guard for ThreadViewPost 84 + */ 85 + export function isThreadViewPost( 86 + post: ThreadViewPost | BlockedPost | NotFoundPost | undefined, 87 + ): post is ThreadViewPost { 88 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 89 + } 90 + 91 + /** 92 + * Document record from site.standard.document 93 + */ 94 + export interface DocumentRecord { 95 + $type: "site.standard.document"; 96 + title: string; 97 + site: string; 98 + path: string; 99 + textContent: string; 100 + publishedAt: string; 101 + canonicalUrl?: string; 102 + description?: string; 103 + tags?: string[]; 104 + bskyPostRef?: StrongRef; 105 + } 106 + 107 + /** 108 + * DID document structure 109 + */ 110 + export interface DIDDocument { 111 + id: string; 112 + service?: Array<{ 113 + id: string; 114 + type: string; 115 + serviceEndpoint: string; 116 + }>; 117 + } 118 + 119 + /** 120 + * Response from com.atproto.repo.getRecord 121 + */ 122 + export interface GetRecordResponse<T> { 123 + uri: string; 124 + cid: string; 125 + value: T; 126 + } 127 + 128 + /** 129 + * Response from app.bsky.feed.getPostThread 130 + */ 131 + export interface GetPostThreadResponse { 132 + thread: ThreadViewPost | BlockedPost | NotFoundPost; 133 + }
+43
packages/ui/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:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3me3hbjtw2v2v"> 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 + } 17 + h1 { 18 + margin-bottom: 2rem; 19 + } 20 + /* Custom styling example */ 21 + sequoia-comments { 22 + --sequoia-accent-color: #0070f3; 23 + --sequoia-border-radius: 12px; 24 + } 25 + .dark-theme sequoia-comments { 26 + --sequoia-bg-color: #1a1a1a; 27 + --sequoia-fg-color: #ffffff; 28 + --sequoia-border-color: #333; 29 + --sequoia-secondary-color: #888; 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 src="./dist/sequoia-comments.iife.js"></script> 42 + </body> 43 + </html>
+17
packages/ui/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 + "strict": true, 8 + "esModuleInterop": true, 9 + "skipLibCheck": true, 10 + "declaration": true, 11 + "declarationMap": true, 12 + "outDir": "./dist", 13 + "rootDir": "./src" 14 + }, 15 + "include": ["src/**/*"], 16 + "exclude": ["node_modules", "dist"] 17 + }