tangled
alpha
login
or
join now
heaths.dev
/
sequoia
forked from
stevedylan.dev/sequoia
0
fork
atom
A CLI for publishing standard.site documents to ATProto
0
fork
atom
overview
issues
pulls
pipelines
[WIP] feat: Add subscription component
Resolves #16
Heath Stewart
1 month ago
9bdabf99
f6d1e094
+422
-10
2 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
add.ts
components
sequoia-subscribe.js
+21
-10
packages/cli/src/commands/add.ts
···
14
14
15
15
const DEFAULT_COMPONENTS_PATH = "src/components";
16
16
17
17
-
const AVAILABLE_COMPONENTS = ["sequoia-comments"];
17
17
+
const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [
18
18
+
{
19
19
+
name: "sequoia-comments",
20
20
+
notes:
21
21
+
`The component will automatically read the document URI from:\n` +
22
22
+
`<link rel="site.standard.document" href="at://...">`,
23
23
+
},
24
24
+
{
25
25
+
name: "sequoia-subscribe",
26
26
+
},
27
27
+
];
18
28
19
29
export const addCommand = command({
20
30
name: "add",
···
30
40
intro("Add Sequoia Component");
31
41
32
42
// Validate component name
33
33
-
if (!AVAILABLE_COMPONENTS.includes(componentName)) {
43
43
+
const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName);
44
44
+
if (!component) {
34
45
log.error(`Component '${componentName}' not found`);
35
46
log.info("Available components:");
36
47
for (const comp of AVAILABLE_COMPONENTS) {
37
37
-
log.info(` - ${comp}`);
48
48
+
log.info(` - ${comp.name}`);
38
49
}
39
50
process.exit(1);
40
51
}
···
143
154
}
144
155
145
156
// Show usage instructions
146
146
-
note(
157
157
+
let notes =
147
158
`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
-
);
159
159
+
`<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` +
160
160
+
`<${componentName}></${componentName}>\n`;
161
161
+
if (component.notes) {
162
162
+
notes += `\n${component.notes}`;
163
163
+
}
164
164
+
note(notes, "Usage");
154
165
155
166
outro(`${componentName} added successfully!`);
156
167
},
+401
packages/cli/src/components/sequoia-subscribe.js
···
1
1
+
/**
2
2
+
* Sequoia Subscribe - A Bluesky-powered subscribe component
3
3
+
*
4
4
+
* A self-contained Web Component that lets users subscribe to a publication
5
5
+
* via the AT Protocol by creating a site.standard.graph.subscription record.
6
6
+
*
7
7
+
* Usage:
8
8
+
* <sequoia-subscribe></sequoia-subscribe>
9
9
+
*
10
10
+
* The component resolves the publication AT URI from the host site's
11
11
+
* /.well-known/site.standard.publication endpoint.
12
12
+
*
13
13
+
* Attributes:
14
14
+
* - publication-uri: Override the publication AT URI (optional)
15
15
+
* - label: Button label text (default: "Subscribe on Bluesky")
16
16
+
*
17
17
+
* CSS Custom Properties:
18
18
+
* - --sequoia-fg-color: Text color (default: #1f2937)
19
19
+
* - --sequoia-bg-color: Background color (default: #ffffff)
20
20
+
* - --sequoia-border-color: Border color (default: #e5e7eb)
21
21
+
* - --sequoia-accent-color: Accent/button color (default: #2563eb)
22
22
+
* - --sequoia-secondary-color: Secondary text color (default: #6b7280)
23
23
+
* - --sequoia-border-radius: Border radius (default: 8px)
24
24
+
*
25
25
+
* Events:
26
26
+
* - sequoia-subscribed: Fired when the subscription is created successfully.
27
27
+
* detail: { publicationUri: string, recordUri: string }
28
28
+
* - sequoia-subscribe-error: Fired when the subscription fails.
29
29
+
* detail: { message: string }
30
30
+
*/
31
31
+
32
32
+
// ============================================================================
33
33
+
// Styles
34
34
+
// ============================================================================
35
35
+
36
36
+
const styles = `
37
37
+
:host {
38
38
+
display: inline-block;
39
39
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
40
40
+
color: var(--sequoia-fg-color, #1f2937);
41
41
+
line-height: 1.5;
42
42
+
}
43
43
+
44
44
+
* {
45
45
+
box-sizing: border-box;
46
46
+
}
47
47
+
48
48
+
.sequoia-subscribe-button {
49
49
+
display: inline-flex;
50
50
+
align-items: center;
51
51
+
gap: 0.375rem;
52
52
+
padding: 0.5rem 1rem;
53
53
+
background: var(--sequoia-accent-color, #2563eb);
54
54
+
color: #ffffff;
55
55
+
border: none;
56
56
+
border-radius: var(--sequoia-border-radius, 8px);
57
57
+
font-size: 0.875rem;
58
58
+
font-weight: 500;
59
59
+
cursor: pointer;
60
60
+
text-decoration: none;
61
61
+
transition: background-color 0.15s ease;
62
62
+
font-family: inherit;
63
63
+
}
64
64
+
65
65
+
.sequoia-subscribe-button:hover:not(:disabled) {
66
66
+
background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
67
67
+
}
68
68
+
69
69
+
.sequoia-subscribe-button:disabled {
70
70
+
opacity: 0.6;
71
71
+
cursor: not-allowed;
72
72
+
}
73
73
+
74
74
+
.sequoia-subscribe-button svg {
75
75
+
width: 1rem;
76
76
+
height: 1rem;
77
77
+
flex-shrink: 0;
78
78
+
}
79
79
+
80
80
+
.sequoia-subscribe-button--success {
81
81
+
background: #16a34a;
82
82
+
}
83
83
+
84
84
+
.sequoia-subscribe-button--success:hover:not(:disabled) {
85
85
+
background: color-mix(in srgb, #16a34a 85%, black);
86
86
+
}
87
87
+
88
88
+
.sequoia-loading-spinner {
89
89
+
display: inline-block;
90
90
+
width: 1rem;
91
91
+
height: 1rem;
92
92
+
border: 2px solid rgba(255, 255, 255, 0.4);
93
93
+
border-top-color: #ffffff;
94
94
+
border-radius: 50%;
95
95
+
animation: sequoia-spin 0.8s linear infinite;
96
96
+
flex-shrink: 0;
97
97
+
}
98
98
+
99
99
+
@keyframes sequoia-spin {
100
100
+
to { transform: rotate(360deg); }
101
101
+
}
102
102
+
103
103
+
.sequoia-error-message {
104
104
+
display: inline-block;
105
105
+
font-size: 0.8125rem;
106
106
+
color: #dc2626;
107
107
+
margin-top: 0.375rem;
108
108
+
}
109
109
+
`;
110
110
+
111
111
+
// ============================================================================
112
112
+
// Icons
113
113
+
// ============================================================================
114
114
+
115
115
+
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
116
116
+
<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"/>
117
117
+
</svg>`;
118
118
+
119
119
+
const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
120
120
+
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
121
121
+
</svg>`;
122
122
+
123
123
+
// ============================================================================
124
124
+
// AT Protocol Functions
125
125
+
// ============================================================================
126
126
+
127
127
+
/**
128
128
+
* Resolve a DID to its PDS URL.
129
129
+
* Supports did:plc and did:web methods.
130
130
+
* @param {string} did - Decentralized Identifier
131
131
+
* @returns {Promise<string>} PDS URL
132
132
+
*/
133
133
+
async function resolvePDS(did) {
134
134
+
let pdsUrl;
135
135
+
136
136
+
if (did.startsWith("did:plc:")) {
137
137
+
const didDocUrl = `https://plc.directory/${did}`;
138
138
+
const didDocResponse = await fetch(didDocUrl);
139
139
+
if (!didDocResponse.ok) {
140
140
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
141
141
+
}
142
142
+
const didDoc = await didDocResponse.json();
143
143
+
144
144
+
const pdsService = didDoc.service?.find(
145
145
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
146
146
+
);
147
147
+
pdsUrl = pdsService?.serviceEndpoint;
148
148
+
} else if (did.startsWith("did:web:")) {
149
149
+
const domain = did.replace("did:web:", "");
150
150
+
const didDocUrl = `https://${domain}/.well-known/did.json`;
151
151
+
const didDocResponse = await fetch(didDocUrl);
152
152
+
if (!didDocResponse.ok) {
153
153
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
154
154
+
}
155
155
+
const didDoc = await didDocResponse.json();
156
156
+
157
157
+
const pdsService = didDoc.service?.find(
158
158
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
159
159
+
);
160
160
+
pdsUrl = pdsService?.serviceEndpoint;
161
161
+
} else {
162
162
+
throw new Error(`Unsupported DID method: ${did}`);
163
163
+
}
164
164
+
165
165
+
if (!pdsUrl) {
166
166
+
throw new Error("Could not find PDS URL for user");
167
167
+
}
168
168
+
169
169
+
return pdsUrl;
170
170
+
}
171
171
+
172
172
+
/**
173
173
+
* Create a site.standard.graph.subscription record in the subscriber's PDS.
174
174
+
* @param {string} did - DID of the subscriber
175
175
+
* @param {string} accessToken - AT Protocol access token
176
176
+
* @param {string} publicationUri - AT URI of the publication to subscribe to
177
177
+
* @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID
178
178
+
*/
179
179
+
async function createRecord(did, accessToken, publicationUri) {
180
180
+
const pdsUrl = await resolvePDS(did);
181
181
+
182
182
+
const collection = "site.standard.graph.subscription";
183
183
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`;
184
184
+
const response = await fetch(url, {
185
185
+
method: "POST",
186
186
+
headers: {
187
187
+
"Content-Type": "application/json",
188
188
+
Authorization: `Bearer ${accessToken}`,
189
189
+
},
190
190
+
body: JSON.stringify({
191
191
+
repo: did,
192
192
+
collection,
193
193
+
record: {
194
194
+
$type: "site.standard.graph.subscription",
195
195
+
publication: publicationUri,
196
196
+
},
197
197
+
}),
198
198
+
});
199
199
+
200
200
+
if (!response.ok) {
201
201
+
const body = await response.json().catch(() => ({}));
202
202
+
const message = body?.message ?? body?.error ?? `HTTP ${response.status}`;
203
203
+
throw new Error(`Failed to create record: ${message}`);
204
204
+
}
205
205
+
206
206
+
const data = await response.json();
207
207
+
return { uri: data.uri, cid: data.cid };
208
208
+
}
209
209
+
210
210
+
/**
211
211
+
* Fetch the publication AT URI from the host site's well-known endpoint.
212
212
+
* @param {string} [origin] - Origin to fetch from (defaults to current page origin)
213
213
+
* @returns {Promise<string>} Publication AT URI
214
214
+
*/
215
215
+
async function fetchPublicationUri(origin) {
216
216
+
const base = origin ?? window.location.origin;
217
217
+
const url = `${base}/.well-known/site.standard.publication`;
218
218
+
const response = await fetch(url);
219
219
+
if (!response.ok) {
220
220
+
throw new Error(
221
221
+
`Could not fetch publication URI: ${response.status}`,
222
222
+
);
223
223
+
}
224
224
+
225
225
+
// Accept either plain text (the AT URI itself) or JSON with a `uri` field.
226
226
+
const contentType = response.headers.get("content-type") ?? "";
227
227
+
if (contentType.includes("application/json")) {
228
228
+
const data = await response.json();
229
229
+
const uri = data?.uri ?? data?.atUri ?? data?.publication;
230
230
+
if (!uri) {
231
231
+
throw new Error("Publication response did not contain a URI");
232
232
+
}
233
233
+
return uri;
234
234
+
}
235
235
+
236
236
+
const text = (await response.text()).trim();
237
237
+
if (!text.startsWith("at://")) {
238
238
+
throw new Error(`Unexpected publication URI format: ${text}`);
239
239
+
}
240
240
+
return text;
241
241
+
}
242
242
+
243
243
+
// ============================================================================
244
244
+
// Web Component
245
245
+
// ============================================================================
246
246
+
247
247
+
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
248
248
+
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
249
249
+
250
250
+
class SequoiaSubscribe extends BaseElement {
251
251
+
constructor() {
252
252
+
super();
253
253
+
const shadow = this.attachShadow({ mode: "open" });
254
254
+
255
255
+
const styleTag = document.createElement("style");
256
256
+
styleTag.innerText = styles;
257
257
+
shadow.appendChild(styleTag);
258
258
+
259
259
+
const wrapper = document.createElement("div");
260
260
+
shadow.appendChild(wrapper);
261
261
+
wrapper.part = "container";
262
262
+
263
263
+
this.wrapper = wrapper;
264
264
+
this.state = { type: "idle" };
265
265
+
this.render();
266
266
+
}
267
267
+
268
268
+
static get observedAttributes() {
269
269
+
return ["publication-uri", "label"];
270
270
+
}
271
271
+
272
272
+
attributeChangedCallback() {
273
273
+
// Reset to idle if attributes change after an error or success
274
274
+
if (
275
275
+
this.state.type === "error" ||
276
276
+
this.state.type === "subscribed"
277
277
+
) {
278
278
+
this.state = { type: "idle" };
279
279
+
}
280
280
+
this.render();
281
281
+
}
282
282
+
283
283
+
get publicationUri() {
284
284
+
return this.getAttribute("publication-uri") ?? null;
285
285
+
}
286
286
+
287
287
+
get label() {
288
288
+
return this.getAttribute("label") ?? "Subscribe on Bluesky";
289
289
+
}
290
290
+
291
291
+
async handleClick() {
292
292
+
if (this.state.type === "loading" || this.state.type === "subscribed") {
293
293
+
return;
294
294
+
}
295
295
+
296
296
+
this.state = { type: "loading" };
297
297
+
this.render();
298
298
+
299
299
+
try {
300
300
+
// Resolve the publication AT URI
301
301
+
const publicationUri =
302
302
+
this.publicationUri ?? (await fetchPublicationUri());
303
303
+
304
304
+
// TODO: resolve authenticated DID and access token before calling createRecord
305
305
+
const { uri: recordUri } = await createRecord(
306
306
+
/* did */ undefined,
307
307
+
/* accessToken */ undefined,
308
308
+
publicationUri,
309
309
+
);
310
310
+
311
311
+
this.state = { type: "subscribed", recordUri, publicationUri };
312
312
+
this.render();
313
313
+
314
314
+
this.dispatchEvent(
315
315
+
new CustomEvent("sequoia-subscribed", {
316
316
+
bubbles: true,
317
317
+
composed: true,
318
318
+
detail: { publicationUri, recordUri },
319
319
+
}),
320
320
+
);
321
321
+
} catch (error) {
322
322
+
const message =
323
323
+
error instanceof Error ? error.message : "Failed to subscribe";
324
324
+
this.state = { type: "error", message };
325
325
+
this.render();
326
326
+
327
327
+
this.dispatchEvent(
328
328
+
new CustomEvent("sequoia-subscribe-error", {
329
329
+
bubbles: true,
330
330
+
composed: true,
331
331
+
detail: { message },
332
332
+
}),
333
333
+
);
334
334
+
}
335
335
+
}
336
336
+
337
337
+
render() {
338
338
+
const { type } = this.state;
339
339
+
const isLoading = type === "loading";
340
340
+
const isSubscribed = type === "subscribed";
341
341
+
342
342
+
const icon = isLoading
343
343
+
? `<span class="sequoia-loading-spinner"></span>`
344
344
+
: isSubscribed
345
345
+
? CHECK_ICON
346
346
+
: BLUESKY_ICON;
347
347
+
348
348
+
const label = isSubscribed ? "Subscribed" : this.label;
349
349
+
const buttonClass = [
350
350
+
"sequoia-subscribe-button",
351
351
+
isSubscribed ? "sequoia-subscribe-button--success" : "",
352
352
+
]
353
353
+
.filter(Boolean)
354
354
+
.join(" ");
355
355
+
356
356
+
const errorHtml =
357
357
+
type === "error"
358
358
+
? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>`
359
359
+
: "";
360
360
+
361
361
+
this.wrapper.innerHTML = `
362
362
+
<button
363
363
+
class="${buttonClass}"
364
364
+
type="button"
365
365
+
part="button"
366
366
+
${isLoading || isSubscribed ? "disabled" : ""}
367
367
+
aria-label="${isSubscribed ? "Subscribed" : this.label}"
368
368
+
>
369
369
+
${icon}
370
370
+
${label}
371
371
+
</button>
372
372
+
${errorHtml}
373
373
+
`;
374
374
+
375
375
+
if (type !== "subscribed") {
376
376
+
const btn = this.wrapper.querySelector("button");
377
377
+
btn?.addEventListener("click", () => this.handleClick());
378
378
+
}
379
379
+
}
380
380
+
}
381
381
+
382
382
+
/**
383
383
+
* Escape HTML special characters (no DOM dependency for SSR).
384
384
+
* @param {string} text
385
385
+
* @returns {string}
386
386
+
*/
387
387
+
function escapeHtml(text) {
388
388
+
return text
389
389
+
.replace(/&/g, "&")
390
390
+
.replace(/</g, "<")
391
391
+
.replace(/>/g, ">")
392
392
+
.replace(/"/g, """);
393
393
+
}
394
394
+
395
395
+
// Register the custom element
396
396
+
if (typeof customElements !== "undefined") {
397
397
+
customElements.define("sequoia-subscribe", SequoiaSubscribe);
398
398
+
}
399
399
+
400
400
+
// Export for module usage
401
401
+
export { SequoiaSubscribe };