sequoia-cli 0.5.5 → 0.5.7

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.
@@ -1,24 +1,15 @@
1
1
  /**
2
- * Sequoia Subscribe - An AT Protocol-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.
2
+ * Sequoia Web Components AT Protocol-powered engagement components
6
3
  *
7
- * Usage:
8
- * <sequoia-subscribe></sequoia-subscribe>
4
+ * Self-contained Web Components for subscribing to publications and
5
+ * recommending documents via the AT Protocol.
9
6
  *
10
- * The component resolves the publication AT URI from the host site's
11
- * /.well-known/site.standard.publication endpoint.
7
+ * Both components share:
8
+ * - OAuth redirect flow via a hosted callback endpoint
9
+ * - DID caching in a cookie (primary) and localStorage (fallback)
10
+ * - A common visual style driven by CSS custom properties
12
11
  *
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
- * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain"
17
- * - label: Override the subscribe button label text
18
- * - unsubscribe-label: Override the unsubscribe button label text
19
- * - hide: Set to "auto" to hide if no publication URI is detected
20
- *
21
- * CSS Custom Properties:
12
+ * CSS Custom Properties (apply to both components):
22
13
  * - --sequoia-fg-color: Text color (default: #1f2937)
23
14
  * - --sequoia-bg-color: Background color (default: #ffffff)
24
15
  * - --sequoia-border-color: Border color (default: #e5e7eb)
@@ -26,12 +17,6 @@
26
17
  * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
27
18
  * - --sequoia-border-radius: Border radius (default: 8px)
28
19
  * - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide
29
- *
30
- * Events:
31
- * - sequoia-subscribed: Fired when the subscription is created successfully.
32
- * detail: { publicationUri: string, recordUri: string }
33
- * - sequoia-subscribe-error: Fired when the subscription fails.
34
- * detail: { message: string }
35
20
  */
36
21
 
37
22
  // ============================================================================
@@ -50,7 +35,7 @@ const styles = `
50
35
  box-sizing: border-box;
51
36
  }
52
37
 
53
- .sequoia-subscribe-button {
38
+ .sequoia-button {
54
39
  display: inline-flex;
55
40
  align-items: center;
56
41
  gap: 0.375rem;
@@ -67,16 +52,16 @@ const styles = `
67
52
  font-family: inherit;
68
53
  }
69
54
 
