sequoia-cli 0.4.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.
@@ -14,6 +14,7 @@
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
+ * - hide: Set to "auto" to hide if no document link is detected
17
18
  *
18
19
  * CSS Custom Properties:
19
20
  * - --sequoia-fg-color: Text color (default: #1f2937)
@@ -573,13 +574,24 @@ const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
573
574
  class SequoiaComments extends BaseElement {
574
575
  constructor() {
575
576
  super();
576
- this.shadow = this.attachShadow({ mode: "open" });
577
+ const shadow = this.attachShadow({ mode: "open" });
578
+
579
+ const styleTag = document.createElement("style");
580
+ shadow.appendChild(styleTag);
581
+ styleTag.innerText = styles;
582
+
583
+ const container = document.createElement("div");
584
+ shadow.appendChild(container);
585
+ container.className = "sequoia-comments-container";
586
+ container.part = "container";
587
+
588
+ this.commentsContainer = container;
577
589
  this.state = { type: "loading" };
578
590
  this.abortController = null;
579
591
  }
580
592
 
581
593
  static get observedAttributes() {
582
- return ["document-uri", "depth"];
594
+ return ["document-uri", "depth", "hide"];
583
595
  }
584
596
 
585
597
  connectedCallback() {
@@ -616,6 +628,11 @@ class SequoiaComments extends BaseElement {
616
628
  return depthAttr ? parseInt(depthAttr, 10) : 6;
617
629
  }
618
630
 
631
+ get hide() {
632
+ const hideAttr = this.getAttribute("hide");
633
+ return hideAttr === "auto";
634
+ }
635
+
619
636
  async loadComments() {
620
637
  // Cancel any in-flight request
621
638
  this.abortController?.abort();
@@ -666,68 +683,54 @@ class SequoiaComments extends BaseElement {
666
683
  }
667
684
 
668
685
  render() {
669
- const styleTag = `<style>${styles}</style>`;
670
-
671
686
  switch (this.state.type) {
672
687
  case "loading":
673
- this.shadow.innerHTML = `
674
- ${styleTag}
675
- <div class="sequoia-comments-container">
676
- <div class="sequoia-loading">
677
- <span class="sequoia-loading-spinner"></span>
678
- Loading comments...
679
- </div>
688
+ this.commentsContainer.innerHTML = `
689
+ <div class="sequoia-loading">
690
+ <span class="sequoia-loading-spinner"></span>
691
+ Loading comments...
680
692
  </div>
681
693
  `;
682
694
  break;
683
695
 
684
696
  case "no-document":
685
- this.shadow.innerHTML = `
686
- ${styleTag}
687
- <div class="sequoia-comments-container">
688
- <div class="sequoia-warning">
689
- No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page.
690
- </div>
697
+ this.commentsContainer.innerHTML = `
698
+ <div class="sequoia-warning">
699
+ No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page.
691
700
  </div>
692
701
  `;
702
+ if (this.hide) {
703
+ this.commentsContainer.style.display = "none";
704
+ }
693
705
  break;
694
706
 
695
707
  case "no-comments-enabled":
696
- this.shadow.innerHTML = `
697
- ${styleTag}
698
- <div class="sequoia-comments-container">
699
- <div class="sequoia-empty">
700
- Comments are not enabled for this post.
701
- </div>
708
+ this.commentsContainer.innerHTML = `
709
+ <div class="sequoia-empty">
710
+ Comments are not enabled for this post.
702
711
  </div>
703
712
  `;
704
713
  break;
705
714
 
706
715
  case "empty":
707
- this.shadow.innerHTML = `
708
- ${styleTag}
709
- <div class="sequoia-comments-container">
710
- <div class="sequoia-comments-header">
711
- <h3 class="sequoia-comments-title">Comments</h3>
712
- <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
713
- ${BLUESKY_ICON}
714
- Reply on Bluesky
715
- </a>
716
- </div>
717
- <div class="sequoia-empty">
718
- No comments yet. Be the first to reply on Bluesky!
719
- </div>
716
+ this.commentsContainer.innerHTML = `
717
+ <div class="sequoia-comments-header">
718
+ <h3 class="sequoia-comments-title">Comments</h3>
719
+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
720
+ ${BLUESKY_ICON}
721
+ Reply on Bluesky
722
+ </a>
723
+ </div>
724
+ <div class="sequoia-empty">
725
+ No comments yet. Be the first to reply on Bluesky!
720
726
  </div>
721
727
  `;
722
728
  break;
723
729
 
724
730
  case "error":
725
- this.shadow.innerHTML = `
726
- ${styleTag}
727
- <div class="sequoia-comments-container">
728
- <div class="sequoia-error">
729
- Failed to load comments: ${escapeHtml(this.state.message)}
730
- </div>
731
+ this.commentsContainer.innerHTML = `
732
+ <div class="sequoia-error">
733
+ Failed to load comments: ${escapeHtml(this.state.message)}
731
734
  </div>
732
735
  `;
733
736
  break;
@@ -740,19 +743,16 @@ class SequoiaComments extends BaseElement {
740
743
  .join("");
741
744
  const commentCount = this.countComments(replies);
742
745
 
743
- this.shadow.innerHTML = `
744
- ${styleTag}
745
- <div class="sequoia-comments-container">
746
- <div class="sequoia-comments-header">
747
- <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
748
- <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
749
- ${BLUESKY_ICON}
750
- Reply on Bluesky
751
- </a>
752
- </div>
753
- <div class="sequoia-comments-list">
754
- ${threadsHtml}
755
- </div>
746
+ this.commentsContainer.innerHTML = `
747
+ <div class="sequoia-comments-header">
748
+ <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
749
+ <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
750
+ ${BLUESKY_ICON}
751
+ Reply on Bluesky
752
+ </a>
753
+ </div>
754
+ <div class="sequoia-comments-list">
755
+ ${threadsHtml}
756
756
  </div>
757
757
  `;
758
758
  break;
@@ -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 };