tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
35
fork
atom
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
35
fork
atom
overview
issues
5
pulls
1
pipelines
chore: refactored package into existing cli
stevedylan.dev
1 month ago
e7ed2e22
4f7cabe4
+963
-1115
18 changed files
expand all
collapse all
unified
split
packages
cli
package.json
src
commands
add.ts
components
sequoia-comments.js
index.ts
lib
types.ts
ui
.gitignore
biome.json
package.json
src
components
sequoia-comments
index.ts
sequoia-comments.ts
styles.ts
utils.ts
index.ts
lib
atproto-client.ts
types
bluesky.ts
styles.ts
test.html
tsconfig.json
+1
-1
packages/cli/package.json
···
16
16
"scripts": {
17
17
"lint": "biome lint --write",
18
18
"format": "biome format --write",
19
19
-
"build": "bun build src/index.ts --target node --outdir dist",
19
19
+
"build": "bun build src/index.ts --target node --outdir dist && mkdir -p dist/components && cp src/components/*.js dist/components/",
20
20
"dev": "bun run build && bun link",
21
21
"deploy": "bun run build && bun publish"
22
22
},
+157
packages/cli/src/commands/add.ts
···
1
1
+
import * as fs from "node:fs/promises";
2
2
+
import { existsSync } from "node:fs";
3
3
+
import * as path from "node:path";
4
4
+
import { command, positional, string } from "cmd-ts";
5
5
+
import { intro, outro, text, spinner, log, note } from "@clack/prompts";
6
6
+
import { fileURLToPath } from "url";
7
7
+
import { dirname } from "path";
8
8
+
import { findConfig, loadConfig } from "../lib/config";
9
9
+
import type { PublisherConfig } from "../lib/types";
10
10
+
11
11
+
const __filename = fileURLToPath(import.meta.url);
12
12
+
const __dirname = dirname(__filename);
13
13
+
const COMPONENTS_DIR = path.join(__dirname, "components");
14
14
+
15
15
+
const DEFAULT_COMPONENTS_PATH = "src/components";
16
16
+
17
17
+
const AVAILABLE_COMPONENTS = ["sequoia-comments"];
18
18
+
19
19
+
export const addCommand = command({
20
20
+
name: "add",
21
21
+
description: "Add a UI component to your project",
22
22
+
args: {
23
23
+
componentName: positional({
24
24
+
type: string,
25
25
+
displayName: "component",
26
26
+
description: "The name of the component to add",
27
27
+
}),
28
28
+
},
29
29
+
handler: async ({ componentName }) => {
30
30
+
intro("Add Sequoia Component");
31
31
+
32
32
+
// Validate component name
33
33
+
if (!AVAILABLE_COMPONENTS.includes(componentName)) {
34
34
+
log.error(`Component '${componentName}' not found`);
35
35
+
log.info("Available components:");
36
36
+
for (const comp of AVAILABLE_COMPONENTS) {
37
37
+
log.info(` - ${comp}`);
38
38
+
}
39
39
+
process.exit(1);
40
40
+
}
41
41
+
42
42
+
// Try to load existing config
43
43
+
const configPath = await findConfig();
44
44
+
let config: PublisherConfig | null = null;
45
45
+
let componentsDir = DEFAULT_COMPONENTS_PATH;
46
46
+
47
47
+
if (configPath) {
48
48
+
try {
49
49
+
config = await loadConfig(configPath);
50
50
+
if (config.ui?.components) {
51
51
+
componentsDir = config.ui.components;
52
52
+
}
53
53
+
} catch {
54
54
+
// Config exists but may be incomplete - that's ok for UI components
55
55
+
}
56
56
+
}
57
57
+
58
58
+
// If no UI config, prompt for components directory
59
59
+
if (!config?.ui?.components) {
60
60
+
log.info("No UI configuration found in sequoia.json");
61
61
+
62
62
+
const inputPath = await text({
63
63
+
message: "Where would you like to install components?",
64
64
+
placeholder: DEFAULT_COMPONENTS_PATH,
65
65
+
defaultValue: DEFAULT_COMPONENTS_PATH,
66
66
+
});
67
67
+
68
68
+
if (inputPath === Symbol.for("cancel")) {
69
69
+
outro("Cancelled");
70
70
+
process.exit(0);
71
71
+
}
72
72
+
73
73
+
componentsDir = inputPath as string;
74
74
+
75
75
+
// Update or create config with UI settings
76
76
+
if (configPath) {
77
77
+
const s = spinner();
78
78
+
s.start("Updating sequoia.json...");
79
79
+
try {
80
80
+
const configContent = await fs.readFile(configPath, "utf-8");
81
81
+
const existingConfig = JSON.parse(configContent);
82
82
+
existingConfig.ui = { components: componentsDir };
83
83
+
await fs.writeFile(
84
84
+
configPath,
85
85
+
JSON.stringify(existingConfig, null, 2),
86
86
+
"utf-8"
87
87
+
);
88
88
+
s.stop("Updated sequoia.json with UI configuration");
89
89
+
} catch (error) {
90
90
+
s.stop("Failed to update sequoia.json");
91
91
+
log.warn(`Could not update config: ${error}`);
92
92
+
}
93
93
+
} else {
94
94
+
// Create minimal config just for UI
95
95
+
const s = spinner();
96
96
+
s.start("Creating sequoia.json...");
97
97
+
const minimalConfig = {
98
98
+
ui: { components: componentsDir },
99
99
+
};
100
100
+
await fs.writeFile(
101
101
+
path.join(process.cwd(), "sequoia.json"),
102
102
+
JSON.stringify(minimalConfig, null, 2),
103
103
+
"utf-8"
104
104
+
);
105
105
+
s.stop("Created sequoia.json with UI configuration");
106
106
+
}
107
107
+
}
108
108
+
109
109
+
// Resolve components directory
110
110
+
const resolvedComponentsDir = path.isAbsolute(componentsDir)
111
111
+
? componentsDir
112
112
+
: path.join(process.cwd(), componentsDir);
113
113
+
114
114
+
// Create components directory if it doesn't exist
115
115
+
if (!existsSync(resolvedComponentsDir)) {
116
116
+
const s = spinner();
117
117
+
s.start(`Creating ${componentsDir} directory...`);
118
118
+
await fs.mkdir(resolvedComponentsDir, { recursive: true });
119
119
+
s.stop(`Created ${componentsDir}`);
120
120
+
}
121
121
+
122
122
+
// Copy the component
123
123
+
const sourceFile = path.join(COMPONENTS_DIR, `${componentName}.js`);
124
124
+
const destFile = path.join(resolvedComponentsDir, `${componentName}.js`);
125
125
+
126
126
+
if (!existsSync(sourceFile)) {
127
127
+
log.error(`Component source file not found: ${sourceFile}`);
128
128
+
log.info("This may be a build issue. Try reinstalling sequoia-cli.");
129
129
+
process.exit(1);
130
130
+
}
131
131
+
132
132
+
const s = spinner();
133
133
+
s.start(`Installing ${componentName}...`);
134
134
+
135
135
+
try {
136
136
+
const componentCode = await fs.readFile(sourceFile, "utf-8");
137
137
+
await fs.writeFile(destFile, componentCode, "utf-8");
138
138
+
s.stop(`Installed ${componentName}`);
139
139
+
} catch (error) {
140
140
+
s.stop("Failed to install component");
141
141
+
log.error(`Error: ${error}`);
142
142
+
process.exit(1);
143
143
+
}
144
144
+
145
145
+
// Show usage instructions
146
146
+
note(
147
147
+
`Add to your HTML:\n\n` +
148
148
+
`<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` +
149
149
+
`<${componentName}></${componentName}>\n\n` +
150
150
+
`The component will automatically read the document URI from:\n` +
151
151
+
`<link rel="site.standard.document" href="at://...">`,
152
152
+
"Usage"
153
153
+
);
154
154
+
155
155
+
outro(`${componentName} added successfully!`);
156
156
+
},
157
157
+
});
+796
packages/cli/src/components/sequoia-comments.js
···
1
1
+
/**
2
2
+
* Sequoia Comments - A Bluesky-powered comments component
3
3
+
*
4
4
+
* A self-contained Web Component that displays comments from Bluesky posts
5
5
+
* linked to documents via the AT Protocol.
6
6
+
*
7
7
+
* Usage:
8
8
+
* <sequoia-comments></sequoia-comments>
9
9
+
*
10
10
+
* The component looks for a document URI in two places:
11
11
+
* 1. The `document-uri` attribute on the element
12
12
+
* 2. A <link rel="site.standard.document" href="at://..."> tag in the document head
13
13
+
*
14
14
+
* Attributes:
15
15
+
* - document-uri: AT Protocol URI for the document (optional if link tag exists)
16
16
+
* - depth: Maximum depth of nested replies to fetch (default: 6)
17
17
+
*
18
18
+
* CSS Custom Properties:
19
19
+
* - --sequoia-fg-color: Text color (default: #1f2937)
20
20
+
* - --sequoia-bg-color: Background color (default: #ffffff)
21
21
+
* - --sequoia-border-color: Border color (default: #e5e7eb)
22
22
+
* - --sequoia-accent-color: Accent/link color (default: #2563eb)
23
23
+
* - --sequoia-secondary-color: Secondary text color (default: #6b7280)
24
24
+
* - --sequoia-border-radius: Border radius (default: 8px)
25
25
+
*/
26
26
+
27
27
+
// ============================================================================
28
28
+
// Styles
29
29
+
// ============================================================================
30
30
+
31
31
+
const styles = `
32
32
+
:host {
33
33
+
display: block;
34
34
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
35
35
+
color: var(--sequoia-fg-color, #1f2937);
36
36
+
line-height: 1.5;
37
37
+
}
38
38
+
39
39
+
* {
40
40
+
box-sizing: border-box;
41
41
+
}
42
42
+
43
43
+
.sequoia-comments-container {
44
44
+
max-width: 100%;
45
45
+
}
46
46
+
47
47
+
.sequoia-loading,
48
48
+
.sequoia-error,
49
49
+
.sequoia-empty,
50
50
+
.sequoia-warning {
51
51
+
padding: 1rem;
52
52
+
border-radius: var(--sequoia-border-radius, 8px);
53
53
+
text-align: center;
54
54
+
}
55
55
+
56
56
+
.sequoia-loading {
57
57
+
background: var(--sequoia-bg-color, #ffffff);
58
58
+
border: 1px solid var(--sequoia-border-color, #e5e7eb);
59
59
+
color: var(--sequoia-secondary-color, #6b7280);
60
60
+
}
61
61
+
62
62
+
.sequoia-loading-spinner {
63
63
+
display: inline-block;
64
64
+
width: 1.25rem;
65
65
+
height: 1.25rem;
66
66
+
border: 2px solid var(--sequoia-border-color, #e5e7eb);
67
67
+
border-top-color: var(--sequoia-accent-color, #2563eb);
68
68
+
border-radius: 50%;
69
69
+
animation: sequoia-spin 0.8s linear infinite;
70
70
+
margin-right: 0.5rem;
71
71
+
vertical-align: middle;
72
72
+
}
73
73
+
74
74
+
@keyframes sequoia-spin {
75
75
+
to { transform: rotate(360deg); }
76
76
+
}
77
77
+
78
78
+
.sequoia-error {
79
79
+
background: #fef2f2;
80
80
+
border: 1px solid #fecaca;
81
81
+
color: #dc2626;
82
82
+
}
83
83
+
84
84
+
.sequoia-warning {
85
85
+
background: #fffbeb;
86
86
+
border: 1px solid #fde68a;
87
87
+
color: #d97706;
88
88
+
}
89
89
+
90
90
+
.sequoia-empty {
91
91
+
background: var(--sequoia-bg-color, #ffffff);
92
92
+
border: 1px solid var(--sequoia-border-color, #e5e7eb);
93
93
+
color: var(--sequoia-secondary-color, #6b7280);
94
94
+
}
95
95
+
96
96
+
.sequoia-comments-header {
97
97
+
display: flex;
98
98
+
justify-content: space-between;
99
99
+
align-items: center;
100
100
+
margin-bottom: 1rem;
101
101
+
padding-bottom: 0.75rem;
102
102
+
border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
103
103
+
}
104
104
+
105
105
+
.sequoia-comments-title {
106
106
+
font-size: 1.125rem;
107
107
+
font-weight: 600;
108
108
+
margin: 0;
109
109
+
}
110
110
+
111
111
+
.sequoia-reply-button {
112
112
+
display: inline-flex;
113
113
+
align-items: center;
114
114
+
gap: 0.375rem;
115
115
+
padding: 0.5rem 1rem;
116
116
+
background: var(--sequoia-accent-color, #2563eb);
117
117
+
color: #ffffff;
118
118
+
border: none;
119
119
+
border-radius: var(--sequoia-border-radius, 8px);
120
120
+
font-size: 0.875rem;
121
121
+
font-weight: 500;
122
122
+
cursor: pointer;
123
123
+
text-decoration: none;
124
124
+
transition: background-color 0.15s ease;
125
125
+
}
126
126
+
127
127
+
.sequoia-reply-button:hover {
128
128
+
background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
129
129
+
}
130
130
+
131
131
+
.sequoia-reply-button svg {
132
132
+
width: 1rem;
133
133
+
height: 1rem;
134
134
+
}
135
135
+
136
136
+
.sequoia-comments-list {
137
137
+
display: flex;
138
138
+
flex-direction: column;
139
139
+
gap: 0;
140
140
+
}
141
141
+
142
142
+
.sequoia-comment {
143
143
+
padding: 1rem;
144
144
+
background: var(--sequoia-bg-color, #ffffff);
145
145
+
border: 1px solid var(--sequoia-border-color, #e5e7eb);
146
146
+
border-radius: var(--sequoia-border-radius, 8px);
147
147
+
margin-bottom: 0.75rem;
148
148
+
}
149
149
+
150
150
+
.sequoia-comment-header {
151
151
+
display: flex;
152
152
+
align-items: center;
153
153
+
gap: 0.75rem;
154
154
+
margin-bottom: 0.5rem;
155
155
+
}
156
156
+
157
157
+
.sequoia-comment-avatar {
158
158
+
width: 2.5rem;
159
159
+
height: 2.5rem;
160
160
+
border-radius: 50%;
161
161
+
background: var(--sequoia-border-color, #e5e7eb);
162
162
+
object-fit: cover;
163
163
+
flex-shrink: 0;
164
164
+
}
165
165
+
166
166
+
.sequoia-comment-avatar-placeholder {
167
167
+
width: 2.5rem;
168
168
+
height: 2.5rem;
169
169
+
border-radius: 50%;
170
170
+
background: var(--sequoia-border-color, #e5e7eb);
171
171
+
display: flex;
172
172
+
align-items: center;
173
173
+
justify-content: center;
174
174
+
flex-shrink: 0;
175
175
+
color: var(--sequoia-secondary-color, #6b7280);
176
176
+
font-weight: 600;
177
177
+
font-size: 1rem;
178
178
+
}
179
179
+
180
180
+
.sequoia-comment-meta {
181
181
+
display: flex;
182
182
+
flex-direction: column;
183
183
+
min-width: 0;
184
184
+
}
185
185
+
186
186
+
.sequoia-comment-author {
187
187
+
font-weight: 600;
188
188
+
color: var(--sequoia-fg-color, #1f2937);
189
189
+
text-decoration: none;
190
190
+
overflow: hidden;
191
191
+
text-overflow: ellipsis;
192
192
+
white-space: nowrap;
193
193
+
}
194
194
+
195
195
+
.sequoia-comment-author:hover {
196
196
+
color: var(--sequoia-accent-color, #2563eb);
197
197
+
}
198
198
+
199
199
+
.sequoia-comment-handle {
200
200
+
font-size: 0.875rem;
201
201
+
color: var(--sequoia-secondary-color, #6b7280);
202
202
+
overflow: hidden;
203
203
+
text-overflow: ellipsis;
204
204
+
white-space: nowrap;
205
205
+
}
206
206
+
207
207
+
.sequoia-comment-time {
208
208
+
font-size: 0.75rem;
209
209
+
color: var(--sequoia-secondary-color, #6b7280);
210
210
+
margin-left: auto;
211
211
+
flex-shrink: 0;
212
212
+
}
213
213
+
214
214
+
.sequoia-comment-text {
215
215
+
margin: 0;
216
216
+
white-space: pre-wrap;
217
217
+
word-wrap: break-word;
218
218
+
}
219
219
+
220
220
+
.sequoia-comment-text a {
221
221
+
color: var(--sequoia-accent-color, #2563eb);
222
222
+
text-decoration: none;
223
223
+
}
224
224
+
225
225
+
.sequoia-comment-text a:hover {
226
226
+
text-decoration: underline;
227
227
+
}
228
228
+
229
229
+
.sequoia-comment-replies {
230
230
+
margin-top: 0.75rem;
231
231
+
margin-left: 1.5rem;
232
232
+
padding-left: 1rem;
233
233
+
border-left: 2px solid var(--sequoia-border-color, #e5e7eb);
234
234
+
}
235
235
+
236
236
+
.sequoia-comment-replies .sequoia-comment {
237
237
+
margin-bottom: 0.5rem;
238
238
+
}
239
239
+
240
240
+
.sequoia-comment-replies .sequoia-comment:last-child {
241
241
+
margin-bottom: 0;
242
242
+
}
243
243
+
244
244
+
.sequoia-bsky-logo {
245
245
+
width: 1rem;
246
246
+
height: 1rem;
247
247
+
}
248
248
+
`;
249
249
+
250
250
+
// ============================================================================
251
251
+
// Utility Functions
252
252
+
// ============================================================================
253
253
+
254
254
+
/**
255
255
+
* Format a relative time string (e.g., "2 hours ago")
256
256
+
* @param {string} dateString - ISO date string
257
257
+
* @returns {string} Formatted relative time
258
258
+
*/
259
259
+
function formatRelativeTime(dateString) {
260
260
+
const date = new Date(dateString);
261
261
+
const now = new Date();
262
262
+
const diffMs = now.getTime() - date.getTime();
263
263
+
const diffSeconds = Math.floor(diffMs / 1000);
264
264
+
const diffMinutes = Math.floor(diffSeconds / 60);
265
265
+
const diffHours = Math.floor(diffMinutes / 60);
266
266
+
const diffDays = Math.floor(diffHours / 24);
267
267
+
const diffWeeks = Math.floor(diffDays / 7);
268
268
+
const diffMonths = Math.floor(diffDays / 30);
269
269
+
const diffYears = Math.floor(diffDays / 365);
270
270
+
271
271
+
if (diffSeconds < 60) {
272
272
+
return "just now";
273
273
+
}
274
274
+
if (diffMinutes < 60) {
275
275
+
return `${diffMinutes}m ago`;
276
276
+
}
277
277
+
if (diffHours < 24) {
278
278
+
return `${diffHours}h ago`;
279
279
+
}
280
280
+
if (diffDays < 7) {
281
281
+
return `${diffDays}d ago`;
282
282
+
}
283
283
+
if (diffWeeks < 4) {
284
284
+
return `${diffWeeks}w ago`;
285
285
+
}
286
286
+
if (diffMonths < 12) {
287
287
+
return `${diffMonths}mo ago`;
288
288
+
}
289
289
+
return `${diffYears}y ago`;
290
290
+
}
291
291
+
292
292
+
/**
293
293
+
* Escape HTML special characters
294
294
+
* @param {string} text - Text to escape
295
295
+
* @returns {string} Escaped HTML
296
296
+
*/
297
297
+
function escapeHtml(text) {
298
298
+
const div = document.createElement("div");
299
299
+
div.textContent = text;
300
300
+
return div.innerHTML;
301
301
+
}
302
302
+
303
303
+
/**
304
304
+
* Convert post text with facets to HTML
305
305
+
* @param {string} text - Post text
306
306
+
* @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
307
307
+
* @returns {string} HTML string with links
308
308
+
*/
309
309
+
function renderTextWithFacets(text, facets) {
310
310
+
if (!facets || facets.length === 0) {
311
311
+
return escapeHtml(text);
312
312
+
}
313
313
+
314
314
+
// Convert text to bytes for proper indexing
315
315
+
const encoder = new TextEncoder();
316
316
+
const decoder = new TextDecoder();
317
317
+
const textBytes = encoder.encode(text);
318
318
+
319
319
+
// Sort facets by start index
320
320
+
const sortedFacets = [...facets].sort(
321
321
+
(a, b) => a.index.byteStart - b.index.byteStart
322
322
+
);
323
323
+
324
324
+
let result = "";
325
325
+
let lastEnd = 0;
326
326
+
327
327
+
for (const facet of sortedFacets) {
328
328
+
const { byteStart, byteEnd } = facet.index;
329
329
+
330
330
+
// Add text before this facet
331
331
+
if (byteStart > lastEnd) {
332
332
+
const beforeBytes = textBytes.slice(lastEnd, byteStart);
333
333
+
result += escapeHtml(decoder.decode(beforeBytes));
334
334
+
}
335
335
+
336
336
+
// Get the facet text
337
337
+
const facetBytes = textBytes.slice(byteStart, byteEnd);
338
338
+
const facetText = decoder.decode(facetBytes);
339
339
+
340
340
+
// Find the first renderable feature
341
341
+
const feature = facet.features[0];
342
342
+
if (feature) {
343
343
+
if (feature.$type === "app.bsky.richtext.facet#link") {
344
344
+
result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
345
345
+
} else if (feature.$type === "app.bsky.richtext.facet#mention") {
346
346
+
result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
347
347
+
} else if (feature.$type === "app.bsky.richtext.facet#tag") {
348
348
+
result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
349
349
+
} else {
350
350
+
result += escapeHtml(facetText);
351
351
+
}
352
352
+
} else {
353
353
+
result += escapeHtml(facetText);
354
354
+
}
355
355
+
356
356
+
lastEnd = byteEnd;
357
357
+
}
358
358
+
359
359
+
// Add remaining text
360
360
+
if (lastEnd < textBytes.length) {
361
361
+
const remainingBytes = textBytes.slice(lastEnd);
362
362
+
result += escapeHtml(decoder.decode(remainingBytes));
363
363
+
}
364
364
+
365
365
+
return result;
366
366
+
}
367
367
+
368
368
+
/**
369
369
+
* Get initials from a name for avatar placeholder
370
370
+
* @param {string} name - Display name
371
371
+
* @returns {string} Initials (1-2 characters)
372
372
+
*/
373
373
+
function getInitials(name) {
374
374
+
const parts = name.trim().split(/\s+/);
375
375
+
if (parts.length >= 2) {
376
376
+
return (parts[0][0] + parts[1][0]).toUpperCase();
377
377
+
}
378
378
+
return name.substring(0, 2).toUpperCase();
379
379
+
}
380
380
+
381
381
+
// ============================================================================
382
382
+
// AT Protocol Client Functions
383
383
+
// ============================================================================
384
384
+
385
385
+
/**
386
386
+
* Parse an AT URI into its components
387
387
+
* Format: at://did/collection/rkey
388
388
+
* @param {string} atUri - AT Protocol URI
389
389
+
* @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
390
390
+
*/
391
391
+
function parseAtUri(atUri) {
392
392
+
const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
393
393
+
if (!match) return null;
394
394
+
return {
395
395
+
did: match[1],
396
396
+
collection: match[2],
397
397
+
rkey: match[3],
398
398
+
};
399
399
+
}
400
400
+
401
401
+
/**
402
402
+
* Resolve a DID to its PDS URL
403
403
+
* Supports did:plc and did:web methods
404
404
+
* @param {string} did - Decentralized Identifier
405
405
+
* @returns {Promise<string>} PDS URL
406
406
+
*/
407
407
+
async function resolvePDS(did) {
408
408
+
let pdsUrl;
409
409
+
410
410
+
if (did.startsWith("did:plc:")) {
411
411
+
// Fetch DID document from plc.directory
412
412
+
const didDocUrl = `https://plc.directory/${did}`;
413
413
+
const didDocResponse = await fetch(didDocUrl);
414
414
+
if (!didDocResponse.ok) {
415
415
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
416
416
+
}
417
417
+
const didDoc = await didDocResponse.json();
418
418
+
419
419
+
// Find the PDS service endpoint
420
420
+
const pdsService = didDoc.service?.find(
421
421
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
422
422
+
);
423
423
+
pdsUrl = pdsService?.serviceEndpoint;
424
424
+
} else if (did.startsWith("did:web:")) {
425
425
+
// For did:web, fetch the DID document from the domain
426
426
+
const domain = did.replace("did:web:", "");
427
427
+
const didDocUrl = `https://${domain}/.well-known/did.json`;
428
428
+
const didDocResponse = await fetch(didDocUrl);
429
429
+
if (!didDocResponse.ok) {
430
430
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
431
431
+
}
432
432
+
const didDoc = await didDocResponse.json();
433
433
+
434
434
+
const pdsService = didDoc.service?.find(
435
435
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
436
436
+
);
437
437
+
pdsUrl = pdsService?.serviceEndpoint;
438
438
+
} else {
439
439
+
throw new Error(`Unsupported DID method: ${did}`);
440
440
+
}
441
441
+
442
442
+
if (!pdsUrl) {
443
443
+
throw new Error("Could not find PDS URL for user");
444
444
+
}
445
445
+
446
446
+
return pdsUrl;
447
447
+
}
448
448
+
449
449
+
/**
450
450
+
* Fetch a record from a PDS using the public API
451
451
+
* @param {string} did - DID of the repository owner
452
452
+
* @param {string} collection - Collection name
453
453
+
* @param {string} rkey - Record key
454
454
+
* @returns {Promise<any>} Record value
455
455
+
*/
456
456
+
async function getRecord(did, collection, rkey) {
457
457
+
const pdsUrl = await resolvePDS(did);
458
458
+
459
459
+
const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
460
460
+
url.searchParams.set("repo", did);
461
461
+
url.searchParams.set("collection", collection);
462
462
+
url.searchParams.set("rkey", rkey);
463
463
+
464
464
+
const response = await fetch(url.toString());
465
465
+
if (!response.ok) {
466
466
+
throw new Error(`Failed to fetch record: ${response.status}`);
467
467
+
}
468
468
+
469
469
+
const data = await response.json();
470
470
+
return data.value;
471
471
+
}
472
472
+
473
473
+
/**
474
474
+
* Fetch a document record from its AT URI
475
475
+
* @param {string} atUri - AT Protocol URI for the document
476
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
477
+
*/
478
478
+
async function getDocument(atUri) {
479
479
+
const parsed = parseAtUri(atUri);
480
480
+
if (!parsed) {
481
481
+
throw new Error(`Invalid AT URI: ${atUri}`);
482
482
+
}
483
483
+
484
484
+
return getRecord(parsed.did, parsed.collection, parsed.rkey);
485
485
+
}
486
486
+
487
487
+
/**
488
488
+
* Fetch a post thread from the public Bluesky API
489
489
+
* @param {string} postUri - AT Protocol URI for the post
490
490
+
* @param {number} [depth=6] - Maximum depth of replies to fetch
491
491
+
* @returns {Promise<ThreadViewPost>} Thread view post
492
492
+
*/
493
493
+
async function getPostThread(postUri, depth = 6) {
494
494
+
const url = new URL(
495
495
+
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread"
496
496
+
);
497
497
+
url.searchParams.set("uri", postUri);
498
498
+
url.searchParams.set("depth", depth.toString());
499
499
+
500
500
+
const response = await fetch(url.toString());
501
501
+
if (!response.ok) {
502
502
+
throw new Error(`Failed to fetch post thread: ${response.status}`);
503
503
+
}
504
504
+
505
505
+
const data = await response.json();
506
506
+
507
507
+
if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
508
508
+
throw new Error("Post not found or blocked");
509
509
+
}
510
510
+
511
511
+
return data.thread;
512
512
+
}
513
513
+
514
514
+
/**
515
515
+
* Build a Bluesky app URL for a post
516
516
+
* @param {string} postUri - AT Protocol URI for the post
517
517
+
* @returns {string} Bluesky app URL
518
518
+
*/
519
519
+
function buildBskyAppUrl(postUri) {
520
520
+
const parsed = parseAtUri(postUri);
521
521
+
if (!parsed) {
522
522
+
throw new Error(`Invalid post URI: ${postUri}`);
523
523
+
}
524
524
+
525
525
+
return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
526
526
+
}
527
527
+
528
528
+
/**
529
529
+
* Type guard for ThreadViewPost
530
530
+
* @param {any} post - Post to check
531
531
+
* @returns {boolean} True if post is a ThreadViewPost
532
532
+
*/
533
533
+
function isThreadViewPost(post) {
534
534
+
return post?.$type === "app.bsky.feed.defs#threadViewPost";
535
535
+
}
536
536
+
537
537
+
// ============================================================================
538
538
+
// Bluesky Icon
539
539
+
// ============================================================================
540
540
+
541
541
+
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
542
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
543
+
</svg>`;
544
544
+
545
545
+
// ============================================================================
546
546
+
// Web Component
547
547
+
// ============================================================================
548
548
+
549
549
+
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
550
550
+
const BaseElement =
551
551
+
typeof HTMLElement !== "undefined"
552
552
+
? HTMLElement
553
553
+
: class {};
554
554
+
555
555
+
class SequoiaComments extends BaseElement {
556
556
+
constructor() {
557
557
+
super();
558
558
+
this.shadow = this.attachShadow({ mode: "open" });
559
559
+
this.state = { type: "loading" };
560
560
+
this.abortController = null;
561
561
+
}
562
562
+
563
563
+
static get observedAttributes() {
564
564
+
return ["document-uri", "depth"];
565
565
+
}
566
566
+
567
567
+
connectedCallback() {
568
568
+
this.render();
569
569
+
this.loadComments();
570
570
+
}
571
571
+
572
572
+
disconnectedCallback() {
573
573
+
this.abortController?.abort();
574
574
+
}
575
575
+
576
576
+
attributeChangedCallback() {
577
577
+
if (this.isConnected) {
578
578
+
this.loadComments();
579
579
+
}
580
580
+
}
581
581
+
582
582
+
get documentUri() {
583
583
+
// First check attribute
584
584
+
const attrUri = this.getAttribute("document-uri");
585
585
+
if (attrUri) {
586
586
+
return attrUri;
587
587
+
}
588
588
+
589
589
+
// Then scan for link tag in document head
590
590
+
const linkTag = document.querySelector(
591
591
+
'link[rel="site.standard.document"]'
592
592
+
);
593
593
+
return linkTag?.href ?? null;
594
594
+
}
595
595
+
596
596
+
get depth() {
597
597
+
const depthAttr = this.getAttribute("depth");
598
598
+
return depthAttr ? parseInt(depthAttr, 10) : 6;
599
599
+
}
600
600
+
601
601
+
async loadComments() {
602
602
+
// Cancel any in-flight request
603
603
+
this.abortController?.abort();
604
604
+
this.abortController = new AbortController();
605
605
+
606
606
+
this.state = { type: "loading" };
607
607
+
this.render();
608
608
+
609
609
+
const docUri = this.documentUri;
610
610
+
if (!docUri) {
611
611
+
this.state = { type: "no-document" };
612
612
+
this.render();
613
613
+
return;
614
614
+
}
615
615
+
616
616
+
try {
617
617
+
// Fetch the document record
618
618
+
const document = await getDocument(docUri);
619
619
+
620
620
+
// Check if document has a Bluesky post reference
621
621
+
if (!document.bskyPostRef) {
622
622
+
this.state = { type: "no-comments-enabled" };
623
623
+
this.render();
624
624
+
return;
625
625
+
}
626
626
+
627
627
+
const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
628
628
+
629
629
+
// Fetch the post thread
630
630
+
const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
631
631
+
632
632
+
// Check if there are any replies
633
633
+
const replies = thread.replies?.filter(isThreadViewPost) ?? [];
634
634
+
if (replies.length === 0) {
635
635
+
this.state = { type: "empty", postUrl };
636
636
+
this.render();
637
637
+
return;
638
638
+
}
639
639
+
640
640
+
this.state = { type: "loaded", thread, postUrl };
641
641
+
this.render();
642
642
+
} catch (error) {
643
643
+
const message =
644
644
+
error instanceof Error ? error.message : "Failed to load comments";
645
645
+
this.state = { type: "error", message };
646
646
+
this.render();
647
647
+
}
648
648
+
}
649
649
+
650
650
+
render() {
651
651
+
const styleTag = `<style>${styles}</style>`;
652
652
+
653
653
+
switch (this.state.type) {
654
654
+
case "loading":
655
655
+
this.shadow.innerHTML = `
656
656
+
${styleTag}
657
657
+
<div class="sequoia-comments-container">
658
658
+
<div class="sequoia-loading">
659
659
+
<span class="sequoia-loading-spinner"></span>
660
660
+
Loading comments...
661
661
+
</div>
662
662
+
</div>
663
663
+
`;
664
664
+
break;
665
665
+
666
666
+
case "no-document":
667
667
+
this.shadow.innerHTML = `
668
668
+
${styleTag}
669
669
+
<div class="sequoia-comments-container">
670
670
+
<div class="sequoia-warning">
671
671
+
No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
672
672
+
</div>
673
673
+
</div>
674
674
+
`;
675
675
+
break;
676
676
+
677
677
+
case "no-comments-enabled":
678
678
+
this.shadow.innerHTML = `
679
679
+
${styleTag}
680
680
+
<div class="sequoia-comments-container">
681
681
+
<div class="sequoia-empty">
682
682
+
Comments are not enabled for this post.
683
683
+
</div>
684
684
+
</div>
685
685
+
`;
686
686
+
break;
687
687
+
688
688
+
case "empty":
689
689
+
this.shadow.innerHTML = `
690
690
+
${styleTag}
691
691
+
<div class="sequoia-comments-container">
692
692
+
<div class="sequoia-comments-header">
693
693
+
<h3 class="sequoia-comments-title">Comments</h3>
694
694
+
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
695
695
+
${BLUESKY_ICON}
696
696
+
Reply on Bluesky
697
697
+
</a>
698
698
+
</div>
699
699
+
<div class="sequoia-empty">
700
700
+
No comments yet. Be the first to reply on Bluesky!
701
701
+
</div>
702
702
+
</div>
703
703
+
`;
704
704
+
break;
705
705
+
706
706
+
case "error":
707
707
+
this.shadow.innerHTML = `
708
708
+
${styleTag}
709
709
+
<div class="sequoia-comments-container">
710
710
+
<div class="sequoia-error">
711
711
+
Failed to load comments: ${escapeHtml(this.state.message)}
712
712
+
</div>
713
713
+
</div>
714
714
+
`;
715
715
+
break;
716
716
+
717
717
+
case "loaded": {
718
718
+
const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? [];
719
719
+
const commentsHtml = replies.map((reply) => this.renderComment(reply)).join("");
720
720
+
const commentCount = this.countComments(replies);
721
721
+
722
722
+
this.shadow.innerHTML = `
723
723
+
${styleTag}
724
724
+
<div class="sequoia-comments-container">
725
725
+
<div class="sequoia-comments-header">
726
726
+
<h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
727
727
+
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
728
728
+
${BLUESKY_ICON}
729
729
+
Reply on Bluesky
730
730
+
</a>
731
731
+
</div>
732
732
+
<div class="sequoia-comments-list">
733
733
+
${commentsHtml}
734
734
+
</div>
735
735
+
</div>
736
736
+
`;
737
737
+
break;
738
738
+
}
739
739
+
}
740
740
+
}
741
741
+
742
742
+
renderComment(thread) {
743
743
+
const { post } = thread;
744
744
+
const author = post.author;
745
745
+
const displayName = author.displayName || author.handle;
746
746
+
const avatarHtml = author.avatar
747
747
+
? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
748
748
+
: `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
749
749
+
750
750
+
const profileUrl = `https://bsky.app/profile/${author.did}`;
751
751
+
const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
752
752
+
const timeAgo = formatRelativeTime(post.record.createdAt);
753
753
+
754
754
+
// Render nested replies
755
755
+
const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
756
756
+
const repliesHtml =
757
757
+
nestedReplies.length > 0
758
758
+
? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>`
759
759
+
: "";
760
760
+
761
761
+
return `
762
762
+
<div class="sequoia-comment">
763
763
+
<div class="sequoia-comment-header">
764
764
+
${avatarHtml}
765
765
+
<div class="sequoia-comment-meta">
766
766
+
<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
767
767
+
${escapeHtml(displayName)}
768
768
+
</a>
769
769
+
<span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
770
770
+
</div>
771
771
+
<span class="sequoia-comment-time">${timeAgo}</span>
772
772
+
</div>
773
773
+
<p class="sequoia-comment-text">${textHtml}</p>
774
774
+
${repliesHtml}
775
775
+
</div>
776
776
+
`;
777
777
+
}
778
778
+
779
779
+
countComments(replies) {
780
780
+
let count = 0;
781
781
+
for (const reply of replies) {
782
782
+
count += 1;
783
783
+
const nested = reply.replies?.filter(isThreadViewPost) ?? [];
784
784
+
count += this.countComments(nested);
785
785
+
}
786
786
+
return count;
787
787
+
}
788
788
+
}
789
789
+
790
790
+
// Register the custom element
791
791
+
if (typeof customElements !== "undefined") {
792
792
+
customElements.define("sequoia-comments", SequoiaComments);
793
793
+
}
794
794
+
795
795
+
// Export for module usage
796
796
+
export { SequoiaComments };
+3
-1
packages/cli/src/index.ts
···
1
1
#!/usr/bin/env node
2
2
3
3
import { run, subcommands } from "cmd-ts";
4
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
38
-
version: "0.3.3",
39
39
+
version: "0.4.0",
39
40
cmds: {
41
41
+
add: addCommand,
40
42
auth: authCommand,
41
43
init: initCommand,
42
44
inject: injectCommand,
+6
packages/cli/src/lib/types.ts
···
20
20
maxAgeDays?: number; // Only post if published within N days (default: 7)
21
21
}
22
22
23
23
+
// UI components configuration
24
24
+
export interface UIConfig {
25
25
+
components: string; // Directory to install UI components (default: src/components)
26
26
+
}
27
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
44
+
ui?: UIConfig; // Optional UI components configuration
39
45
}
40
46
41
47
// Legacy credentials format (for backward compatibility during migration)
-3
packages/ui/.gitignore
···
1
1
-
dist/
2
2
-
node_modules/
3
3
-
test-site/
-37
packages/ui/biome.json
···
1
1
-
{
2
2
-
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
3
3
-
"vcs": {
4
4
-
"enabled": true,
5
5
-
"clientKind": "git",
6
6
-
"useIgnoreFile": true
7
7
-
},
8
8
-
"files": {
9
9
-
"includes": ["**", "!!**/dist"]
10
10
-
},
11
11
-
"formatter": {
12
12
-
"enabled": true,
13
13
-
"indentStyle": "tab"
14
14
-
},
15
15
-
"linter": {
16
16
-
"enabled": true,
17
17
-
"rules": {
18
18
-
"recommended": true,
19
19
-
"style": {
20
20
-
"noNonNullAssertion": "off"
21
21
-
}
22
22
-
}
23
23
-
},
24
24
-
"javascript": {
25
25
-
"formatter": {
26
26
-
"quoteStyle": "double"
27
27
-
}
28
28
-
},
29
29
-
"assist": {
30
30
-
"enabled": true,
31
31
-
"actions": {
32
32
-
"source": {
33
33
-
"organizeImports": "on"
34
34
-
}
35
35
-
}
36
36
-
}
37
37
-
}
-34
packages/ui/package.json
···
1
1
-
{
2
2
-
"name": "sequoia-ui",
3
3
-
"version": "0.0.2",
4
4
-
"type": "module",
5
5
-
"files": [
6
6
-
"dist",
7
7
-
"README.md"
8
8
-
],
9
9
-
"main": "./dist/index.js",
10
10
-
"exports": {
11
11
-
".": {
12
12
-
"import": "./dist/index.js",
13
13
-
"default": "./dist/index.js"
14
14
-
},
15
15
-
"./comments": {
16
16
-
"import": "./dist/index.js",
17
17
-
"default": "./dist/index.js"
18
18
-
}
19
19
-
},
20
20
-
"scripts": {
21
21
-
"lint": "biome lint --write",
22
22
-
"format": "biome format --write",
23
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
24
-
"dev": "bun run build",
25
25
-
"deploy": "bun run build && bun publish --access public"
26
26
-
},
27
27
-
"devDependencies": {
28
28
-
"@biomejs/biome": "^2.3.13",
29
29
-
"@types/node": "^20"
30
30
-
},
31
31
-
"peerDependencies": {
32
32
-
"typescript": "^5"
33
33
-
}
34
34
-
}
-11
packages/ui/src/components/sequoia-comments/index.ts
···
1
1
-
import { SequoiaComments } from "./sequoia-comments";
2
2
-
3
3
-
// Register the custom element if not already registered
4
4
-
if (
5
5
-
typeof customElements !== "undefined" &&
6
6
-
!customElements.get("sequoia-comments")
7
7
-
) {
8
8
-
customElements.define("sequoia-comments", SequoiaComments);
9
9
-
}
10
10
-
11
11
-
export { SequoiaComments };
-276
packages/ui/src/components/sequoia-comments/sequoia-comments.ts
···
1
1
-
import {
2
2
-
buildBskyAppUrl,
3
3
-
getDocument,
4
4
-
getPostThread,
5
5
-
} from "../../lib/atproto-client";
6
6
-
import type { ThreadViewPost } from "../../types/bluesky";
7
7
-
import { isThreadViewPost } from "../../types/bluesky";
8
8
-
import { styles } from "./styles";
9
9
-
import { formatRelativeTime, getInitials, renderTextWithFacets } from "./utils";
10
10
-
11
11
-
/**
12
12
-
* Component state
13
13
-
*/
14
14
-
type State =
15
15
-
| { type: "loading" }
16
16
-
| { type: "loaded"; thread: ThreadViewPost; postUrl: string }
17
17
-
| { type: "no-document" }
18
18
-
| { type: "no-comments-enabled" }
19
19
-
| { type: "empty"; postUrl: string }
20
20
-
| { type: "error"; message: string };
21
21
-
22
22
-
/**
23
23
-
* Bluesky butterfly SVG icon
24
24
-
*/
25
25
-
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
26
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
27
-
</svg>`;
28
28
-
29
29
-
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
30
30
-
const BaseElement =
31
31
-
typeof HTMLElement !== "undefined"
32
32
-
? HTMLElement
33
33
-
: (class {} as typeof HTMLElement);
34
34
-
35
35
-
export class SequoiaComments extends BaseElement {
36
36
-
private shadow: ShadowRoot;
37
37
-
private state: State = { type: "loading" };
38
38
-
private abortController: AbortController | null = null;
39
39
-
40
40
-
static get observedAttributes(): string[] {
41
41
-
return ["document-uri", "depth"];
42
42
-
}
43
43
-
44
44
-
constructor() {
45
45
-
super();
46
46
-
this.shadow = this.attachShadow({ mode: "open" });
47
47
-
}
48
48
-
49
49
-
connectedCallback(): void {
50
50
-
this.render();
51
51
-
this.loadComments();
52
52
-
}
53
53
-
54
54
-
disconnectedCallback(): void {
55
55
-
this.abortController?.abort();
56
56
-
}
57
57
-
58
58
-
attributeChangedCallback(): void {
59
59
-
if (this.isConnected) {
60
60
-
this.loadComments();
61
61
-
}
62
62
-
}
63
63
-
64
64
-
private get documentUri(): string | null {
65
65
-
// First check attribute
66
66
-
const attrUri = this.getAttribute("document-uri");
67
67
-
if (attrUri) {
68
68
-
return attrUri;
69
69
-
}
70
70
-
71
71
-
// Then scan for link tag in document head
72
72
-
const linkTag = document.querySelector<HTMLLinkElement>(
73
73
-
'link[rel="site.standard.document"]',
74
74
-
);
75
75
-
return linkTag?.href ?? null;
76
76
-
}
77
77
-
78
78
-
private get depth(): number {
79
79
-
const depthAttr = this.getAttribute("depth");
80
80
-
return depthAttr ? Number.parseInt(depthAttr, 10) : 6;
81
81
-
}
82
82
-
83
83
-
private async loadComments(): Promise<void> {
84
84
-
// Cancel any in-flight request
85
85
-
this.abortController?.abort();
86
86
-
this.abortController = new AbortController();
87
87
-
88
88
-
this.state = { type: "loading" };
89
89
-
this.render();
90
90
-
91
91
-
const docUri = this.documentUri;
92
92
-
if (!docUri) {
93
93
-
this.state = { type: "no-document" };
94
94
-
this.render();
95
95
-
return;
96
96
-
}
97
97
-
98
98
-
try {
99
99
-
// Fetch the document record
100
100
-
const document = await getDocument(docUri);
101
101
-
102
102
-
// Check if document has a Bluesky post reference
103
103
-
if (!document.bskyPostRef) {
104
104
-
this.state = { type: "no-comments-enabled" };
105
105
-
this.render();
106
106
-
return;
107
107
-
}
108
108
-
109
109
-
const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
110
110
-
111
111
-
// Fetch the post thread
112
112
-
const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
113
113
-
114
114
-
// Check if there are any replies
115
115
-
const replies = thread.replies?.filter(isThreadViewPost) ?? [];
116
116
-
if (replies.length === 0) {
117
117
-
this.state = { type: "empty", postUrl };
118
118
-
this.render();
119
119
-
return;
120
120
-
}
121
121
-
122
122
-
this.state = { type: "loaded", thread, postUrl };
123
123
-
this.render();
124
124
-
} catch (error) {
125
125
-
const message =
126
126
-
error instanceof Error ? error.message : "Failed to load comments";
127
127
-
this.state = { type: "error", message };
128
128
-
this.render();
129
129
-
}
130
130
-
}
131
131
-
132
132
-
private render(): void {
133
133
-
const styleTag = `<style>${styles}</style>`;
134
134
-
135
135
-
switch (this.state.type) {
136
136
-
case "loading":
137
137
-
this.shadow.innerHTML = `
138
138
-
${styleTag}
139
139
-
<div class="sequoia-comments-container">
140
140
-
<div class="sequoia-loading">
141
141
-
<span class="sequoia-loading-spinner"></span>
142
142
-
Loading comments...
143
143
-
</div>
144
144
-
</div>
145
145
-
`;
146
146
-
break;
147
147
-
148
148
-
case "no-document":
149
149
-
this.shadow.innerHTML = `
150
150
-
${styleTag}
151
151
-
<div class="sequoia-comments-container">
152
152
-
<div class="sequoia-warning">
153
153
-
No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
154
154
-
</div>
155
155
-
</div>
156
156
-
`;
157
157
-
break;
158
158
-
159
159
-
case "no-comments-enabled":
160
160
-
this.shadow.innerHTML = `
161
161
-
${styleTag}
162
162
-
<div class="sequoia-comments-container">
163
163
-
<div class="sequoia-empty">
164
164
-
Comments are not enabled for this post.
165
165
-
</div>
166
166
-
</div>
167
167
-
`;
168
168
-
break;
169
169
-
170
170
-
case "empty":
171
171
-
this.shadow.innerHTML = `
172
172
-
${styleTag}
173
173
-
<div class="sequoia-comments-container">
174
174
-
<div class="sequoia-comments-header">
175
175
-
<h3 class="sequoia-comments-title">Comments</h3>
176
176
-
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
177
177
-
${BLUESKY_ICON}
178
178
-
Reply on Bluesky
179
179
-
</a>
180
180
-
</div>
181
181
-
<div class="sequoia-empty">
182
182
-
No comments yet. Be the first to reply on Bluesky!
183
183
-
</div>
184
184
-
</div>
185
185
-
`;
186
186
-
break;
187
187
-
188
188
-
case "error":
189
189
-
this.shadow.innerHTML = `
190
190
-
${styleTag}
191
191
-
<div class="sequoia-comments-container">
192
192
-
<div class="sequoia-error">
193
193
-
Failed to load comments: ${this.escapeHtml(this.state.message)}
194
194
-
</div>
195
195
-
</div>
196
196
-
`;
197
197
-
break;
198
198
-
199
199
-
case "loaded": {
200
200
-
const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? [];
201
201
-
const commentsHtml = replies.map((reply) => this.renderComment(reply)).join("");
202
202
-
const commentCount = this.countComments(replies);
203
203
-
204
204
-
this.shadow.innerHTML = `
205
205
-
${styleTag}
206
206
-
<div class="sequoia-comments-container">
207
207
-
<div class="sequoia-comments-header">
208
208
-
<h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
209
209
-
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
210
210
-
${BLUESKY_ICON}
211
211
-
Reply on Bluesky
212
212
-
</a>
213
213
-
</div>
214
214
-
<div class="sequoia-comments-list">
215
215
-
${commentsHtml}
216
216
-
</div>
217
217
-
</div>
218
218
-
`;
219
219
-
break;
220
220
-
}
221
221
-
}
222
222
-
}
223
223
-
224
224
-
private renderComment(thread: ThreadViewPost): string {
225
225
-
const { post } = thread;
226
226
-
const author = post.author;
227
227
-
const displayName = author.displayName || author.handle;
228
228
-
const avatarHtml = author.avatar
229
229
-
? `<img class="sequoia-comment-avatar" src="${this.escapeHtml(author.avatar)}" alt="${this.escapeHtml(displayName)}" loading="lazy" />`
230
230
-
: `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
231
231
-
232
232
-
const profileUrl = `https://bsky.app/profile/${author.did}`;
233
233
-
const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
234
234
-
const timeAgo = formatRelativeTime(post.record.createdAt);
235
235
-
236
236
-
// Render nested replies
237
237
-
const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
238
238
-
const repliesHtml =
239
239
-
nestedReplies.length > 0
240
240
-
? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>`
241
241
-
: "";
242
242
-
243
243
-
return `
244
244
-
<div class="sequoia-comment">
245
245
-
<div class="sequoia-comment-header">
246
246
-
${avatarHtml}
247
247
-
<div class="sequoia-comment-meta">
248
248
-
<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
249
249
-
${this.escapeHtml(displayName)}
250
250
-
</a>
251
251
-
<span class="sequoia-comment-handle">@${this.escapeHtml(author.handle)}</span>
252
252
-
</div>
253
253
-
<span class="sequoia-comment-time">${timeAgo}</span>
254
254
-
</div>
255
255
-
<p class="sequoia-comment-text">${textHtml}</p>
256
256
-
${repliesHtml}
257
257
-
</div>
258
258
-
`;
259
259
-
}
260
260
-
261
261
-
private countComments(replies: ThreadViewPost[]): number {
262
262
-
let count = 0;
263
263
-
for (const reply of replies) {
264
264
-
count += 1;
265
265
-
const nested = reply.replies?.filter(isThreadViewPost) ?? [];
266
266
-
count += this.countComments(nested);
267
267
-
}
268
268
-
return count;
269
269
-
}
270
270
-
271
271
-
private escapeHtml(text: string): string {
272
272
-
const div = document.createElement("div");
273
273
-
div.textContent = text;
274
274
-
return div.innerHTML;
275
275
-
}
276
276
-
}
-218
packages/ui/src/components/sequoia-comments/styles.ts
···
1
1
-
export const styles = `
2
2
-
:host {
3
3
-
display: block;
4
4
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5
5
-
color: var(--sequoia-fg-color, #1f2937);
6
6
-
line-height: 1.5;
7
7
-
}
8
8
-
9
9
-
* {
10
10
-
box-sizing: border-box;
11
11
-
}
12
12
-
13
13
-
.sequoia-comments-container {
14
14
-
max-width: 100%;
15
15
-
}
16
16
-
17
17
-
.sequoia-loading,
18
18
-
.sequoia-error,
19
19
-
.sequoia-empty,
20
20
-
.sequoia-warning {
21
21
-
padding: 1rem;
22
22
-
border-radius: var(--sequoia-border-radius, 8px);
23
23
-
text-align: center;
24
24
-
}
25
25
-
26
26
-
.sequoia-loading {
27
27
-
background: var(--sequoia-bg-color, #ffffff);
28
28
-
border: 1px solid var(--sequoia-border-color, #e5e7eb);
29
29
-
color: var(--sequoia-secondary-color, #6b7280);
30
30
-
}
31
31
-
32
32
-
.sequoia-loading-spinner {
33
33
-
display: inline-block;
34
34
-
width: 1.25rem;
35
35
-
height: 1.25rem;
36
36
-
border: 2px solid var(--sequoia-border-color, #e5e7eb);
37
37
-
border-top-color: var(--sequoia-accent-color, #2563eb);
38
38
-
border-radius: 50%;
39
39
-
animation: sequoia-spin 0.8s linear infinite;
40
40
-
margin-right: 0.5rem;
41
41
-
vertical-align: middle;
42
42
-
}
43
43
-
44
44
-
@keyframes sequoia-spin {
45
45
-
to { transform: rotate(360deg); }
46
46
-
}
47
47
-
48
48
-
.sequoia-error {
49
49
-
background: #fef2f2;
50
50
-
border: 1px solid #fecaca;
51
51
-
color: #dc2626;
52
52
-
}
53
53
-
54
54
-
.sequoia-warning {
55
55
-
background: #fffbeb;
56
56
-
border: 1px solid #fde68a;
57
57
-
color: #d97706;
58
58
-
}
59
59
-
60
60
-
.sequoia-empty {
61
61
-
background: var(--sequoia-bg-color, #ffffff);
62
62
-
border: 1px solid var(--sequoia-border-color, #e5e7eb);
63
63
-
color: var(--sequoia-secondary-color, #6b7280);
64
64
-
}
65
65
-
66
66
-
.sequoia-comments-header {
67
67
-
display: flex;
68
68
-
justify-content: space-between;
69
69
-
align-items: center;
70
70
-
margin-bottom: 1rem;
71
71
-
padding-bottom: 0.75rem;
72
72
-
border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
73
73
-
}
74
74
-
75
75
-
.sequoia-comments-title {
76
76
-
font-size: 1.125rem;
77
77
-
font-weight: 600;
78
78
-
margin: 0;
79
79
-
}
80
80
-
81
81
-
.sequoia-reply-button {
82
82
-
display: inline-flex;
83
83
-
align-items: center;
84
84
-
gap: 0.375rem;
85
85
-
padding: 0.5rem 1rem;
86
86
-
background: var(--sequoia-accent-color, #2563eb);
87
87
-
color: #ffffff;
88
88
-
border: none;
89
89
-
border-radius: var(--sequoia-border-radius, 8px);
90
90
-
font-size: 0.875rem;
91
91
-
font-weight: 500;
92
92
-
cursor: pointer;
93
93
-
text-decoration: none;
94
94
-
transition: background-color 0.15s ease;
95
95
-
}
96
96
-
97
97
-
.sequoia-reply-button:hover {
98
98
-
background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
99
99
-
}
100
100
-
101
101
-
.sequoia-reply-button svg {
102
102
-
width: 1rem;
103
103
-
height: 1rem;
104
104
-
}
105
105
-
106
106
-
.sequoia-comments-list {
107
107
-
display: flex;
108
108
-
flex-direction: column;
109
109
-
gap: 0;
110
110
-
}
111
111
-
112
112
-
.sequoia-comment {
113
113
-
padding: 1rem;
114
114
-
background: var(--sequoia-bg-color, #ffffff);
115
115
-
border: 1px solid var(--sequoia-border-color, #e5e7eb);
116
116
-
border-radius: var(--sequoia-border-radius, 8px);
117
117
-
margin-bottom: 0.75rem;
118
118
-
}
119
119
-
120
120
-
.sequoia-comment-header {
121
121
-
display: flex;
122
122
-
align-items: center;
123
123
-
gap: 0.75rem;
124
124
-
margin-bottom: 0.5rem;
125
125
-
}
126
126
-
127
127
-
.sequoia-comment-avatar {
128
128
-
width: 2.5rem;
129
129
-
height: 2.5rem;
130
130
-
border-radius: 50%;
131
131
-
background: var(--sequoia-border-color, #e5e7eb);
132
132
-
object-fit: cover;
133
133
-
flex-shrink: 0;
134
134
-
}
135
135
-
136
136
-
.sequoia-comment-avatar-placeholder {
137
137
-
width: 2.5rem;
138
138
-
height: 2.5rem;
139
139
-
border-radius: 50%;
140
140
-
background: var(--sequoia-border-color, #e5e7eb);
141
141
-
display: flex;
142
142
-
align-items: center;
143
143
-
justify-content: center;
144
144
-
flex-shrink: 0;
145
145
-
color: var(--sequoia-secondary-color, #6b7280);
146
146
-
font-weight: 600;
147
147
-
font-size: 1rem;
148
148
-
}
149
149
-
150
150
-
.sequoia-comment-meta {
151
151
-
display: flex;
152
152
-
flex-direction: column;
153
153
-
min-width: 0;
154
154
-
}
155
155
-
156
156
-
.sequoia-comment-author {
157
157
-
font-weight: 600;
158
158
-
color: var(--sequoia-fg-color, #1f2937);
159
159
-
text-decoration: none;
160
160
-
overflow: hidden;
161
161
-
text-overflow: ellipsis;
162
162
-
white-space: nowrap;
163
163
-
}
164
164
-
165
165
-
.sequoia-comment-author:hover {
166
166
-
color: var(--sequoia-accent-color, #2563eb);
167
167
-
}
168
168
-
169
169
-
.sequoia-comment-handle {
170
170
-
font-size: 0.875rem;
171
171
-
color: var(--sequoia-secondary-color, #6b7280);
172
172
-
overflow: hidden;
173
173
-
text-overflow: ellipsis;
174
174
-
white-space: nowrap;
175
175
-
}
176
176
-
177
177
-
.sequoia-comment-time {
178
178
-
font-size: 0.75rem;
179
179
-
color: var(--sequoia-secondary-color, #6b7280);
180
180
-
margin-left: auto;
181
181
-
flex-shrink: 0;
182
182
-
}
183
183
-
184
184
-
.sequoia-comment-text {
185
185
-
margin: 0;
186
186
-
white-space: pre-wrap;
187
187
-
word-wrap: break-word;
188
188
-
}
189
189
-
190
190
-
.sequoia-comment-text a {
191
191
-
color: var(--sequoia-accent-color, #2563eb);
192
192
-
text-decoration: none;
193
193
-
}
194
194
-
195
195
-
.sequoia-comment-text a:hover {
196
196
-
text-decoration: underline;
197
197
-
}
198
198
-
199
199
-
.sequoia-comment-replies {
200
200
-
margin-top: 0.75rem;
201
201
-
margin-left: 1.5rem;
202
202
-
padding-left: 1rem;
203
203
-
border-left: 2px solid var(--sequoia-border-color, #e5e7eb);
204
204
-
}
205
205
-
206
206
-
.sequoia-comment-replies .sequoia-comment {
207
207
-
margin-bottom: 0.5rem;
208
208
-
}
209
209
-
210
210
-
.sequoia-comment-replies .sequoia-comment:last-child {
211
211
-
margin-bottom: 0;
212
212
-
}
213
213
-
214
214
-
.sequoia-bsky-logo {
215
215
-
width: 1rem;
216
216
-
height: 1rem;
217
217
-
}
218
218
-
`;
-127
packages/ui/src/components/sequoia-comments/utils.ts
···
1
1
-
/**
2
2
-
* Format a relative time string (e.g., "2 hours ago")
3
3
-
*/
4
4
-
export function formatRelativeTime(dateString: string): string {
5
5
-
const date = new Date(dateString);
6
6
-
const now = new Date();
7
7
-
const diffMs = now.getTime() - date.getTime();
8
8
-
const diffSeconds = Math.floor(diffMs / 1000);
9
9
-
const diffMinutes = Math.floor(diffSeconds / 60);
10
10
-
const diffHours = Math.floor(diffMinutes / 60);
11
11
-
const diffDays = Math.floor(diffHours / 24);
12
12
-
const diffWeeks = Math.floor(diffDays / 7);
13
13
-
const diffMonths = Math.floor(diffDays / 30);
14
14
-
const diffYears = Math.floor(diffDays / 365);
15
15
-
16
16
-
if (diffSeconds < 60) {
17
17
-
return "just now";
18
18
-
}
19
19
-
if (diffMinutes < 60) {
20
20
-
return `${diffMinutes}m ago`;
21
21
-
}
22
22
-
if (diffHours < 24) {
23
23
-
return `${diffHours}h ago`;
24
24
-
}
25
25
-
if (diffDays < 7) {
26
26
-
return `${diffDays}d ago`;
27
27
-
}
28
28
-
if (diffWeeks < 4) {
29
29
-
return `${diffWeeks}w ago`;
30
30
-
}
31
31
-
if (diffMonths < 12) {
32
32
-
return `${diffMonths}mo ago`;
33
33
-
}
34
34
-
return `${diffYears}y ago`;
35
35
-
}
36
36
-
37
37
-
/**
38
38
-
* Escape HTML special characters
39
39
-
*/
40
40
-
export function escapeHtml(text: string): string {
41
41
-
const div = document.createElement("div");
42
42
-
div.textContent = text;
43
43
-
return div.innerHTML;
44
44
-
}
45
45
-
46
46
-
/**
47
47
-
* Convert post text with facets to HTML
48
48
-
*/
49
49
-
export function renderTextWithFacets(
50
50
-
text: string,
51
51
-
facets?: Array<{
52
52
-
index: { byteStart: number; byteEnd: number };
53
53
-
features: Array<
54
54
-
| { $type: "app.bsky.richtext.facet#link"; uri: string }
55
55
-
| { $type: "app.bsky.richtext.facet#mention"; did: string }
56
56
-
| { $type: "app.bsky.richtext.facet#tag"; tag: string }
57
57
-
>;
58
58
-
}>,
59
59
-
): string {
60
60
-
if (!facets || facets.length === 0) {
61
61
-
return escapeHtml(text);
62
62
-
}
63
63
-
64
64
-
// Convert text to bytes for proper indexing
65
65
-
const encoder = new TextEncoder();
66
66
-
const decoder = new TextDecoder();
67
67
-
const textBytes = encoder.encode(text);
68
68
-
69
69
-
// Sort facets by start index
70
70
-
const sortedFacets = [...facets].sort(
71
71
-
(a, b) => a.index.byteStart - b.index.byteStart,
72
72
-
);
73
73
-
74
74
-
let result = "";
75
75
-
let lastEnd = 0;
76
76
-
77
77
-
for (const facet of sortedFacets) {
78
78
-
const { byteStart, byteEnd } = facet.index;
79
79
-
80
80
-
// Add text before this facet
81
81
-
if (byteStart > lastEnd) {
82
82
-
const beforeBytes = textBytes.slice(lastEnd, byteStart);
83
83
-
result += escapeHtml(decoder.decode(beforeBytes));
84
84
-
}
85
85
-
86
86
-
// Get the facet text
87
87
-
const facetBytes = textBytes.slice(byteStart, byteEnd);
88
88
-
const facetText = decoder.decode(facetBytes);
89
89
-
90
90
-
// Find the first renderable feature
91
91
-
const feature = facet.features[0];
92
92
-
if (feature) {
93
93
-
if (feature.$type === "app.bsky.richtext.facet#link") {
94
94
-
result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
95
95
-
} else if (feature.$type === "app.bsky.richtext.facet#mention") {
96
96
-
result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
97
97
-
} else if (feature.$type === "app.bsky.richtext.facet#tag") {
98
98
-
result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
99
99
-
} else {
100
100
-
result += escapeHtml(facetText);
101
101
-
}
102
102
-
} else {
103
103
-
result += escapeHtml(facetText);
104
104
-
}
105
105
-
106
106
-
lastEnd = byteEnd;
107
107
-
}
108
108
-
109
109
-
// Add remaining text
110
110
-
if (lastEnd < textBytes.length) {
111
111
-
const remainingBytes = textBytes.slice(lastEnd);
112
112
-
result += escapeHtml(decoder.decode(remainingBytes));
113
113
-
}
114
114
-
115
115
-
return result;
116
116
-
}
117
117
-
118
118
-
/**
119
119
-
* Get initials from a name for avatar placeholder
120
120
-
*/
121
121
-
export function getInitials(name: string): string {
122
122
-
const parts = name.trim().split(/\s+/);
123
123
-
if (parts.length >= 2) {
124
124
-
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
125
125
-
}
126
126
-
return name.substring(0, 2).toUpperCase();
127
127
-
}
-30
packages/ui/src/index.ts
···
1
1
-
// Components
2
2
-
export { SequoiaComments } from "./components/sequoia-comments";
3
3
-
4
4
-
// AT Protocol client utilities
5
5
-
export {
6
6
-
parseAtUri,
7
7
-
resolvePDS,
8
8
-
getRecord,
9
9
-
getDocument,
10
10
-
getPostThread,
11
11
-
buildBskyAppUrl,
12
12
-
} from "./lib/atproto-client";
13
13
-
14
14
-
// Types
15
15
-
export type {
16
16
-
StrongRef,
17
17
-
ProfileViewBasic,
18
18
-
PostRecord,
19
19
-
PostView,
20
20
-
ThreadViewPost,
21
21
-
BlockedPost,
22
22
-
NotFoundPost,
23
23
-
DocumentRecord,
24
24
-
} from "./types/bluesky";
25
25
-
26
26
-
export { isThreadViewPost } from "./types/bluesky";
27
27
-
28
28
-
// Styles and theming
29
29
-
export type { SequoiaTheme, SequoiaCSSVar } from "./types/styles";
30
30
-
export { SEQUOIA_CSS_VARS } from "./types/styles";
-144
packages/ui/src/lib/atproto-client.ts
···
1
1
-
import type {
2
2
-
DIDDocument,
3
3
-
DocumentRecord,
4
4
-
GetPostThreadResponse,
5
5
-
GetRecordResponse,
6
6
-
ThreadViewPost,
7
7
-
} from "../types/bluesky";
8
8
-
9
9
-
/**
10
10
-
* Parse an AT URI into its components
11
11
-
* Format: at://did/collection/rkey
12
12
-
*/
13
13
-
export function parseAtUri(
14
14
-
atUri: string,
15
15
-
): { did: string; collection: string; rkey: string } | null {
16
16
-
const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
17
17
-
if (!match) return null;
18
18
-
return {
19
19
-
did: match[1]!,
20
20
-
collection: match[2]!,
21
21
-
rkey: match[3]!,
22
22
-
};
23
23
-
}
24
24
-
25
25
-
/**
26
26
-
* Resolve a DID to its PDS URL
27
27
-
* Supports did:plc and did:web methods
28
28
-
*/
29
29
-
export async function resolvePDS(did: string): Promise<string> {
30
30
-
let pdsUrl: string | undefined;
31
31
-
32
32
-
if (did.startsWith("did:plc:")) {
33
33
-
// Fetch DID document from plc.directory
34
34
-
const didDocUrl = `https://plc.directory/${did}`;
35
35
-
const didDocResponse = await fetch(didDocUrl);
36
36
-
if (!didDocResponse.ok) {
37
37
-
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
38
38
-
}
39
39
-
const didDoc: DIDDocument = await didDocResponse.json();
40
40
-
41
41
-
// Find the PDS service endpoint
42
42
-
const pdsService = didDoc.service?.find(
43
43
-
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
44
44
-
);
45
45
-
pdsUrl = pdsService?.serviceEndpoint;
46
46
-
} else if (did.startsWith("did:web:")) {
47
47
-
// For did:web, fetch the DID document from the domain
48
48
-
const domain = did.replace("did:web:", "");
49
49
-
const didDocUrl = `https://${domain}/.well-known/did.json`;
50
50
-
const didDocResponse = await fetch(didDocUrl);
51
51
-
if (!didDocResponse.ok) {
52
52
-
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
53
53
-
}
54
54
-
const didDoc: DIDDocument = await didDocResponse.json();
55
55
-
56
56
-
const pdsService = didDoc.service?.find(
57
57
-
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
58
58
-
);
59
59
-
pdsUrl = pdsService?.serviceEndpoint;
60
60
-
} else {
61
61
-
throw new Error(`Unsupported DID method: ${did}`);
62
62
-
}
63
63
-
64
64
-
if (!pdsUrl) {
65
65
-
throw new Error("Could not find PDS URL for user");
66
66
-
}
67
67
-
68
68
-
return pdsUrl;
69
69
-
}
70
70
-
71
71
-
/**
72
72
-
* Fetch a record from a PDS using the public API
73
73
-
*/
74
74
-
export async function getRecord<T>(
75
75
-
did: string,
76
76
-
collection: string,
77
77
-
rkey: string,
78
78
-
): Promise<T> {
79
79
-
const pdsUrl = await resolvePDS(did);
80
80
-
81
81
-
const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
82
82
-
url.searchParams.set("repo", did);
83
83
-
url.searchParams.set("collection", collection);
84
84
-
url.searchParams.set("rkey", rkey);
85
85
-
86
86
-
const response = await fetch(url.toString());
87
87
-
if (!response.ok) {
88
88
-
throw new Error(`Failed to fetch record: ${response.status}`);
89
89
-
}
90
90
-
91
91
-
const data: GetRecordResponse<T> = await response.json();
92
92
-
return data.value;
93
93
-
}
94
94
-
95
95
-
/**
96
96
-
* Fetch a document record from its AT URI
97
97
-
*/
98
98
-
export async function getDocument(atUri: string): Promise<DocumentRecord> {
99
99
-
const parsed = parseAtUri(atUri);
100
100
-
if (!parsed) {
101
101
-
throw new Error(`Invalid AT URI: ${atUri}`);
102
102
-
}
103
103
-
104
104
-
return getRecord<DocumentRecord>(parsed.did, parsed.collection, parsed.rkey);
105
105
-
}
106
106
-
107
107
-
/**
108
108
-
* Fetch a post thread from the public Bluesky API
109
109
-
*/
110
110
-
export async function getPostThread(
111
111
-
postUri: string,
112
112
-
depth = 6,
113
113
-
): Promise<ThreadViewPost> {
114
114
-
const url = new URL(
115
115
-
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
116
116
-
);
117
117
-
url.searchParams.set("uri", postUri);
118
118
-
url.searchParams.set("depth", depth.toString());
119
119
-
120
120
-
const response = await fetch(url.toString());
121
121
-
if (!response.ok) {
122
122
-
throw new Error(`Failed to fetch post thread: ${response.status}`);
123
123
-
}
124
124
-
125
125
-
const data: GetPostThreadResponse = await response.json();
126
126
-
127
127
-
if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
128
128
-
throw new Error("Post not found or blocked");
129
129
-
}
130
130
-
131
131
-
return data.thread as ThreadViewPost;
132
132
-
}
133
133
-
134
134
-
/**
135
135
-
* Build a Bluesky app URL for a post
136
136
-
*/
137
137
-
export function buildBskyAppUrl(postUri: string): string {
138
138
-
const parsed = parseAtUri(postUri);
139
139
-
if (!parsed) {
140
140
-
throw new Error(`Invalid post URI: ${postUri}`);
141
141
-
}
142
142
-
143
143
-
return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
144
144
-
}
-133
packages/ui/src/types/bluesky.ts
···
1
1
-
/**
2
2
-
* Strong reference for AT Protocol records (com.atproto.repo.strongRef)
3
3
-
*/
4
4
-
export interface StrongRef {
5
5
-
uri: string; // at:// URI format
6
6
-
cid: string; // Content ID
7
7
-
}
8
8
-
9
9
-
/**
10
10
-
* Basic profile view from Bluesky API
11
11
-
*/
12
12
-
export interface ProfileViewBasic {
13
13
-
did: string;
14
14
-
handle: string;
15
15
-
displayName?: string;
16
16
-
avatar?: string;
17
17
-
}
18
18
-
19
19
-
/**
20
20
-
* Post record content from app.bsky.feed.post
21
21
-
*/
22
22
-
export interface PostRecord {
23
23
-
$type: "app.bsky.feed.post";
24
24
-
text: string;
25
25
-
createdAt: string;
26
26
-
reply?: {
27
27
-
root: StrongRef;
28
28
-
parent: StrongRef;
29
29
-
};
30
30
-
facets?: Array<{
31
31
-
index: { byteStart: number; byteEnd: number };
32
32
-
features: Array<
33
33
-
| { $type: "app.bsky.richtext.facet#link"; uri: string }
34
34
-
| { $type: "app.bsky.richtext.facet#mention"; did: string }
35
35
-
| { $type: "app.bsky.richtext.facet#tag"; tag: string }
36
36
-
>;
37
37
-
}>;
38
38
-
}
39
39
-
40
40
-
/**
41
41
-
* Post view from Bluesky API
42
42
-
*/
43
43
-
export interface PostView {
44
44
-
uri: string;
45
45
-
cid: string;
46
46
-
author: ProfileViewBasic;
47
47
-
record: PostRecord;
48
48
-
replyCount?: number;
49
49
-
repostCount?: number;
50
50
-
likeCount?: number;
51
51
-
indexedAt: string;
52
52
-
}
53
53
-
54
54
-
/**
55
55
-
* Thread view post from app.bsky.feed.getPostThread
56
56
-
*/
57
57
-
export interface ThreadViewPost {
58
58
-
$type: "app.bsky.feed.defs#threadViewPost";
59
59
-
post: PostView;
60
60
-
parent?: ThreadViewPost | BlockedPost | NotFoundPost;
61
61
-
replies?: Array<ThreadViewPost | BlockedPost | NotFoundPost>;
62
62
-
}
63
63
-
64
64
-
/**
65
65
-
* Blocked post placeholder
66
66
-
*/
67
67
-
export interface BlockedPost {
68
68
-
$type: "app.bsky.feed.defs#blockedPost";
69
69
-
uri: string;
70
70
-
blocked: true;
71
71
-
}
72
72
-
73
73
-
/**
74
74
-
* Not found post placeholder
75
75
-
*/
76
76
-
export interface NotFoundPost {
77
77
-
$type: "app.bsky.feed.defs#notFoundPost";
78
78
-
uri: string;
79
79
-
notFound: true;
80
80
-
}
81
81
-
82
82
-
/**
83
83
-
* Type guard for ThreadViewPost
84
84
-
*/
85
85
-
export function isThreadViewPost(
86
86
-
post: ThreadViewPost | BlockedPost | NotFoundPost | undefined,
87
87
-
): post is ThreadViewPost {
88
88
-
return post?.$type === "app.bsky.feed.defs#threadViewPost";
89
89
-
}
90
90
-
91
91
-
/**
92
92
-
* Document record from site.standard.document
93
93
-
*/
94
94
-
export interface DocumentRecord {
95
95
-
$type: "site.standard.document";
96
96
-
title: string;
97
97
-
site: string;
98
98
-
path: string;
99
99
-
textContent: string;
100
100
-
publishedAt: string;
101
101
-
canonicalUrl?: string;
102
102
-
description?: string;
103
103
-
tags?: string[];
104
104
-
bskyPostRef?: StrongRef;
105
105
-
}
106
106
-
107
107
-
/**
108
108
-
* DID document structure
109
109
-
*/
110
110
-
export interface DIDDocument {
111
111
-
id: string;
112
112
-
service?: Array<{
113
113
-
id: string;
114
114
-
type: string;
115
115
-
serviceEndpoint: string;
116
116
-
}>;
117
117
-
}
118
118
-
119
119
-
/**
120
120
-
* Response from com.atproto.repo.getRecord
121
121
-
*/
122
122
-
export interface GetRecordResponse<T> {
123
123
-
uri: string;
124
124
-
cid: string;
125
125
-
value: T;
126
126
-
}
127
127
-
128
128
-
/**
129
129
-
* Response from app.bsky.feed.getPostThread
130
130
-
*/
131
131
-
export interface GetPostThreadResponse {
132
132
-
thread: ThreadViewPost | BlockedPost | NotFoundPost;
133
133
-
}
-40
packages/ui/src/types/styles.ts
···
1
1
-
/**
2
2
-
* CSS custom properties for theming SequoiaComments
3
3
-
*
4
4
-
* @example
5
5
-
* ```css
6
6
-
* :root {
7
7
-
* --sequoia-fg-color: #1f2937;
8
8
-
* --sequoia-bg-color: #ffffff;
9
9
-
* --sequoia-accent-color: #2563eb;
10
10
-
* }
11
11
-
* ```
12
12
-
*/
13
13
-
export interface SequoiaTheme {
14
14
-
/** Primary text color (default: #1f2937) */
15
15
-
"--sequoia-fg-color"?: string;
16
16
-
/** Background color for comments and containers (default: #ffffff) */
17
17
-
"--sequoia-bg-color"?: string;
18
18
-
/** Border color for separators and outlines (default: #e5e7eb) */
19
19
-
"--sequoia-border-color"?: string;
20
20
-
/** Secondary/muted text color (default: #6b7280) */
21
21
-
"--sequoia-secondary-color"?: string;
22
22
-
/** Accent color for links and buttons (default: #2563eb) */
23
23
-
"--sequoia-accent-color"?: string;
24
24
-
/** Border radius for cards and buttons (default: 8px) */
25
25
-
"--sequoia-border-radius"?: string;
26
26
-
}
27
27
-
28
28
-
/**
29
29
-
* All available CSS custom property names
30
30
-
*/
31
31
-
export const SEQUOIA_CSS_VARS = [
32
32
-
"--sequoia-fg-color",
33
33
-
"--sequoia-bg-color",
34
34
-
"--sequoia-border-color",
35
35
-
"--sequoia-secondary-color",
36
36
-
"--sequoia-accent-color",
37
37
-
"--sequoia-border-radius",
38
38
-
] as const;
39
39
-
40
40
-
export type SequoiaCSSVar = (typeof SEQUOIA_CSS_VARS)[number];
-43
packages/ui/test.html
···
1
1
-
<!DOCTYPE html>
2
2
-
<html lang="en">
3
3
-
<head>
4
4
-
<meta charset="UTF-8">
5
5
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
-
<title>Sequoia Comments Test</title>
7
7
-
<!-- Link to a published document - replace with your own AT URI -->
8
8
-
<link rel="site.standard.document" href="at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3me3hbjtw2v2v">
9
9
-
<style>
10
10
-
body {
11
11
-
font-family: system-ui, -apple-system, sans-serif;
12
12
-
max-width: 800px;
13
13
-
margin: 2rem auto;
14
14
-
padding: 0 1rem;
15
15
-
line-height: 1.6;
16
16
-
}
17
17
-
h1 {
18
18
-
margin-bottom: 2rem;
19
19
-
}
20
20
-
/* Custom styling example */
21
21
-
sequoia-comments {
22
22
-
--sequoia-accent-color: #0070f3;
23
23
-
--sequoia-border-radius: 12px;
24
24
-
}
25
25
-
.dark-theme sequoia-comments {
26
26
-
--sequoia-bg-color: #1a1a1a;
27
27
-
--sequoia-fg-color: #ffffff;
28
28
-
--sequoia-border-color: #333;
29
29
-
--sequoia-secondary-color: #888;
30
30
-
}
31
31
-
</style>
32
32
-
</head>
33
33
-
<body>
34
34
-
<h1>Blog Post Title</h1>
35
35
-
<p>This is a test page for the sequoia-comments web component.</p>
36
36
-
<p>The component will look for a <code><link rel="site.standard.document"></code> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.</p>
37
37
-
38
38
-
<h2>Comments</h2>
39
39
-
<sequoia-comments></sequoia-comments>
40
40
-
41
41
-
<script src="./dist/sequoia-comments.iife.js"></script>
42
42
-
</body>
43
43
-
</html>
-17
packages/ui/tsconfig.json
···
1
1
-
{
2
2
-
"compilerOptions": {
3
3
-
"target": "ES2022",
4
4
-
"module": "ESNext",
5
5
-
"moduleResolution": "bundler",
6
6
-
"lib": ["ES2022", "DOM", "DOM.Iterable"],
7
7
-
"strict": true,
8
8
-
"esModuleInterop": true,
9
9
-
"skipLibCheck": true,
10
10
-
"declaration": true,
11
11
-
"declarationMap": true,
12
12
-
"outDir": "./dist",
13
13
-
"rootDir": "./src"
14
14
-
},
15
15
-
"include": ["src/**/*"],
16
16
-
"exclude": ["node_modules", "dist"]
17
17
-
}