70
- .sequoia-subscribe-button:hover:not(:disabled) {
55
+ .sequoia-button:hover:not(:disabled) {
71
56
  background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
72
57
  }
73
58
 
74
- .sequoia-subscribe-button:disabled {
59
+ .sequoia-button:disabled {
75
60
  opacity: 0.6;
76
61
  cursor: not-allowed;
77
62
  }
78
63
 
79
- .sequoia-subscribe-button svg {
64
+ .sequoia-button svg {
80
65
  display: var(--sequoia-icon-display, inline-block);
81
66
  width: 1rem;
82
67
  height: 1rem;
@@ -148,6 +133,47 @@ const BUTTON_TYPES = {
148
133
  plain: { icon: "", subscribe: "Subscribe", unsubscribe: "Unsubscribe" },
149
134
  };
150
135
 
136
+ // ============================================================================
137
+ // Recommend Icon Configuration
138
+ // ============================================================================
139
+
140
+ const HEART_PATH =
141
+ "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z";
142
+ const HEART_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${HEART_PATH}"/></svg>`;
143
+ const HEART_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${HEART_PATH}"/></svg>`;
144
+
145
+ const STAR_PATH =
146
+ "M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z";
147
+ const STAR_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="${STAR_PATH}"/></svg>`;
148
+ const STAR_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="${STAR_PATH}"/></svg>`;
149
+
150
+ const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z";
151
+ const THUMBS_UP_HAND_PATH =
152
+ "M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z";
153
+ const THUMBS_UP_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${THUMBS_UP_RECT_PATH}" fill="currentColor"/><path d="${THUMBS_UP_HAND_PATH}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
154
+ const THUMBS_UP_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${THUMBS_UP_RECT_PATH}"/><path d="${THUMBS_UP_HAND_PATH}"/></svg>`;
155
+
156
+ const RECOMMEND_ICON_TYPES = {
157
+ heart: {
158
+ icon: HEART_ICON_OUTLINED,
159
+ iconActioned: HEART_ICON_FILLED,
160
+ action: "Recommend",
161
+ unaction: "Unrecommend",
162
+ },
163
+ star: {
164
+ icon: STAR_ICON_OUTLINED,
165
+ iconActioned: STAR_ICON_FILLED,
166
+ action: "Recommend",
167
+ unaction: "Unrecommend",
168
+ },
169
+ "thumbs-up": {
170
+ icon: THUMBS_UP_ICON_OUTLINED,
171
+ iconActioned: THUMBS_UP_ICON_FILLED,
172
+ action: "Recommend",
173
+ unaction: "Unrecommend",
174
+ },
175
+ };
176
+
151
177
  // ============================================================================
152
178
  // DID Storage
153
179
  // ============================================================================
@@ -161,6 +187,7 @@ function storeSubscriberDid(did) {
161
187
  const expires = new Date(
162
188
  Date.now() + 365 * 24 * 60 * 60 * 1000,
163
189
  ).toUTCString();
190
+ // biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers
164
191
  document.cookie = `sequoia_did=${encodeURIComponent(did)}; Expires=${expires}; Path=/; SameSite=Lax; Secure`;
165
192
  } catch {
166
193
  // Cookie write may fail in some embedded contexts
@@ -200,6 +227,7 @@ function getStoredSubscriberDid() {
200
227
  */
201
228
  function clearSubscriberDid() {
202
229
  try {
230
+ // biome-ignore lint/suspicious/noDocumentCookie: back-compat with older browsers
203
231
  document.cookie =
204
232
  "sequoia_did=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax; Secure";
205
233
  } catch {
@@ -229,7 +257,7 @@ function consumeReturnParams() {
229
257
  changed = true;
230
258
  }
231
259
 
232
- if (did && did.startsWith("did:")) {
260
+ if (did?.startsWith("did:")) {
233
261
  storeSubscriberDid(did);
234
262
  url.searchParams.delete("sequoia_did");
235
263
  changed = true;
@@ -287,7 +315,13 @@ async function fetchPublicationUri(origin) {
287
315
  // SSR-safe base class - use HTMLElement in browser, empty class in Node.js
288
316
  const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
289
317
 
290
- class SequoiaSubscribe extends BaseElement {
318
+ /**
319
+ * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend.
320
+ * Handles shadow DOM setup, state management, the OAuth redirect flow,
321
+ * DID storage, and button rendering. Subclasses implement template methods
322
+ * to provide resource-specific behaviour.
323
+ */
324
+ class SequoiaActionBase extends BaseElement {
291
325
  constructor() {
292
326
  super();
293
327
  const shadow = this.attachShadow({ mode: "open" });
@@ -301,82 +335,104 @@ class SequoiaSubscribe extends BaseElement {
301
335
  wrapper.part = "container";
302
336
 
303
337
  this.wrapper = wrapper;
304
- this.subscribed = false;
338
+ this.actioned = false;
305
339
  this.state = { type: "idle" };
306
340
  this.abortController = null;
307
341
  this.render();
308
342
  }
309
343
 
310
- static get observedAttributes() {
311
- return [
312
- "publication-uri",
313
- "callback-uri",
314
- "label",
315
- "unsubscribe-label",
316
- "button-type",
317
- "hide",
318
- ];
319
- }
320
-
321
- connectedCallback() {
322
- consumeReturnParams();
323
- this.checkPublication();
324
- }
325
-
326
344
  disconnectedCallback() {
327
345
  this.abortController?.abort();
328
346
  }
329
347
 
330
348
  attributeChangedCallback() {
331
- if (this.state.type === "error" || this.state.type === "no-publication") {
349
+ if (this.state.type === "error" || this.state.type === "no-resource") {
332
350
  this.state = { type: "idle" };
333
351
  }
334
352
  this.render();
335
353
  }
336
354
 
337
- get publicationUri() {
338
- return this.getAttribute("publication-uri") ?? null;
339
- }
355
+ // ── Shared getters ───────────────────────────────────────────────────────
340
356
 
341
357
  get callbackUri() {
342
- return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe";
358
+ return this.getAttribute("callback-uri") ?? this.defaultCallbackUri;
343
359
  }
344
360
 
345
- get label() {
346
- return this.getAttribute("label") ?? null;
361
+ get hide() {
362
+ return this.getAttribute("hide") === "auto";
347
363
  }
348
364
 
349
- get unsubscribeLabel() {
350
- return this.getAttribute("unsubscribe-label") ?? null;
365
+ // ── Template methods (override in subclasses) ────────────────────────────
366
+
367
+ /** @returns {string} Default callback URI when the attribute is absent */
368
+ get defaultCallbackUri() {
369
+ return "";
351
370
  }
352
371
 
353
- get buttonType() {
354
- const val = this.getAttribute("button-type");
355
- return val && val in BUTTON_TYPES ? val : "sequoia";
372
+ /** @returns {string} Query-parameter name for the resource URI */
373
+ get resourceParam() {
374
+ return "resourceUri";
356
375
  }
357
376
 
358
- get hide() {
359
- const hideAttr = this.getAttribute("hide");
360
- return hideAttr === "auto";
377
+ /**
378
+ * Value of the `action` query-parameter used in the unaction redirect.
379
+ * @returns {string}
380
+ */
381
+ get unactionValue() {
382
+ return "unaction";
361
383
  }
362
384
 
363
- async checkPublication() {
364
- this.abortController?.abort();
365
- this.abortController = new AbortController();
385
+ /** @returns {string} Key in the /check response that signals the action was taken */
386
+ get actionedKey() {
387
+ return "actioned";
388
+ }
366
389
 
367
- try {
368
- const uri = this.publicationUri ?? (await fetchPublicationUri());
369
- this.checkSubscription(uri);
370
- } catch {
371
- this.state = { type: "no-publication" };
372
- this.render();
373
- }
390
+ /** @returns {string} CustomEvent name dispatched on success */
391
+ get actionedEventName() {
392
+ return "sequoia-actioned";
393
+ }
394
+
395
+ /** @returns {string} CustomEvent name dispatched on error */
396
+ get errorEventName() {
397
+ return "sequoia-action-error";
398
+ }
399
+
400
+ /** @returns {string} Fallback error message when the thrown value has no message */
401
+ get defaultErrorMessage() {
402
+ return "Action failed";
374
403
  }
375
404
 
376
- async checkSubscription(publicationUri) {
405
+ /** @returns {string} SVG string for the button icon */
406
+ getIcon() {
407
+ return "";
408
+ }
409
+
410
+ /** @returns {string} Accessible label for the button (defaults to the visible label) */
411
+ getAriaLabel() {
412
+ return this.actioned
413
+ ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
414
+ : (this.label ?? this.getDefaultActionLabel?.() ?? "");
415
+ }
416
+
417
+ /**
418
+ * Resolve the resource URI for this action. May perform async network calls.
419
+ * @returns {Promise<string>}
420
+ */
421
+ async resolveResourceUri() {
422
+ throw new Error("resolveResourceUri() must be implemented by subclass");
423
+ }
424
+
425
+ // ── Shared logic ─────────────────────────────────────────────────────────
426
+
427
+ /**
428
+ * Check whether the current user has already taken this action for the
429
+ * given resource URI. Updates this.actioned and re-renders on success.
430
+ * @param {string} resourceUri
431
+ */
432
+ async checkStatusFor(resourceUri) {
377
433
  try {
378
434
  const checkUrl = new URL(`${this.callbackUri}/check`);
379
- checkUrl.searchParams.set("publicationUri", publicationUri);
435
+ checkUrl.searchParams.set(this.resourceParam, resourceUri);
380
436
 
381
437
  // Pass the stored DID so the server can check without a session cookie
382
438
  const storedDid = getStoredSubscriberDid();
@@ -389,12 +445,12 @@ class SequoiaSubscribe extends BaseElement {
389
445
  });
390
446
  if (!res.ok) return;
391
447
  const data = await res.json();
392
- if (data.subscribed) {
393
- this.subscribed = true;
448
+ if (data[this.actionedKey]) {
449
+ this.actioned = true;
394
450
  this.render();
395
451
  }
396
452
  } catch {
397
- // Ignore errors — show default subscribe button
453
+ // Ignore errors — show default action button
398
454
  }
399
455
  }
400
456
 
@@ -403,11 +459,10 @@ class SequoiaSubscribe extends BaseElement {
403
459
  return;
404
460
  }
405
461
 
406
- // Unsubscribe: redirect to full-page unsubscribe flow
407
- if (this.subscribed) {
408
- const publicationUri =
409
- this.publicationUri ?? (await fetchPublicationUri());
410
- window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`;
462
+ // Unaction: redirect to the full-page unaction flow
463
+ if (this.actioned) {
464
+ const resourceUri = await this.resolveResourceUri();
465
+ window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`;
411
466
  return;
412
467
  }
413
468
 
@@ -415,28 +470,27 @@ class SequoiaSubscribe extends BaseElement {
415
470
  this.render();
416
471
 
417
472
  try {
418
- const publicationUri =
419
- this.publicationUri ?? (await fetchPublicationUri());
473
+ const resourceUri = await this.resolveResourceUri();
420
474
 
421
475
  const response = await fetch(this.callbackUri, {
422
476
  method: "POST",
423
477
  headers: { "Content-Type": "application/json" },
424
478
  credentials: "include",
425
479
  referrerPolicy: "no-referrer-when-downgrade",
426
- body: JSON.stringify({ publicationUri }),
480
+ body: JSON.stringify({ [this.resourceParam]: resourceUri }),
427
481
  });
428
482
 
429
483
  const data = await response.json();
430
484
 
431
485
  if (response.status === 401 && data.authenticated === false) {
432
- // Redirect to the hosted subscribe page to complete OAuth,
486
+ // Redirect to the hosted action page to complete OAuth,
433
487
  // passing the current page URL (without credentials) as returnTo.
434
- const subscribeUrl = new URL(data.subscribeUrl);
488
+ const actionUrl = new URL(data.subscribeUrl);
435
489
  const pageUrl = new URL(window.location.href);
436
490
  pageUrl.username = "";
437
491
  pageUrl.password = "";
438
- subscribeUrl.searchParams.set("returnTo", pageUrl.toString());
439
- window.location.href = subscribeUrl.toString();
492
+ actionUrl.searchParams.set("returnTo", pageUrl.toString());
493
+ window.location.href = actionUrl.toString();
440
494
  return;
441
495
  }
442
496
 
@@ -454,27 +508,27 @@ class SequoiaSubscribe extends BaseElement {
454
508
  }
455
509
  }
456
510
 
457
- this.subscribed = true;
511
+ this.actioned = true;
458
512
  this.state = { type: "idle" };
459
513
  this.render();
460
514
 
461
515
  this.dispatchEvent(
462
- new CustomEvent("sequoia-subscribed", {
516
+ new CustomEvent(this.actionedEventName, {
463
517
  bubbles: true,
464
518
  composed: true,
465
- detail: { publicationUri, recordUri },
519
+ detail: { [this.resourceParam]: resourceUri, recordUri },
466
520
  }),
467
521
  );
468
522
  } catch (error) {
469
523
  if (this.state.type !== "loading") return;
470
524
 
471
525
  const message =
472
- error instanceof Error ? error.message : "Failed to subscribe";
526
+ error instanceof Error ? error.message : this.defaultErrorMessage;
473
527
  this.state = { type: "error", message };
474
528
  this.render();
475
529
 
476
530
  this.dispatchEvent(
477
- new CustomEvent("sequoia-subscribe-error", {
531
+ new CustomEvent(this.errorEventName, {
478
532
  bubbles: true,
479
533
  composed: true,
480
534
  detail: { message },
@@ -486,7 +540,7 @@ class SequoiaSubscribe extends BaseElement {
486
540
  render() {
487
541
  const { type } = this.state;
488
542
 
489
- if (type === "no-publication") {
543
+ if (type === "no-resource") {
490
544
  if (this.hide) {
491
545
  this.wrapper.innerHTML = "";
492
546
  this.wrapper.style.display = "none";
@@ -495,15 +549,15 @@ class SequoiaSubscribe extends BaseElement {
495
549
  }
496
550
 
497
551
  const isLoading = type === "loading";
498
- const config = BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia;
499
-
500
552
  const icon = isLoading
501
553
  ? `<span class="sequoia-loading-spinner"></span>`
502
- : config.icon;
554
+ : this.getIcon();
555
+
556
+ const label = this.actioned
557
+ ? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
558
+ : (this.label ?? this.getDefaultActionLabel?.() ?? "");
503
559
 
504
- const label = this.subscribed
505
- ? (this.unsubscribeLabel ?? config.unsubscribe)
506
- : (this.label ?? config.subscribe);
560
+ const ariaLabel = this.getAriaLabel();
507
561
 
508
562
  const errorHtml =
509
563
  type === "error"
@@ -512,11 +566,11 @@ class SequoiaSubscribe extends BaseElement {
512
566
 
513
567
  this.wrapper.innerHTML = `
514
568
  <button
515
- class="sequoia-subscribe-button"
569
+ class="sequoia-button"
516
570
  type="button"
517
571
  part="button"
518
572
  ${isLoading ? "disabled" : ""}
519
- aria-label="${label}"
573
+ aria-label="${ariaLabel}"
520
574
  >
521
575
  ${icon}
522
576
  ${label}
@@ -529,6 +583,197 @@ class SequoiaSubscribe extends BaseElement {
529
583
  }
530
584
  }
531
585
 
586
+ class SequoiaSubscribe extends SequoiaActionBase {
587
+ static get observedAttributes() {
588
+ return [
589
+ "publication-uri",
590
+ "callback-uri",
591
+ "label",
592
+ "unsubscribe-label",
593
+ "button-type",
594
+ "hide",
595
+ ];
596
+ }
597
+
598
+ connectedCallback() {
599
+ consumeReturnParams();
600
+ this.checkPublication();
601
+ }
602
+
603
+ get publicationUri() {
604
+ return this.getAttribute("publication-uri") ?? null;
605
+ }
606
+
607
+ get label() {
608
+ return this.getAttribute("label") ?? null;
609
+ }
610
+
611
+ get buttonType() {
612
+ const val = this.getAttribute("button-type");
613
+ return val && val in BUTTON_TYPES ? val : "sequoia";
614
+ }
615
+
616
+ get unsubscribeLabel() {
617
+ return this.getAttribute("unsubscribe-label") ?? null;
618
+ }
619
+
620
+ // ── Template method overrides ────────────────────────────────────────────
621
+
622
+ get defaultCallbackUri() {
623
+ return "https://sequoia.pub/subscribe";
624
+ }
625
+ get resourceParam() {
626
+ return "publicationUri";
627
+ }
628
+ get unactionValue() {
629
+ return "unsubscribe";
630
+ }
631
+ get actionedKey() {
632
+ return "subscribed";
633
+ }
634
+ get actionedEventName() {
635
+ return "sequoia-subscribed";
636
+ }
637
+ get errorEventName() {
638
+ return "sequoia-subscribe-error";
639
+ }
640
+ get defaultErrorMessage() {
641
+ return "Failed to subscribe";
642
+ }
643
+
644
+ getDefaultActionLabel() {
645
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe;
646
+ }
647
+
648
+ getDefaultUnactionLabel() {
649
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe;
650
+ }
651
+
652
+ getUnactionLabel() {
653
+ return this.unsubscribeLabel;
654
+ }
655
+
656
+ getIcon() {
657
+ return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon;
658
+ }
659
+
660
+ async resolveResourceUri() {
661
+ return this.publicationUri ?? (await fetchPublicationUri());
662
+ }
663
+
664
+ // ── SequoiaSubscribe-specific logic ──────────────────────────────────────
665
+
666
+ /** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */
667
+ get subscribed() {
668
+ return this.actioned;
669
+ }
670
+
671
+ /**
672
+ * Check whether the current user is subscribed to the given publication URI.
673
+ * Forwards to the shared checkStatusFor() method.
674
+ * @param {string} publicationUri
675
+ */
676
+ checkSubscription(publicationUri) {
677
+ return this.checkStatusFor(publicationUri);
678
+ }
679
+
680
+ async checkPublication() {
681
+ this.abortController?.abort();
682
+ this.abortController = new AbortController();
683
+
684
+ try {
685
+ const uri = await this.resolveResourceUri();
686
+ this.checkStatusFor(uri);
687
+ } catch {
688
+ this.state = { type: "no-resource" };
689
+ this.render();
690
+ }
691
+ }
692
+ }
693
+
694
+ class SequoiaRecommend extends SequoiaActionBase {
695
+ static get observedAttributes() {
696
+ return ["document-uri", "callback-uri", "button-type", "hide"];
697
+ }
698
+
699
+ connectedCallback() {
700
+ consumeReturnParams();
701
+ this.checkDocument();
702
+ }
703
+
704
+ get documentUri() {
705
+ const attrUri = this.getAttribute("document-uri");
706
+ if (attrUri) return attrUri;
707
+ const linkTag = document.querySelector(
708
+ 'link[rel="site.standard.document"]',
709
+ );
710
+ return linkTag?.href ?? null;
711
+ }
712
+
713
+ get buttonType() {
714
+ const val = this.getAttribute("button-type");
715
+ return val && val in RECOMMEND_ICON_TYPES ? val : "heart";
716
+ }
717
+
718
+ // ── Template method overrides ────────────────────────────────────────────
719
+
720
+ get defaultCallbackUri() {
721
+ return "https://sequoia.pub/recommend";
722
+ }
723
+ get resourceParam() {
724
+ return "documentUri";
725
+ }
726
+ get unactionValue() {
727
+ return "remove";
728
+ }
729
+ get actionedKey() {
730
+ return "recommended";
731
+ }
732
+ get actionedEventName() {
733
+ return "sequoia-recommended";
734
+ }
735
+ get errorEventName() {
736
+ return "sequoia-recommend-error";
737
+ }
738
+ get defaultErrorMessage() {
739
+ return "Failed to recommend";
740
+ }
741
+
742
+ getAriaLabel() {
743
+ const config =
744
+ RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
745
+ return this.actioned ? config.unaction : config.action;
746
+ }
747
+
748
+ getIcon() {
749
+ const config =
750
+ RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
751
+ return this.actioned ? config.iconActioned : config.icon;
752
+ }
753
+
754
+ async resolveResourceUri() {
755
+ const uri = this.documentUri;
756
+ if (!uri) throw new Error("No document URI found");
757
+ return uri;
758
+ }
759
+
760
+ // ── SequoiaRecommend-specific logic ──────────────────────────────────────
761
+
762
+ async checkDocument() {
763
+ this.abortController?.abort();
764
+ this.abortController = new AbortController();
765
+
766
+ const uri = this.documentUri;
767
+ if (!uri) {
768
+ this.state = { type: "no-resource" };
769
+ this.render();
770
+ return;
771
+ }
772
+
773
+ this.checkStatusFor(uri);
774
+ }
775
+ }
776
+
532
777
  /**
533
778
  * Escape HTML special characters (no DOM dependency for SSR).
534
779
  * @param {string} text
@@ -542,10 +787,62 @@ function escapeHtml(text) {
542
787
  .replace(/"/g, "&quot;");
543
788
  }
544
789
 
545
- // Register the custom element
790
+ // Register the custom elements
546
791
  if (typeof customElements !== "undefined") {
547
792
  customElements.define("sequoia-subscribe", SequoiaSubscribe);
793
+ customElements.define("sequoia-recommend", SequoiaRecommend);
548
794
  }
549
795
 
550
- // Export for module usage
796
+ /**
797
+ * Sequoia Subscribe - An AT Protocol-powered subscribe component
798
+ *
799
+ * A self-contained Web Component that lets users subscribe to a publication
800
+ * via the AT Protocol by creating a site.standard.graph.subscription record.
801
+ *
802
+ * Usage:
803
+ * <sequoia-subscribe></sequoia-subscribe>
804
+ *
805
+ * The component resolves the publication AT URI from the host site's
806
+ * /.well-known/site.standard.publication endpoint.
807
+ *
808
+ * Attributes:
809
+ * - publication-uri: Override the publication AT URI (optional)
810
+ * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
811
+ * - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain"
812
+ * - label: Override the subscribe button label text
813
+ * - unsubscribe-label: Override the unsubscribe button label text
814
+ * - hide: Set to "auto" to hide if no publication URI is detected
815
+ *
816
+ * Events:
817
+ * - sequoia-subscribed: Fired when the subscription is created successfully.
818
+ * detail: { publicationUri: string, recordUri: string }
819
+ * - sequoia-subscribe-error: Fired when the subscription fails.
820
+ * detail: { message: string }
821
+ */
551
822
  export { SequoiaSubscribe };
823
+
824
+ /**
825
+ * Sequoia Recommend - An AT Protocol-powered recommend component
826
+ *
827
+ * A self-contained Web Component that lets users recommend a document
828
+ * via the AT Protocol by creating a site.standard.graph.recommend record.
829
+ *
830
+ * Usage:
831
+ * <sequoia-recommend></sequoia-recommend>
832
+ *
833
+ * The component resolves the document AT URI from the `document-uri` attribute
834
+ * or a <link rel="site.standard.document" href="at://..."> tag in the page head.
835
+ *
836
+ * Attributes:
837
+ * - document-uri: AT Protocol URI of the document to recommend (optional if link tag present)
838
+ * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend")
839
+ * - button-type: Icon style — "heart" (default), "star", or "thumbs-up"
840
+ * - hide: Set to "auto" to hide if no document URI is detected
841
+ *
842
+ * Events:
843
+ * - sequoia-recommended: Fired when the recommendation is created successfully.
844
+ * detail: { documentUri: string, recordUri: string }
845
+ * - sequoia-recommend-error: Fired when the recommendation fails.
846
+ * detail: { message: string }
847
+ */
848
+ export { SequoiaRecommend };