sequoia-cli 0.5.0-alpha.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -588,7 +588,6 @@ class SequoiaComments extends BaseElement {
588
588
  this.commentsContainer = container;
589
589
  this.state = { type: "loading" };
590
590
  this.abortController = null;
591
-
592
591
  }
593
592
 
594
593
  static get observedAttributes() {
@@ -701,7 +700,7 @@ class SequoiaComments extends BaseElement {
701
700
  </div>
702
701
  `;
703
702
  if (this.hide) {
704
- this.commentsContainer.style.display = 'none';
703
+ this.commentsContainer.style.display = "none";
705
704
  }
706
705
  break;
707
706
 
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Sequoia Subscribe - A Bluesky-powered subscribe component
3
+ *
4
+ * A self-contained Web Component that lets users subscribe to a publication
5
+ * via the AT Protocol by creating a site.standard.graph.subscription record.
6
+ *
7
+ * Usage:
8
+ * <sequoia-subscribe></sequoia-subscribe>
9
+ *
10
+ * The component resolves the publication AT URI from the host site's
11
+ * /.well-known/site.standard.publication endpoint.
12
+ *
13
+ * Attributes:
14
+ * - publication-uri: Override the publication AT URI (optional)
15
+ * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
16
+ * - label: Button label text (default: "Subscribe on Bluesky")
17
+ * - hide: Set to "auto" to hide if no publication URI is detected
18
+ *
19
+ * CSS Custom Properties:
20
+ * - --sequoia-fg-color: Text color (default: #1f2937)
21
+ * - --sequoia-bg-color: Background color (default: #ffffff)
22
+ * - --sequoia-border-color: Border color (default: #e5e7eb)
23
+ * - --sequoia-accent-color: Accent/button color (default: #2563eb)
24
+ * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
25
+ * - --sequoia-border-radius: Border radius (default: 8px)
26
+ *
27
+ * Events:
28
+ * - sequoia-subscribed: Fired when the subscription is created successfully.
29
+ * detail: { publicationUri: string, recordUri: string }
30
+ * - sequoia-subscribe-error: Fired when the subscription fails.
31
+ * detail: { message: string }
32
+ */
33
+
34
+ // ============================================================================
35
+ // Styles
36
+ // ============================================================================
37
+
38
+ const styles = `
39
+ :host {
40
+ display: inline-block;
41
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
42
+ color: var(--sequoia-fg-color, #1f2937);
43
+ line-height: 1.5;
44
+ }
45
+
46
+ * {
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ .sequoia-subscribe-button {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ gap: 0.375rem;
54
+ padding: 0.5rem 1rem;
55
+ background: var(--sequoia-accent-color, #2563eb);
56
+ color: #ffffff;
57
+ border: none;
58
+ border-radius: var(--sequoia-border-radius, 8px);
59
+ font-size: 0.875rem;
60
+ font-weight: 500;
61
+ cursor: pointer;
62
+ text-decoration: none;
63
+ transition: background-color 0.15s ease;
64
+ font-family: inherit;
65
+ }
66
+
67
+ .sequoia-subscribe-button:hover:not(:disabled) {
68
+ background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
69
+ }
70
+
71
+ .sequoia-subscribe-button:disabled {
72
+ opacity: 0.6;
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .sequoia-subscribe-button svg {
77
+ width: 1rem;
78
+ height: 1rem;
79
+ flex-shrink: 0;
80
+ }
81
+
82
+ .sequoia-subscribe-button--success {
83
+ background: #16a34a;
84
+ }
85
+
86
+ .sequoia-subscribe-button--success:hover:not(:disabled) {
87
+ background: color-mix(in srgb, #16a34a 85%, black);
88
+ }
89
+
90
+ .sequoia-loading-spinner {
91
+ display: inline-block;
92
+ width: 1rem;
93
+ height: 1rem;
94
+ border: 2px solid rgba(255, 255, 255, 0.4);
95
+ border-top-color: #ffffff;
96
+ border-radius: 50%;
97
+ animation: sequoia-spin 0.8s linear infinite;
98
+ flex-shrink: 0;
99
+ }
100
+
101
+ @keyframes sequoia-spin {
102
+ to { transform: rotate(360deg); }
103
+ }
104
+
105
+ .sequoia-error-message {
106
+ display: inline-block;
107
+ font-size: 0.8125rem;
108
+ color: #dc2626;
109
+ margin-top: 0.375rem;
110
+ }
111
+ `;
112
+
113
+ // ============================================================================
114
+ // Icons
115
+ // ============================================================================
116
+
117
+ const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
118
+ <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"/>
119
+ </svg>`;
120
+
121
+ const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
122
+ <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"/>
123
+ </svg>`;
124
+
125
+ // ============================================================================
126
+ // AT Protocol Functions
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Fetch the publication AT URI from the host site's well-known endpoint.
131
+ * @param {string} [origin] - Origin to fetch from (defaults to current page origin)
132
+ * @returns {Promise<string>} Publication AT URI
133
+ */
134
+ async function fetchPublicationUri(origin) {
135
+ const base = origin ?? window.location.origin;
136
+ const url = `${base}/.well-known/site.standard.publication`;
137
+ const response = await fetch(url);
138
+ if (!response.ok) {
139
+ throw new Error(`Could not fetch publication URI: ${response.status}`);
140
+ }
141
+
142
+ // Accept either plain text (the AT URI itself) or JSON with a `uri` field.
143
+ const contentType = response.headers.get("content-type") ?? "";
144
+ if (contentType.includes("application/json")) {
145
+ const data = await response.json();
146
+ const uri = data?.uri ?? data?.atUri ?? data?.publication;
147
+ if (!uri) {
148
+ throw new Error("Publication response did not contain a URI");
149
+ }
150
+ return uri;
151
+ }
152
+
153
+ const text = (await response.text()).trim();
154
+ if (!text.startsWith("at://")) {
155
+ throw new Error(`Unexpected publication URI format: ${text}`);
156
+ }
157
+ return text;
158
+ }
159
+
160
+ // ============================================================================
161
+ // Web Component
162
+ // ============================================================================
163
+
164
+ // SSR-safe base class - use HTMLElement in browser, empty class in Node.js
165
+ const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
166
+
167
+ class SequoiaSubscribe extends BaseElement {
168
+ constructor() {
169
+ super();
170
+ const shadow = this.attachShadow({ mode: "open" });
171
+
172
+ const styleTag = document.createElement("style");
173
+ styleTag.innerText = styles;
174
+ shadow.appendChild(styleTag);
175
+
176
+ const wrapper = document.createElement("div");
177
+ shadow.appendChild(wrapper);
178
+ wrapper.part = "container";
179
+
180
+ this.wrapper = wrapper;
181
+ this.state = { type: "idle" };
182
+ this.abortController = null;
183
+ this.render();
184
+ }
185
+
186
+ static get observedAttributes() {
187
+ return ["publication-uri", "callback-uri", "label", "hide"];
188
+ }
189
+
190
+ connectedCallback() {
191
+ // Pre-check publication availability so hide="auto" can take effect
192
+ if (!this.publicationUri) {
193
+ this.checkPublication();
194
+ }
195
+ }
196
+
197
+ disconnectedCallback() {
198
+ this.abortController?.abort();
199
+ }
200
+
201
+ attributeChangedCallback() {
202
+ // Reset to idle if attributes change after an error or success
203
+ if (
204
+ this.state.type === "error" ||
205
+ this.state.type === "subscribed" ||
206
+ this.state.type === "no-publication"
207
+ ) {
208
+ this.state = { type: "idle" };
209
+ }
210
+ this.render();
211
+ }
212
+
213
+ get publicationUri() {
214
+ return this.getAttribute("publication-uri") ?? null;
215
+ }
216
+
217
+ get callbackUri() {
218
+ return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe";
219
+ }
220
+
221
+ get label() {
222
+ return this.getAttribute("label") ?? "Subscribe on Bluesky";
223
+ }
224
+
225
+ get hide() {
226
+ const hideAttr = this.getAttribute("hide");
227
+ return hideAttr === "auto";
228
+ }
229
+
230
+ async checkPublication() {
231
+ this.abortController?.abort();
232
+ this.abortController = new AbortController();
233
+
234
+ try {
235
+ await fetchPublicationUri();
236
+ } catch {
237
+ this.state = { type: "no-publication" };
238
+ this.render();
239
+ }
240
+ }
241
+
242
+ async handleClick() {
243
+ if (this.state.type === "loading" || this.state.type === "subscribed") {
244
+ return;
245
+ }
246
+
247
+ this.state = { type: "loading" };
248
+ this.render();
249
+
250
+ try {
251
+ const publicationUri =
252
+ this.publicationUri ?? (await fetchPublicationUri());
253
+
254
+ // POST to the callbackUri (e.g. https://sequoia.pub/subscribe).
255
+ // If the server reports the user isn't authenticated it returns a
256
+ // subscribeUrl for the full-page OAuth + subscription flow.
257
+ const response = await fetch(this.callbackUri, {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ credentials: "include",
261
+ body: JSON.stringify({ publicationUri }),
262
+ });
263
+
264
+ const data = await response.json();
265
+
266
+ if (response.status === 401 && data.authenticated === false) {
267
+ // Redirect to the hosted subscribe page to complete OAuth
268
+ window.location.href = data.subscribeUrl;
269
+ return;
270
+ }
271
+
272
+ if (!response.ok) {
273
+ throw new Error(data.error ?? `HTTP ${response.status}`);
274
+ }
275
+
276
+ const { recordUri } = data;
277
+ this.state = { type: "subscribed", recordUri, publicationUri };
278
+ this.render();
279
+
280
+ this.dispatchEvent(
281
+ new CustomEvent("sequoia-subscribed", {
282
+ bubbles: true,
283
+ composed: true,
284
+ detail: { publicationUri, recordUri },
285
+ }),
286
+ );
287
+ } catch (error) {
288
+ // Don't overwrite state if we already navigated away
289
+ if (this.state.type !== "loading") return;
290
+
291
+ const message =
292
+ error instanceof Error ? error.message : "Failed to subscribe";
293
+ this.state = { type: "error", message };
294
+ this.render();
295
+
296
+ this.dispatchEvent(
297
+ new CustomEvent("sequoia-subscribe-error", {
298
+ bubbles: true,
299
+ composed: true,
300
+ detail: { message },
301
+ }),
302
+ );
303
+ }
304
+ }
305
+
306
+ render() {
307
+ const { type } = this.state;
308
+
309
+ if (type === "no-publication") {
310
+ if (this.hide) {
311
+ this.wrapper.innerHTML = "";
312
+ this.wrapper.style.display = "none";
313
+ }
314
+ return;
315
+ }
316
+
317
+ const isLoading = type === "loading";
318
+ const isSubscribed = type === "subscribed";
319
+
320
+ const icon = isLoading
321
+ ? `<span class="sequoia-loading-spinner"></span>`
322
+ : isSubscribed
323
+ ? CHECK_ICON
324
+ : BLUESKY_ICON;
325
+
326
+ const label = isSubscribed ? "Subscribed" : this.label;
327
+ const buttonClass = [
328
+ "sequoia-subscribe-button",
329
+ isSubscribed ? "sequoia-subscribe-button--success" : "",
330
+ ]
331
+ .filter(Boolean)
332
+ .join(" ");
333
+
334
+ const errorHtml =
335
+ type === "error"
336
+ ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>`
337
+ : "";
338
+
339
+ this.wrapper.innerHTML = `
340
+ <button
341
+ class="${buttonClass}"
342
+ type="button"
343
+ part="button"
344
+ ${isLoading || isSubscribed ? "disabled" : ""}
345
+ aria-label="${isSubscribed ? "Subscribed" : this.label}"
346
+ >
347
+ ${icon}
348
+ ${label}
349
+ </button>
350
+ ${errorHtml}
351
+ `;
352
+
353
+ if (type !== "subscribed") {
354
+ const btn = this.wrapper.querySelector("button");
355
+ btn?.addEventListener("click", () => this.handleClick());
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Escape HTML special characters (no DOM dependency for SSR).
362
+ * @param {string} text
363
+ * @returns {string}
364
+ */
365
+ function escapeHtml(text) {
366
+ return text
367
+ .replace(/&/g, "&amp;")
368
+ .replace(/</g, "&lt;")
369
+ .replace(/>/g, "&gt;")
370
+ .replace(/"/g, "&quot;");
371
+ }
372
+
373
+ // Register the custom element
374
+ if (typeof customElements !== "undefined") {
375
+ customElements.define("sequoia-subscribe", SequoiaSubscribe);
376
+ }
377
+
378
+ // Export for module usage
379
+ export { SequoiaSubscribe };