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

chore: refactored package into existing cli

authored by stevedylan.dev and committed by tangled.org c3f94619 77af2e1f

+963 -1115
+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 "url"; 7 + import { dirname } from "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 + });
+796
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 + 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 };
+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)
-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 - }
-34
packages/ui/package.json
··· 1 - { 2 - "name": "sequoia-ui", 3 - "version": "0.0.2", 4 - "type": "module", 5 - "files": [ 6 - "dist", 7 - "README.md" 8 - ], 9 - "main": "./dist/index.js", 10 - "exports": { 11 - ".": { 12 - "import": "./dist/index.js", 13 - "default": "./dist/index.js" 14 - }, 15 - "./comments": { 16 - "import": "./dist/index.js", 17 - "default": "./dist/index.js" 18 - } 19 - }, 20 - "scripts": { 21 - "lint": "biome lint --write", 22 - "format": "biome format --write", 23 - "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", 24 - "dev": "bun run build", 25 - "deploy": "bun run build && bun publish --access public" 26 - }, 27 - "devDependencies": { 28 - "@biomejs/biome": "^2.3.13", 29 - "@types/node": "^20" 30 - }, 31 - "peerDependencies": { 32 - "typescript": "^5" 33 - } 34 - }
-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 };
-276
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 - // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 30 - const BaseElement = 31 - typeof HTMLElement !== "undefined" 32 - ? HTMLElement 33 - : (class {} as typeof HTMLElement); 34 - 35 - export class SequoiaComments extends BaseElement { 36 - private shadow: ShadowRoot; 37 - private state: State = { type: "loading" }; 38 - private abortController: AbortController | null = null; 39 - 40 - static get observedAttributes(): string[] { 41 - return ["document-uri", "depth"]; 42 - } 43 - 44 - constructor() { 45 - super(); 46 - this.shadow = this.attachShadow({ mode: "open" }); 47 - } 48 - 49 - connectedCallback(): void { 50 - this.render(); 51 - this.loadComments(); 52 - } 53 - 54 - disconnectedCallback(): void { 55 - this.abortController?.abort(); 56 - } 57 - 58 - attributeChangedCallback(): void { 59 - if (this.isConnected) { 60 - this.loadComments(); 61 - } 62 - } 63 - 64 - private get documentUri(): string | null { 65 - // First check attribute 66 - const attrUri = this.getAttribute("document-uri"); 67 - if (attrUri) { 68 - return attrUri; 69 - } 70 - 71 - // Then scan for link tag in document head 72 - const linkTag = document.querySelector<HTMLLinkElement>( 73 - 'link[rel="site.standard.document"]', 74 - ); 75 - return linkTag?.href ?? null; 76 - } 77 - 78 - private get depth(): number { 79 - const depthAttr = this.getAttribute("depth"); 80 - return depthAttr ? Number.parseInt(depthAttr, 10) : 6; 81 - } 82 - 83 - private async loadComments(): Promise<void> { 84 - // Cancel any in-flight request 85 - this.abortController?.abort(); 86 - this.abortController = new AbortController(); 87 - 88 - this.state = { type: "loading" }; 89 - this.render(); 90 - 91 - const docUri = this.documentUri; 92 - if (!docUri) { 93 - this.state = { type: "no-document" }; 94 - this.render(); 95 - return; 96 - } 97 - 98 - try { 99 - // Fetch the document record 100 - const document = await getDocument(docUri); 101 - 102 - // Check if document has a Bluesky post reference 103 - if (!document.bskyPostRef) { 104 - this.state = { type: "no-comments-enabled" }; 105 - this.render(); 106 - return; 107 - } 108 - 109 - const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 110 - 111 - // Fetch the post thread 112 - const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 113 - 114 - // Check if there are any replies 115 - const replies = thread.replies?.filter(isThreadViewPost) ?? []; 116 - if (replies.length === 0) { 117 - this.state = { type: "empty", postUrl }; 118 - this.render(); 119 - return; 120 - } 121 - 122 - this.state = { type: "loaded", thread, postUrl }; 123 - this.render(); 124 - } catch (error) { 125 - const message = 126 - error instanceof Error ? error.message : "Failed to load comments"; 127 - this.state = { type: "error", message }; 128 - this.render(); 129 - } 130 - } 131 - 132 - private render(): void { 133 - const styleTag = `<style>${styles}</style>`; 134 - 135 - switch (this.state.type) { 136 - case "loading": 137 - this.shadow.innerHTML = ` 138 - ${styleTag} 139 - <div class="sequoia-comments-container"> 140 - <div class="sequoia-loading"> 141 - <span class="sequoia-loading-spinner"></span> 142 - Loading comments... 143 - </div> 144 - </div> 145 - `; 146 - break; 147 - 148 - case "no-document": 149 - this.shadow.innerHTML = ` 150 - ${styleTag} 151 - <div class="sequoia-comments-container"> 152 - <div class="sequoia-warning"> 153 - No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 154 - </div> 155 - </div> 156 - `; 157 - break; 158 - 159 - case "no-comments-enabled": 160 - this.shadow.innerHTML = ` 161 - ${styleTag} 162 - <div class="sequoia-comments-container"> 163 - <div class="sequoia-empty"> 164 - Comments are not enabled for this post. 165 - </div> 166 - </div> 167 - `; 168 - break; 169 - 170 - case "empty": 171 - this.shadow.innerHTML = ` 172 - ${styleTag} 173 - <div class="sequoia-comments-container"> 174 - <div class="sequoia-comments-header"> 175 - <h3 class="sequoia-comments-title">Comments</h3> 176 - <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 177 - ${BLUESKY_ICON} 178 - Reply on Bluesky 179 - </a> 180 - </div> 181 - <div class="sequoia-empty"> 182 - No comments yet. Be the first to reply on Bluesky! 183 - </div> 184 - </div> 185 - `; 186 - break; 187 - 188 - case "error": 189 - this.shadow.innerHTML = ` 190 - ${styleTag} 191 - <div class="sequoia-comments-container"> 192 - <div class="sequoia-error"> 193 - Failed to load comments: ${this.escapeHtml(this.state.message)} 194 - </div> 195 - </div> 196 - `; 197 - break; 198 - 199 - case "loaded": { 200 - const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; 201 - const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); 202 - const commentCount = this.countComments(replies); 203 - 204 - this.shadow.innerHTML = ` 205 - ${styleTag} 206 - <div class="sequoia-comments-container"> 207 - <div class="sequoia-comments-header"> 208 - <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 209 - <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 210 - ${BLUESKY_ICON} 211 - Reply on Bluesky 212 - </a> 213 - </div> 214 - <div class="sequoia-comments-list"> 215 - ${commentsHtml} 216 - </div> 217 - </div> 218 - `; 219 - break; 220 - } 221 - } 222 - } 223 - 224 - private renderComment(thread: ThreadViewPost): string { 225 - const { post } = thread; 226 - const author = post.author; 227 - const displayName = author.displayName || author.handle; 228 - const avatarHtml = author.avatar 229 - ? `<img class="sequoia-comment-avatar" src="${this.escapeHtml(author.avatar)}" alt="${this.escapeHtml(displayName)}" loading="lazy" />` 230 - : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 231 - 232 - const profileUrl = `https://bsky.app/profile/${author.did}`; 233 - const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 234 - const timeAgo = formatRelativeTime(post.record.createdAt); 235 - 236 - // Render nested replies 237 - const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 238 - const repliesHtml = 239 - nestedReplies.length > 0 240 - ? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>` 241 - : ""; 242 - 243 - return ` 244 - <div class="sequoia-comment"> 245 - <div class="sequoia-comment-header"> 246 - ${avatarHtml} 247 - <div class="sequoia-comment-meta"> 248 - <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 249 - ${this.escapeHtml(displayName)} 250 - </a> 251 - <span class="sequoia-comment-handle">@${this.escapeHtml(author.handle)}</span> 252 - </div> 253 - <span class="sequoia-comment-time">${timeAgo}</span> 254 - </div> 255 - <p class="sequoia-comment-text">${textHtml}</p> 256 - ${repliesHtml} 257 - </div> 258 - `; 259 - } 260 - 261 - private countComments(replies: ThreadViewPost[]): number { 262 - let count = 0; 263 - for (const reply of replies) { 264 - count += 1; 265 - const nested = reply.replies?.filter(isThreadViewPost) ?? []; 266 - count += this.countComments(nested); 267 - } 268 - return count; 269 - } 270 - 271 - private escapeHtml(text: string): string { 272 - const div = document.createElement("div"); 273 - div.textContent = text; 274 - return div.innerHTML; 275 - } 276 - }
-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 - }
-30
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"; 27 - 28 - // Styles and theming 29 - export type { SequoiaTheme, SequoiaCSSVar } from "./types/styles"; 30 - export { SEQUOIA_CSS_VARS } from "./types/styles";
-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 - }
-40
packages/ui/src/types/styles.ts
··· 1 - /** 2 - * CSS custom properties for theming SequoiaComments 3 - * 4 - * @example 5 - * ```css 6 - * :root { 7 - * --sequoia-fg-color: #1f2937; 8 - * --sequoia-bg-color: #ffffff; 9 - * --sequoia-accent-color: #2563eb; 10 - * } 11 - * ``` 12 - */ 13 - export interface SequoiaTheme { 14 - /** Primary text color (default: #1f2937) */ 15 - "--sequoia-fg-color"?: string; 16 - /** Background color for comments and containers (default: #ffffff) */ 17 - "--sequoia-bg-color"?: string; 18 - /** Border color for separators and outlines (default: #e5e7eb) */ 19 - "--sequoia-border-color"?: string; 20 - /** Secondary/muted text color (default: #6b7280) */ 21 - "--sequoia-secondary-color"?: string; 22 - /** Accent color for links and buttons (default: #2563eb) */ 23 - "--sequoia-accent-color"?: string; 24 - /** Border radius for cards and buttons (default: 8px) */ 25 - "--sequoia-border-radius"?: string; 26 - } 27 - 28 - /** 29 - * All available CSS custom property names 30 - */ 31 - export const SEQUOIA_CSS_VARS = [ 32 - "--sequoia-fg-color", 33 - "--sequoia-bg-color", 34 - "--sequoia-border-color", 35 - "--sequoia-secondary-color", 36 - "--sequoia-accent-color", 37 - "--sequoia-border-radius", 38 - ] as const; 39 - 40 - export type SequoiaCSSVar = (typeof SEQUOIA_CSS_VARS)[number];
-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 - }