chore: refactored package into existing cli c3f94619
Steve · 2026-02-06 07:18 18 file(s) · +963 −1115
packages/cli/package.json +1 −1
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
	},
packages/cli/src/commands/add.ts (added) +157 −0
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 +
});
packages/cli/src/components/sequoia-comments.js (added) +796 −0
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 };
packages/cli/src/index.ts +3 −1
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,
packages/cli/src/lib/types.ts +6 −0
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)
packages/ui/.gitignore (deleted) +0 −3
1 -
dist/
2 -
node_modules/
3 -
test-site/
packages/ui/biome.json (deleted) +0 −37
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 -
}
packages/ui/package.json (deleted) +0 −34
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 -
}
packages/ui/src/components/sequoia-comments/index.ts (deleted) +0 −11
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 };
packages/ui/src/components/sequoia-comments/sequoia-comments.ts (deleted) +0 −276
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 -
}
packages/ui/src/components/sequoia-comments/styles.ts (deleted) +0 −218
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 -
`;
packages/ui/src/components/sequoia-comments/utils.ts (deleted) +0 −127
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 -
}
packages/ui/src/index.ts (deleted) +0 −30
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";
packages/ui/src/lib/atproto-client.ts (deleted) +0 −144
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 -
}
packages/ui/src/types/bluesky.ts (deleted) +0 −133
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 -
}
packages/ui/src/types/styles.ts (deleted) +0 −40
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];
packages/ui/test.html (deleted) +0 −43
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>
packages/ui/tsconfig.json (deleted) +0 −17
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 -
}