From c3f94619efe30feb7fe184d13c54779131685580 Mon Sep 17 00:00:00 2001 From: Steve Date: Fri, 6 Feb 2026 07:18:13 -0500 Subject: [PATCH] chore: refactored package into existing cli --- packages/cli/package.json | 2 +- packages/cli/src/commands/add.ts | 157 ++++ .../cli/src/components/sequoia-comments.js | 796 ++++++++++++++++++ packages/cli/src/index.ts | 4 +- packages/cli/src/lib/types.ts | 6 + packages/ui/.gitignore | 3 - packages/ui/biome.json | 37 - packages/ui/package.json | 34 - .../src/components/sequoia-comments/index.ts | 11 - .../sequoia-comments/sequoia-comments.ts | 276 ------ .../src/components/sequoia-comments/styles.ts | 218 ----- .../src/components/sequoia-comments/utils.ts | 127 --- packages/ui/src/index.ts | 30 - packages/ui/src/lib/atproto-client.ts | 144 ---- packages/ui/src/types/bluesky.ts | 133 --- packages/ui/src/types/styles.ts | 40 - packages/ui/test.html | 43 - packages/ui/tsconfig.json | 17 - 18 files changed, 963 insertions(+), 1115 deletions(-) create mode 100644 packages/cli/src/commands/add.ts create mode 100644 packages/cli/src/components/sequoia-comments.js delete mode 100644 packages/ui/.gitignore delete mode 100644 packages/ui/biome.json delete mode 100644 packages/ui/package.json delete mode 100644 packages/ui/src/components/sequoia-comments/index.ts delete mode 100644 packages/ui/src/components/sequoia-comments/sequoia-comments.ts delete mode 100644 packages/ui/src/components/sequoia-comments/styles.ts delete mode 100644 packages/ui/src/components/sequoia-comments/utils.ts delete mode 100644 packages/ui/src/index.ts delete mode 100644 packages/ui/src/lib/atproto-client.ts delete mode 100644 packages/ui/src/types/bluesky.ts delete mode 100644 packages/ui/src/types/styles.ts delete mode 100644 packages/ui/test.html delete mode 100644 packages/ui/tsconfig.json diff --git a/packages/cli/package.json b/packages/cli/package.json index a378b53..9275051 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,7 +16,7 @@ "scripts": { "lint": "biome lint --write", "format": "biome format --write", - "build": "bun build src/index.ts --target node --outdir dist", + "build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/", "dev": "bun run build && bun link", "deploy": "bun run build && bun publish" }, diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts new file mode 100644 index 0000000..3101033 --- /dev/null +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,157 @@ +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import * as path from "node:path"; +import { command, positional, string } from "cmd-ts"; +import { intro, outro, text, spinner, log, note } from "@clack/prompts"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import { findConfig, loadConfig } from "../lib/config"; +import type { PublisherConfig } from "../lib/types"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const COMPONENTS_DIR = path.join(__dirname, "components"); + +const DEFAULT_COMPONENTS_PATH = "src/components"; + +const AVAILABLE_COMPONENTS = ["sequoia-comments"]; + +export const addCommand = command({ + name: "add", + description: "Add a UI component to your project", + args: { + componentName: positional({ + type: string, + displayName: "component", + description: "The name of the component to add", + }), + }, + handler: async ({ componentName }) => { + intro("Add Sequoia Component"); + + // Validate component name + if (!AVAILABLE_COMPONENTS.includes(componentName)) { + log.error(`Component '${componentName}' not found`); + log.info("Available components:"); + for (const comp of AVAILABLE_COMPONENTS) { + log.info(` - ${comp}`); + } + process.exit(1); + } + + // Try to load existing config + const configPath = await findConfig(); + let config: PublisherConfig | null = null; + let componentsDir = DEFAULT_COMPONENTS_PATH; + + if (configPath) { + try { + config = await loadConfig(configPath); + if (config.ui?.components) { + componentsDir = config.ui.components; + } + } catch { + // Config exists but may be incomplete - that's ok for UI components + } + } + + // If no UI config, prompt for components directory + if (!config?.ui?.components) { + log.info("No UI configuration found in sequoia.json"); + + const inputPath = await text({ + message: "Where would you like to install components?", + placeholder: DEFAULT_COMPONENTS_PATH, + defaultValue: DEFAULT_COMPONENTS_PATH, + }); + + if (inputPath === Symbol.for("cancel")) { + outro("Cancelled"); + process.exit(0); + } + + componentsDir = inputPath as string; + + // Update or create config with UI settings + if (configPath) { + const s = spinner(); + s.start("Updating sequoia.json..."); + try { + const configContent = await fs.readFile(configPath, "utf-8"); + const existingConfig = JSON.parse(configContent); + existingConfig.ui = { components: componentsDir }; + await fs.writeFile( + configPath, + JSON.stringify(existingConfig, null, 2), + "utf-8" + ); + s.stop("Updated sequoia.json with UI configuration"); + } catch (error) { + s.stop("Failed to update sequoia.json"); + log.warn(`Could not update config: ${error}`); + } + } else { + // Create minimal config just for UI + const s = spinner(); + s.start("Creating sequoia.json..."); + const minimalConfig = { + ui: { components: componentsDir }, + }; + await fs.writeFile( + path.join(process.cwd(), "sequoia.json"), + JSON.stringify(minimalConfig, null, 2), + "utf-8" + ); + s.stop("Created sequoia.json with UI configuration"); + } + } + + // Resolve components directory + const resolvedComponentsDir = path.isAbsolute(componentsDir) + ? componentsDir + : path.join(process.cwd(), componentsDir); + + // Create components directory if it doesn't exist + if (!existsSync(resolvedComponentsDir)) { + const s = spinner(); + s.start(`Creating ${componentsDir} directory...`); + await fs.mkdir(resolvedComponentsDir, { recursive: true }); + s.stop(`Created ${componentsDir}`); + } + + // Copy the component + const sourceFile = path.join(COMPONENTS_DIR, `${componentName}.js`); + const destFile = path.join(resolvedComponentsDir, `${componentName}.js`); + + if (!existsSync(sourceFile)) { + log.error(`Component source file not found: ${sourceFile}`); + log.info("This may be a build issue. Try reinstalling sequoia-cli."); + process.exit(1); + } + + const s = spinner(); + s.start(`Installing ${componentName}...`); + + try { + const componentCode = await fs.readFile(sourceFile, "utf-8"); + await fs.writeFile(destFile, componentCode, "utf-8"); + s.stop(`Installed ${componentName}`); + } catch (error) { + s.stop("Failed to install component"); + log.error(`Error: ${error}`); + process.exit(1); + } + + // Show usage instructions + note( + `Add to your HTML:\n\n` + + `\n` + + `<${componentName}>\n\n` + + `The component will automatically read the document URI from:\n` + + ``, + "Usage" + ); + + outro(`${componentName} added successfully!`); + }, +}); diff --git a/packages/cli/src/components/sequoia-comments.js b/packages/cli/src/components/sequoia-comments.js new file mode 100644 index 0000000..b9489c9 --- /dev/null +++ b/packages/cli/src/components/sequoia-comments.js @@ -0,0 +1,796 @@ +/** + * Sequoia Comments - A Bluesky-powered comments component + * + * A self-contained Web Component that displays comments from Bluesky posts + * linked to documents via the AT Protocol. + * + * Usage: + * + * + * The component looks for a document URI in two places: + * 1. The `document-uri` attribute on the element + * 2. A tag in the document head + * + * Attributes: + * - document-uri: AT Protocol URI for the document (optional if link tag exists) + * - depth: Maximum depth of nested replies to fetch (default: 6) + * + * CSS Custom Properties: + * - --sequoia-fg-color: Text color (default: #1f2937) + * - --sequoia-bg-color: Background color (default: #ffffff) + * - --sequoia-border-color: Border color (default: #e5e7eb) + * - --sequoia-accent-color: Accent/link color (default: #2563eb) + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) + * - --sequoia-border-radius: Border radius (default: 8px) + */ + +// ============================================================================ +// Styles +// ============================================================================ + +const styles = ` +:host { + display: block; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + color: var(--sequoia-fg-color, #1f2937); + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +.sequoia-comments-container { + max-width: 100%; +} + +.sequoia-loading, +.sequoia-error, +.sequoia-empty, +.sequoia-warning { + padding: 1rem; + border-radius: var(--sequoia-border-radius, 8px); + text-align: center; +} + +.sequoia-loading { + background: var(--sequoia-bg-color, #ffffff); + border: 1px solid var(--sequoia-border-color, #e5e7eb); + color: var(--sequoia-secondary-color, #6b7280); +} + +.sequoia-loading-spinner { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border: 2px solid var(--sequoia-border-color, #e5e7eb); + border-top-color: var(--sequoia-accent-color, #2563eb); + border-radius: 50%; + animation: sequoia-spin 0.8s linear infinite; + margin-right: 0.5rem; + vertical-align: middle; +} + +@keyframes sequoia-spin { + to { transform: rotate(360deg); } +} + +.sequoia-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; +} + +.sequoia-warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #d97706; +} + +.sequoia-empty { + background: var(--sequoia-bg-color, #ffffff); + border: 1px solid var(--sequoia-border-color, #e5e7eb); + color: var(--sequoia-secondary-color, #6b7280); +} + +.sequoia-comments-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); +} + +.sequoia-comments-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0; +} + +.sequoia-reply-button { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: var(--sequoia-accent-color, #2563eb); + color: #ffffff; + border: none; + border-radius: var(--sequoia-border-radius, 8px); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +.sequoia-reply-button:hover { + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); +} + +.sequoia-reply-button svg { + width: 1rem; + height: 1rem; +} + +.sequoia-comments-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.sequoia-comment { + padding: 1rem; + background: var(--sequoia-bg-color, #ffffff); + border: 1px solid var(--sequoia-border-color, #e5e7eb); + border-radius: var(--sequoia-border-radius, 8px); + margin-bottom: 0.75rem; +} + +.sequoia-comment-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.sequoia-comment-avatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: var(--sequoia-border-color, #e5e7eb); + object-fit: cover; + flex-shrink: 0; +} + +.sequoia-comment-avatar-placeholder { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: var(--sequoia-border-color, #e5e7eb); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--sequoia-secondary-color, #6b7280); + font-weight: 600; + font-size: 1rem; +} + +.sequoia-comment-meta { + display: flex; + flex-direction: column; + min-width: 0; +} + +.sequoia-comment-author { + font-weight: 600; + color: var(--sequoia-fg-color, #1f2937); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sequoia-comment-author:hover { + color: var(--sequoia-accent-color, #2563eb); +} + +.sequoia-comment-handle { + font-size: 0.875rem; + color: var(--sequoia-secondary-color, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sequoia-comment-time { + font-size: 0.75rem; + color: var(--sequoia-secondary-color, #6b7280); + margin-left: auto; + flex-shrink: 0; +} + +.sequoia-comment-text { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.sequoia-comment-text a { + color: var(--sequoia-accent-color, #2563eb); + text-decoration: none; +} + +.sequoia-comment-text a:hover { + text-decoration: underline; +} + +.sequoia-comment-replies { + margin-top: 0.75rem; + margin-left: 1.5rem; + padding-left: 1rem; + border-left: 2px solid var(--sequoia-border-color, #e5e7eb); +} + +.sequoia-comment-replies .sequoia-comment { + margin-bottom: 0.5rem; +} + +.sequoia-comment-replies .sequoia-comment:last-child { + margin-bottom: 0; +} + +.sequoia-bsky-logo { + width: 1rem; + height: 1rem; +} +`; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Format a relative time string (e.g., "2 hours ago") + * @param {string} dateString - ISO date string + * @returns {string} Formatted relative time + */ +function formatRelativeTime(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSeconds < 60) { + return "just now"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + if (diffHours < 24) { + return `${diffHours}h ago`; + } + if (diffDays < 7) { + return `${diffDays}d ago`; + } + if (diffWeeks < 4) { + return `${diffWeeks}w ago`; + } + if (diffMonths < 12) { + return `${diffMonths}mo ago`; + } + return `${diffYears}y ago`; +} + +/** + * Escape HTML special characters + * @param {string} text - Text to escape + * @returns {string} Escaped HTML + */ +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +/** + * Convert post text with facets to HTML + * @param {string} text - Post text + * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets + * @returns {string} HTML string with links + */ +function renderTextWithFacets(text, facets) { + if (!facets || facets.length === 0) { + return escapeHtml(text); + } + + // Convert text to bytes for proper indexing + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const textBytes = encoder.encode(text); + + // Sort facets by start index + const sortedFacets = [...facets].sort( + (a, b) => a.index.byteStart - b.index.byteStart + ); + + let result = ""; + let lastEnd = 0; + + for (const facet of sortedFacets) { + const { byteStart, byteEnd } = facet.index; + + // Add text before this facet + if (byteStart > lastEnd) { + const beforeBytes = textBytes.slice(lastEnd, byteStart); + result += escapeHtml(decoder.decode(beforeBytes)); + } + + // Get the facet text + const facetBytes = textBytes.slice(byteStart, byteEnd); + const facetText = decoder.decode(facetBytes); + + // Find the first renderable feature + const feature = facet.features[0]; + if (feature) { + if (feature.$type === "app.bsky.richtext.facet#link") { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === "app.bsky.richtext.facet#mention") { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === "app.bsky.richtext.facet#tag") { + result += `${escapeHtml(facetText)}`; + } else { + result += escapeHtml(facetText); + } + } else { + result += escapeHtml(facetText); + } + + lastEnd = byteEnd; + } + + // Add remaining text + if (lastEnd < textBytes.length) { + const remainingBytes = textBytes.slice(lastEnd); + result += escapeHtml(decoder.decode(remainingBytes)); + } + + return result; +} + +/** + * Get initials from a name for avatar placeholder + * @param {string} name - Display name + * @returns {string} Initials (1-2 characters) + */ +function getInitials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} + +// ============================================================================ +// AT Protocol Client Functions +// ============================================================================ + +/** + * Parse an AT URI into its components + * Format: at://did/collection/rkey + * @param {string} atUri - AT Protocol URI + * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null + */ +function parseAtUri(atUri) { + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!match) return null; + return { + did: match[1], + collection: match[2], + rkey: match[3], + }; +} + +/** + * Resolve a DID to its PDS URL + * Supports did:plc and did:web methods + * @param {string} did - Decentralized Identifier + * @returns {Promise} PDS URL + */ +async function resolvePDS(did) { + let pdsUrl; + + if (did.startsWith("did:plc:")) { + // Fetch DID document from plc.directory + const didDocUrl = `https://plc.directory/${did}`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + // Find the PDS service endpoint + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" + ); + pdsUrl = pdsService?.serviceEndpoint; + } else if (did.startsWith("did:web:")) { + // For did:web, fetch the DID document from the domain + const domain = did.replace("did:web:", ""); + const didDocUrl = `https://${domain}/.well-known/did.json`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" + ); + pdsUrl = pdsService?.serviceEndpoint; + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + + if (!pdsUrl) { + throw new Error("Could not find PDS URL for user"); + } + + return pdsUrl; +} + +/** + * Fetch a record from a PDS using the public API + * @param {string} did - DID of the repository owner + * @param {string} collection - Collection name + * @param {string} rkey - Record key + * @returns {Promise} Record value + */ +async function getRecord(did, collection, rkey) { + const pdsUrl = await resolvePDS(did); + + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); + url.searchParams.set("repo", did); + url.searchParams.set("collection", collection); + url.searchParams.set("rkey", rkey); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch record: ${response.status}`); + } + + const data = await response.json(); + return data.value; +} + +/** + * Fetch a document record from its AT URI + * @param {string} atUri - AT Protocol URI for the document + * @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 + */ +async function getDocument(atUri) { + const parsed = parseAtUri(atUri); + if (!parsed) { + throw new Error(`Invalid AT URI: ${atUri}`); + } + + return getRecord(parsed.did, parsed.collection, parsed.rkey); +} + +/** + * Fetch a post thread from the public Bluesky API + * @param {string} postUri - AT Protocol URI for the post + * @param {number} [depth=6] - Maximum depth of replies to fetch + * @returns {Promise} Thread view post + */ +async function getPostThread(postUri, depth = 6) { + const url = new URL( + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread" + ); + url.searchParams.set("uri", postUri); + url.searchParams.set("depth", depth.toString()); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch post thread: ${response.status}`); + } + + const data = await response.json(); + + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { + throw new Error("Post not found or blocked"); + } + + return data.thread; +} + +/** + * Build a Bluesky app URL for a post + * @param {string} postUri - AT Protocol URI for the post + * @returns {string} Bluesky app URL + */ +function buildBskyAppUrl(postUri) { + const parsed = parseAtUri(postUri); + if (!parsed) { + throw new Error(`Invalid post URI: ${postUri}`); + } + + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; +} + +/** + * Type guard for ThreadViewPost + * @param {any} post - Post to check + * @returns {boolean} True if post is a ThreadViewPost + */ +function isThreadViewPost(post) { + return post?.$type === "app.bsky.feed.defs#threadViewPost"; +} + +// ============================================================================ +// Bluesky Icon +// ============================================================================ + +const BLUESKY_ICON = ``; + +// ============================================================================ +// Web Component +// ============================================================================ + +// SSR-safe base class - use HTMLElement in browser, empty class in Node.js +const BaseElement = + typeof HTMLElement !== "undefined" + ? HTMLElement + : class {}; + +class SequoiaComments extends BaseElement { + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + this.state = { type: "loading" }; + this.abortController = null; + } + + static get observedAttributes() { + return ["document-uri", "depth"]; + } + + connectedCallback() { + this.render(); + this.loadComments(); + } + + disconnectedCallback() { + this.abortController?.abort(); + } + + attributeChangedCallback() { + if (this.isConnected) { + this.loadComments(); + } + } + + get documentUri() { + // First check attribute + const attrUri = this.getAttribute("document-uri"); + if (attrUri) { + return attrUri; + } + + // Then scan for link tag in document head + const linkTag = document.querySelector( + 'link[rel="site.standard.document"]' + ); + return linkTag?.href ?? null; + } + + get depth() { + const depthAttr = this.getAttribute("depth"); + return depthAttr ? parseInt(depthAttr, 10) : 6; + } + + async loadComments() { + // Cancel any in-flight request + this.abortController?.abort(); + this.abortController = new AbortController(); + + this.state = { type: "loading" }; + this.render(); + + const docUri = this.documentUri; + if (!docUri) { + this.state = { type: "no-document" }; + this.render(); + return; + } + + try { + // Fetch the document record + const document = await getDocument(docUri); + + // Check if document has a Bluesky post reference + if (!document.bskyPostRef) { + this.state = { type: "no-comments-enabled" }; + this.render(); + return; + } + + const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); + + // Fetch the post thread + const thread = await getPostThread(document.bskyPostRef.uri, this.depth); + + // Check if there are any replies + const replies = thread.replies?.filter(isThreadViewPost) ?? []; + if (replies.length === 0) { + this.state = { type: "empty", postUrl }; + this.render(); + return; + } + + this.state = { type: "loaded", thread, postUrl }; + this.render(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load comments"; + this.state = { type: "error", message }; + this.render(); + } + } + + render() { + const styleTag = ``; + + switch (this.state.type) { + case "loading": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ + Loading comments... +
+
+ `; + break; + + case "no-document": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ No document found. Add a <link rel="site.standard.document" href="at://..."> tag to your page. +
+
+ `; + break; + + case "no-comments-enabled": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ Comments are not enabled for this post. +
+
+ `; + break; + + case "empty": + this.shadow.innerHTML = ` + ${styleTag} +
+ +
+ No comments yet. Be the first to reply on Bluesky! +
+
+ `; + break; + + case "error": + this.shadow.innerHTML = ` + ${styleTag} +
+
+ Failed to load comments: ${escapeHtml(this.state.message)} +
+
+ `; + break; + + case "loaded": { + const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; + const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); + const commentCount = this.countComments(replies); + + this.shadow.innerHTML = ` + ${styleTag} +
+
+

${commentCount} Comment${commentCount !== 1 ? "s" : ""}

+ + ${BLUESKY_ICON} + Reply on Bluesky + +
+
+ ${commentsHtml} +
+
+ `; + break; + } + } + } + + renderComment(thread) { + const { post } = thread; + const author = post.author; + const displayName = author.displayName || author.handle; + const avatarHtml = author.avatar + ? `${escapeHtml(displayName)}` + : `
${getInitials(displayName)}
`; + + const profileUrl = `https://bsky.app/profile/${author.did}`; + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); + const timeAgo = formatRelativeTime(post.record.createdAt); + + // Render nested replies + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; + const repliesHtml = + nestedReplies.length > 0 + ? `
${nestedReplies.map((r) => this.renderComment(r)).join("")}
` + : ""; + + return ` +
+
+ ${avatarHtml} +
+ + ${escapeHtml(displayName)} + + @${escapeHtml(author.handle)} +
+ ${timeAgo} +
+

${textHtml}

+ ${repliesHtml} +
+ `; + } + + countComments(replies) { + let count = 0; + for (const reply of replies) { + count += 1; + const nested = reply.replies?.filter(isThreadViewPost) ?? []; + count += this.countComments(nested); + } + return count; + } +} + +// Register the custom element +if (typeof customElements !== "undefined") { + customElements.define("sequoia-comments", SequoiaComments); +} + +// Export for module usage +export { SequoiaComments }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d81991d..6079331 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { run, subcommands } from "cmd-ts"; +import { addCommand } from "./commands/add"; import { authCommand } from "./commands/auth"; import { initCommand } from "./commands/init"; import { injectCommand } from "./commands/inject"; @@ -35,8 +36,9 @@ Publish evergreen content to the ATmosphere > https://tangled.org/stevedylan.dev/sequoia `, - version: "0.3.3", + version: "0.4.0", cmds: { + add: addCommand, auth: authCommand, init: initCommand, inject: injectCommand, diff --git a/packages/cli/src/lib/types.ts b/packages/cli/src/lib/types.ts index 735ff8a..993b474 100644 --- a/packages/cli/src/lib/types.ts +++ b/packages/cli/src/lib/types.ts @@ -20,6 +20,11 @@ export interface BlueskyConfig { maxAgeDays?: number; // Only post if published within N days (default: 7) } +// UI components configuration +export interface UIConfig { + components: string; // Directory to install UI components (default: src/components) +} + export interface PublisherConfig { siteUrl: string; contentDir: string; @@ -36,6 +41,7 @@ export interface PublisherConfig { stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) textContentField?: string; // Frontmatter field to use for textContent instead of markdown body bluesky?: BlueskyConfig; // Optional Bluesky posting configuration + ui?: UIConfig; // Optional UI components configuration } // Legacy credentials format (for backward compatibility during migration) diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore deleted file mode 100644 index 72a7db1..0000000 --- a/packages/ui/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -dist/ -node_modules/ -test-site/ diff --git a/packages/ui/biome.json b/packages/ui/biome.json deleted file mode 100644 index 80098a8..0000000 --- a/packages/ui/biome.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": ["**", "!!**/dist"] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - }, - "assist": { - "enabled": true, - "actions": { - "source": { - "organizeImports": "on" - } - } - } -} diff --git a/packages/ui/package.json b/packages/ui/package.json deleted file mode 100644 index 29f8446..0000000 --- a/packages/ui/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "sequoia-ui", - "version": "0.0.2", - "type": "module", - "files": [ - "dist", - "README.md" - ], - "main": "./dist/index.js", - "exports": { - ".": { - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./comments": { - "import": "./dist/index.js", - "default": "./dist/index.js" - } - }, - "scripts": { - "lint": "biome lint --write", - "format": "biome format --write", - "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", - "dev": "bun run build", - "deploy": "bun run build && bun publish --access public" - }, - "devDependencies": { - "@biomejs/biome": "^2.3.13", - "@types/node": "^20" - }, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/packages/ui/src/components/sequoia-comments/index.ts b/packages/ui/src/components/sequoia-comments/index.ts deleted file mode 100644 index 2668d70..0000000 --- a/packages/ui/src/components/sequoia-comments/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SequoiaComments } from "./sequoia-comments"; - -// Register the custom element if not already registered -if ( - typeof customElements !== "undefined" && - !customElements.get("sequoia-comments") -) { - customElements.define("sequoia-comments", SequoiaComments); -} - -export { SequoiaComments }; diff --git a/packages/ui/src/components/sequoia-comments/sequoia-comments.ts b/packages/ui/src/components/sequoia-comments/sequoia-comments.ts deleted file mode 100644 index 6a9fdbc..0000000 --- a/packages/ui/src/components/sequoia-comments/sequoia-comments.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { - buildBskyAppUrl, - getDocument, - getPostThread, -} from "../../lib/atproto-client"; -import type { ThreadViewPost } from "../../types/bluesky"; -import { isThreadViewPost } from "../../types/bluesky"; -import { styles } from "./styles"; -import { formatRelativeTime, getInitials, renderTextWithFacets } from "./utils"; - -/** - * Component state - */ -type State = - | { type: "loading" } - | { type: "loaded"; thread: ThreadViewPost; postUrl: string } - | { type: "no-document" } - | { type: "no-comments-enabled" } - | { type: "empty"; postUrl: string } - | { type: "error"; message: string }; - -/** - * Bluesky butterfly SVG icon - */ -const BLUESKY_ICON = ``; - -// SSR-safe base class - use HTMLElement in browser, empty class in Node.js -const BaseElement = - typeof HTMLElement !== "undefined" - ? HTMLElement - : (class {} as typeof HTMLElement); - -export class SequoiaComments extends BaseElement { - private shadow: ShadowRoot; - private state: State = { type: "loading" }; - private abortController: AbortController | null = null; - - static get observedAttributes(): string[] { - return ["document-uri", "depth"]; - } - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: "open" }); - } - - connectedCallback(): void { - this.render(); - this.loadComments(); - } - - disconnectedCallback(): void { - this.abortController?.abort(); - } - - attributeChangedCallback(): void { - if (this.isConnected) { - this.loadComments(); - } - } - - private get documentUri(): string | null { - // First check attribute - const attrUri = this.getAttribute("document-uri"); - if (attrUri) { - return attrUri; - } - - // Then scan for link tag in document head - const linkTag = document.querySelector( - 'link[rel="site.standard.document"]', - ); - return linkTag?.href ?? null; - } - - private get depth(): number { - const depthAttr = this.getAttribute("depth"); - return depthAttr ? Number.parseInt(depthAttr, 10) : 6; - } - - private async loadComments(): Promise { - // Cancel any in-flight request - this.abortController?.abort(); - this.abortController = new AbortController(); - - this.state = { type: "loading" }; - this.render(); - - const docUri = this.documentUri; - if (!docUri) { - this.state = { type: "no-document" }; - this.render(); - return; - } - - try { - // Fetch the document record - const document = await getDocument(docUri); - - // Check if document has a Bluesky post reference - if (!document.bskyPostRef) { - this.state = { type: "no-comments-enabled" }; - this.render(); - return; - } - - const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); - - // Fetch the post thread - const thread = await getPostThread(document.bskyPostRef.uri, this.depth); - - // Check if there are any replies - const replies = thread.replies?.filter(isThreadViewPost) ?? []; - if (replies.length === 0) { - this.state = { type: "empty", postUrl }; - this.render(); - return; - } - - this.state = { type: "loaded", thread, postUrl }; - this.render(); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load comments"; - this.state = { type: "error", message }; - this.render(); - } - } - - private render(): void { - const styleTag = ``; - - switch (this.state.type) { - case "loading": - this.shadow.innerHTML = ` - ${styleTag} -
-
- - Loading comments... -
-
- `; - break; - - case "no-document": - this.shadow.innerHTML = ` - ${styleTag} -
-
- No document found. Add a <link rel="site.standard.document" href="at://..."> tag to your page. -
-
- `; - break; - - case "no-comments-enabled": - this.shadow.innerHTML = ` - ${styleTag} -
-
- Comments are not enabled for this post. -
-
- `; - break; - - case "empty": - this.shadow.innerHTML = ` - ${styleTag} -
- -
- No comments yet. Be the first to reply on Bluesky! -
-
- `; - break; - - case "error": - this.shadow.innerHTML = ` - ${styleTag} -
-
- Failed to load comments: ${this.escapeHtml(this.state.message)} -
-
- `; - break; - - case "loaded": { - const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? []; - const commentsHtml = replies.map((reply) => this.renderComment(reply)).join(""); - const commentCount = this.countComments(replies); - - this.shadow.innerHTML = ` - ${styleTag} -
-
-

${commentCount} Comment${commentCount !== 1 ? "s" : ""}

- - ${BLUESKY_ICON} - Reply on Bluesky - -
-
- ${commentsHtml} -
-
- `; - break; - } - } - } - - private renderComment(thread: ThreadViewPost): string { - const { post } = thread; - const author = post.author; - const displayName = author.displayName || author.handle; - const avatarHtml = author.avatar - ? `${this.escapeHtml(displayName)}` - : `
${getInitials(displayName)}
`; - - const profileUrl = `https://bsky.app/profile/${author.did}`; - const textHtml = renderTextWithFacets(post.record.text, post.record.facets); - const timeAgo = formatRelativeTime(post.record.createdAt); - - // Render nested replies - const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; - const repliesHtml = - nestedReplies.length > 0 - ? `
${nestedReplies.map((r) => this.renderComment(r)).join("")}
` - : ""; - - return ` -
-
- ${avatarHtml} -
- - ${this.escapeHtml(displayName)} - - @${this.escapeHtml(author.handle)} -
- ${timeAgo} -
-

${textHtml}

- ${repliesHtml} -
- `; - } - - private countComments(replies: ThreadViewPost[]): number { - let count = 0; - for (const reply of replies) { - count += 1; - const nested = reply.replies?.filter(isThreadViewPost) ?? []; - count += this.countComments(nested); - } - return count; - } - - private escapeHtml(text: string): string { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; - } -} diff --git a/packages/ui/src/components/sequoia-comments/styles.ts b/packages/ui/src/components/sequoia-comments/styles.ts deleted file mode 100644 index dd7098b..0000000 --- a/packages/ui/src/components/sequoia-comments/styles.ts +++ /dev/null @@ -1,218 +0,0 @@ -export const styles = ` -:host { - display: block; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - color: var(--sequoia-fg-color, #1f2937); - line-height: 1.5; -} - -* { - box-sizing: border-box; -} - -.sequoia-comments-container { - max-width: 100%; -} - -.sequoia-loading, -.sequoia-error, -.sequoia-empty, -.sequoia-warning { - padding: 1rem; - border-radius: var(--sequoia-border-radius, 8px); - text-align: center; -} - -.sequoia-loading { - background: var(--sequoia-bg-color, #ffffff); - border: 1px solid var(--sequoia-border-color, #e5e7eb); - color: var(--sequoia-secondary-color, #6b7280); -} - -.sequoia-loading-spinner { - display: inline-block; - width: 1.25rem; - height: 1.25rem; - border: 2px solid var(--sequoia-border-color, #e5e7eb); - border-top-color: var(--sequoia-accent-color, #2563eb); - border-radius: 50%; - animation: sequoia-spin 0.8s linear infinite; - margin-right: 0.5rem; - vertical-align: middle; -} - -@keyframes sequoia-spin { - to { transform: rotate(360deg); } -} - -.sequoia-error { - background: #fef2f2; - border: 1px solid #fecaca; - color: #dc2626; -} - -.sequoia-warning { - background: #fffbeb; - border: 1px solid #fde68a; - color: #d97706; -} - -.sequoia-empty { - background: var(--sequoia-bg-color, #ffffff); - border: 1px solid var(--sequoia-border-color, #e5e7eb); - color: var(--sequoia-secondary-color, #6b7280); -} - -.sequoia-comments-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); -} - -.sequoia-comments-title { - font-size: 1.125rem; - font-weight: 600; - margin: 0; -} - -.sequoia-reply-button { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 1rem; - background: var(--sequoia-accent-color, #2563eb); - color: #ffffff; - border: none; - border-radius: var(--sequoia-border-radius, 8px); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - text-decoration: none; - transition: background-color 0.15s ease; -} - -.sequoia-reply-button:hover { - background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); -} - -.sequoia-reply-button svg { - width: 1rem; - height: 1rem; -} - -.sequoia-comments-list { - display: flex; - flex-direction: column; - gap: 0; -} - -.sequoia-comment { - padding: 1rem; - background: var(--sequoia-bg-color, #ffffff); - border: 1px solid var(--sequoia-border-color, #e5e7eb); - border-radius: var(--sequoia-border-radius, 8px); - margin-bottom: 0.75rem; -} - -.sequoia-comment-header { - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.5rem; -} - -.sequoia-comment-avatar { - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - background: var(--sequoia-border-color, #e5e7eb); - object-fit: cover; - flex-shrink: 0; -} - -.sequoia-comment-avatar-placeholder { - width: 2.5rem; - height: 2.5rem; - border-radius: 50%; - background: var(--sequoia-border-color, #e5e7eb); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--sequoia-secondary-color, #6b7280); - font-weight: 600; - font-size: 1rem; -} - -.sequoia-comment-meta { - display: flex; - flex-direction: column; - min-width: 0; -} - -.sequoia-comment-author { - font-weight: 600; - color: var(--sequoia-fg-color, #1f2937); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sequoia-comment-author:hover { - color: var(--sequoia-accent-color, #2563eb); -} - -.sequoia-comment-handle { - font-size: 0.875rem; - color: var(--sequoia-secondary-color, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sequoia-comment-time { - font-size: 0.75rem; - color: var(--sequoia-secondary-color, #6b7280); - margin-left: auto; - flex-shrink: 0; -} - -.sequoia-comment-text { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; -} - -.sequoia-comment-text a { - color: var(--sequoia-accent-color, #2563eb); - text-decoration: none; -} - -.sequoia-comment-text a:hover { - text-decoration: underline; -} - -.sequoia-comment-replies { - margin-top: 0.75rem; - margin-left: 1.5rem; - padding-left: 1rem; - border-left: 2px solid var(--sequoia-border-color, #e5e7eb); -} - -.sequoia-comment-replies .sequoia-comment { - margin-bottom: 0.5rem; -} - -.sequoia-comment-replies .sequoia-comment:last-child { - margin-bottom: 0; -} - -.sequoia-bsky-logo { - width: 1rem; - height: 1rem; -} -`; diff --git a/packages/ui/src/components/sequoia-comments/utils.ts b/packages/ui/src/components/sequoia-comments/utils.ts deleted file mode 100644 index 7aae3e4..0000000 --- a/packages/ui/src/components/sequoia-comments/utils.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Format a relative time string (e.g., "2 hours ago") - */ -export function formatRelativeTime(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); - const diffMinutes = Math.floor(diffSeconds / 60); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - const diffWeeks = Math.floor(diffDays / 7); - const diffMonths = Math.floor(diffDays / 30); - const diffYears = Math.floor(diffDays / 365); - - if (diffSeconds < 60) { - return "just now"; - } - if (diffMinutes < 60) { - return `${diffMinutes}m ago`; - } - if (diffHours < 24) { - return `${diffHours}h ago`; - } - if (diffDays < 7) { - return `${diffDays}d ago`; - } - if (diffWeeks < 4) { - return `${diffWeeks}w ago`; - } - if (diffMonths < 12) { - return `${diffMonths}mo ago`; - } - return `${diffYears}y ago`; -} - -/** - * Escape HTML special characters - */ -export function escapeHtml(text: string): string { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; -} - -/** - * Convert post text with facets to HTML - */ -export function renderTextWithFacets( - text: string, - facets?: Array<{ - index: { byteStart: number; byteEnd: number }; - features: Array< - | { $type: "app.bsky.richtext.facet#link"; uri: string } - | { $type: "app.bsky.richtext.facet#mention"; did: string } - | { $type: "app.bsky.richtext.facet#tag"; tag: string } - >; - }>, -): string { - if (!facets || facets.length === 0) { - return escapeHtml(text); - } - - // Convert text to bytes for proper indexing - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - const textBytes = encoder.encode(text); - - // Sort facets by start index - const sortedFacets = [...facets].sort( - (a, b) => a.index.byteStart - b.index.byteStart, - ); - - let result = ""; - let lastEnd = 0; - - for (const facet of sortedFacets) { - const { byteStart, byteEnd } = facet.index; - - // Add text before this facet - if (byteStart > lastEnd) { - const beforeBytes = textBytes.slice(lastEnd, byteStart); - result += escapeHtml(decoder.decode(beforeBytes)); - } - - // Get the facet text - const facetBytes = textBytes.slice(byteStart, byteEnd); - const facetText = decoder.decode(facetBytes); - - // Find the first renderable feature - const feature = facet.features[0]; - if (feature) { - if (feature.$type === "app.bsky.richtext.facet#link") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#mention") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#tag") { - result += `${escapeHtml(facetText)}`; - } else { - result += escapeHtml(facetText); - } - } else { - result += escapeHtml(facetText); - } - - lastEnd = byteEnd; - } - - // Add remaining text - if (lastEnd < textBytes.length) { - const remainingBytes = textBytes.slice(lastEnd); - result += escapeHtml(decoder.decode(remainingBytes)); - } - - return result; -} - -/** - * Get initials from a name for avatar placeholder - */ -export function getInitials(name: string): string { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); - } - return name.substring(0, 2).toUpperCase(); -} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts deleted file mode 100644 index a3a9a76..0000000 --- a/packages/ui/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Components -export { SequoiaComments } from "./components/sequoia-comments"; - -// AT Protocol client utilities -export { - parseAtUri, - resolvePDS, - getRecord, - getDocument, - getPostThread, - buildBskyAppUrl, -} from "./lib/atproto-client"; - -// Types -export type { - StrongRef, - ProfileViewBasic, - PostRecord, - PostView, - ThreadViewPost, - BlockedPost, - NotFoundPost, - DocumentRecord, -} from "./types/bluesky"; - -export { isThreadViewPost } from "./types/bluesky"; - -// Styles and theming -export type { SequoiaTheme, SequoiaCSSVar } from "./types/styles"; -export { SEQUOIA_CSS_VARS } from "./types/styles"; diff --git a/packages/ui/src/lib/atproto-client.ts b/packages/ui/src/lib/atproto-client.ts deleted file mode 100644 index 7297ed0..0000000 --- a/packages/ui/src/lib/atproto-client.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { - DIDDocument, - DocumentRecord, - GetPostThreadResponse, - GetRecordResponse, - ThreadViewPost, -} from "../types/bluesky"; - -/** - * Parse an AT URI into its components - * Format: at://did/collection/rkey - */ -export function parseAtUri( - atUri: string, -): { did: string; collection: string; rkey: string } | null { - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!match) return null; - return { - did: match[1]!, - collection: match[2]!, - rkey: match[3]!, - }; -} - -/** - * Resolve a DID to its PDS URL - * Supports did:plc and did:web methods - */ -export async function resolvePDS(did: string): Promise { - let pdsUrl: string | undefined; - - if (did.startsWith("did:plc:")) { - // Fetch DID document from plc.directory - const didDocUrl = `https://plc.directory/${did}`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc: DIDDocument = await didDocResponse.json(); - - // Find the PDS service endpoint - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else if (did.startsWith("did:web:")) { - // For did:web, fetch the DID document from the domain - const domain = did.replace("did:web:", ""); - const didDocUrl = `https://${domain}/.well-known/did.json`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc: DIDDocument = await didDocResponse.json(); - - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else { - throw new Error(`Unsupported DID method: ${did}`); - } - - if (!pdsUrl) { - throw new Error("Could not find PDS URL for user"); - } - - return pdsUrl; -} - -/** - * Fetch a record from a PDS using the public API - */ -export async function getRecord( - did: string, - collection: string, - rkey: string, -): Promise { - const pdsUrl = await resolvePDS(did); - - const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch record: ${response.status}`); - } - - const data: GetRecordResponse = await response.json(); - return data.value; -} - -/** - * Fetch a document record from its AT URI - */ -export async function getDocument(atUri: string): Promise { - const parsed = parseAtUri(atUri); - if (!parsed) { - throw new Error(`Invalid AT URI: ${atUri}`); - } - - return getRecord(parsed.did, parsed.collection, parsed.rkey); -} - -/** - * Fetch a post thread from the public Bluesky API - */ -export async function getPostThread( - postUri: string, - depth = 6, -): Promise { - const url = new URL( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", - ); - url.searchParams.set("uri", postUri); - url.searchParams.set("depth", depth.toString()); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch post thread: ${response.status}`); - } - - const data: GetPostThreadResponse = await response.json(); - - if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { - throw new Error("Post not found or blocked"); - } - - return data.thread as ThreadViewPost; -} - -/** - * Build a Bluesky app URL for a post - */ -export function buildBskyAppUrl(postUri: string): string { - const parsed = parseAtUri(postUri); - if (!parsed) { - throw new Error(`Invalid post URI: ${postUri}`); - } - - return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; -} diff --git a/packages/ui/src/types/bluesky.ts b/packages/ui/src/types/bluesky.ts deleted file mode 100644 index 3ec01d4..0000000 --- a/packages/ui/src/types/bluesky.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Strong reference for AT Protocol records (com.atproto.repo.strongRef) - */ -export interface StrongRef { - uri: string; // at:// URI format - cid: string; // Content ID -} - -/** - * Basic profile view from Bluesky API - */ -export interface ProfileViewBasic { - did: string; - handle: string; - displayName?: string; - avatar?: string; -} - -/** - * Post record content from app.bsky.feed.post - */ -export interface PostRecord { - $type: "app.bsky.feed.post"; - text: string; - createdAt: string; - reply?: { - root: StrongRef; - parent: StrongRef; - }; - facets?: Array<{ - index: { byteStart: number; byteEnd: number }; - features: Array< - | { $type: "app.bsky.richtext.facet#link"; uri: string } - | { $type: "app.bsky.richtext.facet#mention"; did: string } - | { $type: "app.bsky.richtext.facet#tag"; tag: string } - >; - }>; -} - -/** - * Post view from Bluesky API - */ -export interface PostView { - uri: string; - cid: string; - author: ProfileViewBasic; - record: PostRecord; - replyCount?: number; - repostCount?: number; - likeCount?: number; - indexedAt: string; -} - -/** - * Thread view post from app.bsky.feed.getPostThread - */ -export interface ThreadViewPost { - $type: "app.bsky.feed.defs#threadViewPost"; - post: PostView; - parent?: ThreadViewPost | BlockedPost | NotFoundPost; - replies?: Array; -} - -/** - * Blocked post placeholder - */ -export interface BlockedPost { - $type: "app.bsky.feed.defs#blockedPost"; - uri: string; - blocked: true; -} - -/** - * Not found post placeholder - */ -export interface NotFoundPost { - $type: "app.bsky.feed.defs#notFoundPost"; - uri: string; - notFound: true; -} - -/** - * Type guard for ThreadViewPost - */ -export function isThreadViewPost( - post: ThreadViewPost | BlockedPost | NotFoundPost | undefined, -): post is ThreadViewPost { - return post?.$type === "app.bsky.feed.defs#threadViewPost"; -} - -/** - * Document record from site.standard.document - */ -export interface DocumentRecord { - $type: "site.standard.document"; - title: string; - site: string; - path: string; - textContent: string; - publishedAt: string; - canonicalUrl?: string; - description?: string; - tags?: string[]; - bskyPostRef?: StrongRef; -} - -/** - * DID document structure - */ -export interface DIDDocument { - id: string; - service?: Array<{ - id: string; - type: string; - serviceEndpoint: string; - }>; -} - -/** - * Response from com.atproto.repo.getRecord - */ -export interface GetRecordResponse { - uri: string; - cid: string; - value: T; -} - -/** - * Response from app.bsky.feed.getPostThread - */ -export interface GetPostThreadResponse { - thread: ThreadViewPost | BlockedPost | NotFoundPost; -} diff --git a/packages/ui/src/types/styles.ts b/packages/ui/src/types/styles.ts deleted file mode 100644 index 2f847dc..0000000 --- a/packages/ui/src/types/styles.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * CSS custom properties for theming SequoiaComments - * - * @example - * ```css - * :root { - * --sequoia-fg-color: #1f2937; - * --sequoia-bg-color: #ffffff; - * --sequoia-accent-color: #2563eb; - * } - * ``` - */ -export interface SequoiaTheme { - /** Primary text color (default: #1f2937) */ - "--sequoia-fg-color"?: string; - /** Background color for comments and containers (default: #ffffff) */ - "--sequoia-bg-color"?: string; - /** Border color for separators and outlines (default: #e5e7eb) */ - "--sequoia-border-color"?: string; - /** Secondary/muted text color (default: #6b7280) */ - "--sequoia-secondary-color"?: string; - /** Accent color for links and buttons (default: #2563eb) */ - "--sequoia-accent-color"?: string; - /** Border radius for cards and buttons (default: 8px) */ - "--sequoia-border-radius"?: string; -} - -/** - * All available CSS custom property names - */ -export const SEQUOIA_CSS_VARS = [ - "--sequoia-fg-color", - "--sequoia-bg-color", - "--sequoia-border-color", - "--sequoia-secondary-color", - "--sequoia-accent-color", - "--sequoia-border-radius", -] as const; - -export type SequoiaCSSVar = (typeof SEQUOIA_CSS_VARS)[number]; diff --git a/packages/ui/test.html b/packages/ui/test.html deleted file mode 100644 index 1d9cba0..0000000 --- a/packages/ui/test.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - Sequoia Comments Test - - - - - -

Blog Post Title

-

This is a test page for the sequoia-comments web component.

-

The component will look for a <link rel="site.standard.document"> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.

- -

Comments

- - - - - diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json deleted file mode 100644 index 93a3f49..0000000 --- a/packages/ui/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} -- 2.51.2