sequoia-cli 0.5.4 → 0.5.6

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.
@@ -11,7 +11,16 @@
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
+ * Custom reply button:
15
+ * Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons.
16
+ * It stays in the light DOM, so your page CSS applies to it normally.
17
+ * Only practical with post-uri, since that's the only time the URL is known at authoring time:
18
+ * <sequoia-comments post-uri="https://bsky.app/profile/.../post/...">
19
+ * <a slot="reply-button" href="https://bsky.app/profile/.../post/...">Reply</a>
20
+ * </sequoia-comments>
21
+ *
14
22
  * Attributes:
23
+ * - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup
15
24
  * - document-uri: AT Protocol URI for the document (optional if link tag exists)
16
25
  * - depth: Maximum depth of nested replies to fetch (default: 6)
17
26
  * - hide: Set to "auto" to hide if no document link is detected
@@ -22,6 +31,7 @@
22
31
  * - --sequoia-border-color: Border color (default: #e5e7eb)
23
32
  * - --sequoia-accent-color: Accent/link color (default: #2563eb)
24
33
  * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
34
+ * - --sequoia-font-family: Font family (default: system-ui stack)
25
35
  * - --sequoia-border-radius: Border radius (default: 8px)
26
36
  */
27
37
 
@@ -32,7 +42,7 @@
32
42
  const styles = `
33
43
  :host {
34
44
  display: block;
35
- font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
45
+ font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
36
46
  color: var(--sequoia-fg-color, #1f2937);
37
47
  line-height: 1.5;
38
48
  }
@@ -250,17 +260,17 @@ const styles = `
250
260
  white-space: nowrap;
251
261
  }
252
262
 
263
+ .sequoia-comment-handle::after {
264
+ content: "·";
265
+ margin-left: 0.5rem;
266
+ }
267
+
253
268
  .sequoia-comment-time {
254
269
  font-size: 0.875rem;
255
270
  color: var(--sequoia-secondary-color, #6b7280);
256
271
  flex-shrink: 0;
257
272
  }
258
273
 
259
- .sequoia-comment-time::before {
260
- content: "·";
261
- margin-right: 0.5rem;
262
- }
263
-
264
274
  .sequoia-comment-text {
265
275
  margin: 0;
266
276
  white-space: pre-wrap;
@@ -280,6 +290,30 @@ const styles = `
280
290
  width: 1rem;
281
291
  height: 1rem;
282
292
  }
293
+
294
+ .sequoia-quotes-section {
295
+ margin-top: 1.75rem;
296
+ }
297
+
298
+ .sequoia-quotes-header {
299
+ font-size: 0.75rem;
300
+ font-weight: 600;
301
+ color: var(--sequoia-secondary-color, #6b7280);
302
+ letter-spacing: 0.05em;
303
+ text-transform: uppercase;
304
+ margin: 0;
305
+ padding-bottom: 0.75rem;
306
+ border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
307
+ }
308
+
309
+ a.sequoia-comment-time {
310
+ text-decoration: none;
311
+ color: var(--sequoia-secondary-color, #6b7280);
312
+ }
313
+
314
+ a.sequoia-comment-time:hover {
315
+ text-decoration: underline;
316
+ }
283
317
  `;
284
318
 
285
319
  // ============================================================================
@@ -583,6 +617,72 @@ function isThreadViewPost(post) {
583
617
  return post?.$type === "app.bsky.feed.defs#threadViewPost";
584
618
  }
585
619
 
620
+ /**
621
+ * Fetch all quote posts for a given post URI, paginating through all results.
622
+ * Uses the public Bluesky AppView — gaps are expected for posts from
623
+ * less-connected PDS instances.
624
+ * @param {string} postUri - AT Protocol URI for the post
625
+ * @returns {Promise<Array>} Array of PostView objects
626
+ */
627
+ /**
628
+ * Normalise a user-supplied post reference to an AT-URI.
629
+ * Accepts:
630
+ * - AT-URIs as-is: at://did:plc:.../app.bsky.feed.post/rkey
631
+ * - bsky.app post URLs: https://bsky.app/profile/<handle-or-did>/post/<rkey>
632
+ * When the profile segment is already a DID no network request is made.
633
+ * @param {string} uriOrUrl
634
+ * @returns {Promise<string>} AT-URI
635
+ */
636
+ async function resolvePostUri(uriOrUrl) {
637
+ if (uriOrUrl.startsWith("at://")) return uriOrUrl;
638
+
639
+ const match = uriOrUrl.match(
640
+ /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/,
641
+ );
642
+ if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`);
643
+
644
+ const [, handleOrDid, rkey] = match;
645
+
646
+ let did = handleOrDid;
647
+ if (!handleOrDid.startsWith("did:")) {
648
+ const url = new URL(
649
+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle",
650
+ );
651
+ url.searchParams.set("handle", handleOrDid);
652
+ const response = await fetch(url.toString());
653
+ if (!response.ok)
654
+ throw new Error(`Failed to resolve handle: ${response.status}`);
655
+ did = (await response.json()).did;
656
+ }
657
+
658
+ return `at://${did}/app.bsky.feed.post/${rkey}`;
659
+ }
660
+
661
+ async function getQuotes(postUri) {
662
+ const quotes = [];
663
+ let cursor;
664
+
665
+ do {
666
+ const url = new URL(
667
+ "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes",
668
+ );
669
+ url.searchParams.set("uri", postUri);
670
+ url.searchParams.set("limit", "100");
671
+ if (cursor) url.searchParams.set("cursor", cursor);
672
+
673
+ const response = await fetch(url.toString());
674
+ if (!response.ok) {
675
+ throw new Error(`Failed to fetch quotes: ${response.status}`);
676
+ }
677
+
678
+ const data = await response.json();
679
+ quotes.push(...(data.posts ?? []));
680
+ cursor = data.cursor;
681
+ } while (cursor);
682
+
683
+ return quotes;
684
+ }
685
+
586
686
  // ============================================================================
587
687
  // Bluesky Icon
588
688
  // ============================================================================
@@ -620,10 +720,11 @@ class SequoiaComments extends BaseElement {
620
720
  }
621
721
 
622
722
  static get observedAttributes() {
623
- return ["document-uri", "depth", "hide"];
723
+ return ["post-uri", "document-uri", "depth", "hide"];
624
724
  }
625
725
 
626
726
  connectedCallback() {
727
+ this.initialized = true;
627
728
  this.render();
628
729
  this.loadComments();
629
730
  }
@@ -633,7 +734,10 @@ class SequoiaComments extends BaseElement {
633
734
  }
634
735
 
635
736
  attributeChangedCallback() {
636
- if (this.isConnected) {
737
+ // attributeChangedCallback fires for pre-existing attributes during
738
+ // element upgrade, *before* connectedCallback — skip until we've done
739
+ // the initial load, otherwise every attribute triggers a duplicate fetch.
740
+ if (this.initialized) {
637
741
  this.loadComments();
638
742
  }
639
743
  }
@@ -670,39 +774,54 @@ class SequoiaComments extends BaseElement {
670
774
  this.state = { type: "loading" };
671
775
  this.render();
672
776
 
673
- const docUri = this.documentUri;
674
- if (!docUri) {
675
- this.state = { type: "no-document" };
676
- this.render();
677
- return;
678
- }
679
-
680
777
  try {
681
- // Fetch the document record
682
- const document = await getDocument(docUri);
778
+ // Resolve the post URI — either directly from the attribute or via the
779
+ // document record (which requires a PDS roundtrip)
780
+ const rawPostUri = this.getAttribute("post-uri");
781
+ let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null;
782
+ if (!postUri) {
783
+ const docUri = this.documentUri;
784
+ if (!docUri) {
785
+ this.state = { type: "no-document" };
786
+ this.render();
787
+ return;
788
+ }
683
789
 
684
- // Check if document has a Bluesky post reference
685
- if (!document.bskyPostRef) {
686
- this.state = { type: "no-comments-enabled" };
687
- this.render();
688
- return;
790
+ const document = await getDocument(docUri);
791
+ if (!document.bskyPostRef) {
792
+ this.state = { type: "no-comments-enabled" };
793
+ this.render();
794
+ return;
795
+ }
796
+
797
+ postUri = document.bskyPostRef.uri;
689
798
  }
690
799
 
691
- const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
692
- const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri);
800
+ const postUrl = buildBskyAppUrl(postUri);
801
+ const blackskyPostUrl = buildBlackskyAppUrl(postUri);
802
+
803
+ // Fetch thread and quotes in parallel; quote failures degrade gracefully
804
+ const [threadResult, quotesResult] = await Promise.allSettled([
805
+ getPostThread(postUri, this.depth),
806
+ getQuotes(postUri),
807
+ ]);
808
+
809
+ if (threadResult.status === "rejected") {
810
+ throw threadResult.reason;
811
+ }
693
812
 
694
- // Fetch the post thread
695
- const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
813
+ const thread = threadResult.value;
814
+ const quotes =
815
+ quotesResult.status === "fulfilled" ? quotesResult.value : [];
696
816
 
697
- // Check if there are any replies
698
817
  const replies = thread.replies?.filter(isThreadViewPost) ?? [];
699
- if (replies.length === 0) {
818
+ if (replies.length === 0 && quotes.length === 0) {
700
819
  this.state = { type: "empty", postUrl, blackskyPostUrl };
701
820
  this.render();
702
821
  return;
703
822
  }
704
823
 
705
- this.state = { type: "loaded", thread, postUrl, blackskyPostUrl };
824
+ this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl };
706
825
  this.render();
707
826
  } catch (error) {
708
827
  const message =
@@ -746,14 +865,7 @@ class SequoiaComments extends BaseElement {
746
865
  this.commentsContainer.innerHTML = `
747
866
  <div class="sequoia-comments-header">
748
867
  <h3 class="sequoia-comments-title">Comments</h3>
749
- <div>
750
- <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
751
- ${BLUESKY_ICON}
752
- </a>
753
- <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
754
- ${BLACKSKY_ICON}
755
- </a>
756
- </div>
868
+ <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
757
869
  </div>
758
870
  <div class="sequoia-empty">
759
871
  No comments yet. Be the first to reply on Bluesky!
@@ -772,26 +884,26 @@ class SequoiaComments extends BaseElement {
772
884
  case "loaded": {
773
885
  const replies =
774
886
  this.state.thread.replies?.filter(isThreadViewPost) ?? [];
887
+ const quotes = this.state.quotes ?? [];
775
888
  const threadsHtml = replies
776
889
  .map((reply) => this.renderThread(reply))
777
890
  .join("");
778
891
  const commentCount = this.countComments(replies);
892
+ const titleText =
893
+ commentCount > 0
894
+ ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}`
895
+ : "Comments";
896
+ const quotesHtml = this.renderQuotesSection(quotes);
779
897
 
780
898
  this.commentsContainer.innerHTML = `
781
899
  <div class="sequoia-comments-header">
782
- <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
783
- <div>
784
- <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
785
- ${BLUESKY_ICON}
786
- </a>
787
- <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
788
- ${BLACKSKY_ICON}
789
- </a>
790
- </div>
900
+ <h3 class="sequoia-comments-title">${titleText}</h3>
901
+ <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
791
902
  </div>
792
903
  <div class="sequoia-comments-list">
793
904
  ${threadsHtml}
794
905
  </div>
906
+ ${quotesHtml}
795
907
  `;
796
908
  break;
797
909
  }
@@ -820,6 +932,24 @@ class SequoiaComments extends BaseElement {
820
932
  return result;
821
933
  }
822
934
 
935
+ /**
936
+ * Render the reply-button slot. Any element with slot="reply-button" in the
937
+ * light DOM is projected here and remains styleable by external CSS.
938
+ * The default Bluesky/Blacksky buttons are used as fallback content.
939
+ */
940
+ renderReplyButtons(postUrl, blackskyPostUrl) {
941
+ return `
942
+ <slot name="reply-button">
943
+ <a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
944
+ ${BLUESKY_ICON}
945
+ </a>
946
+ <a href="${escapeHtml(blackskyPostUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
947
+ ${BLACKSKY_ICON}
948
+ </a>
949
+ </slot>
950
+ `;
951
+ }
952
+
823
953
  /**
824
954
  * Render a complete thread (top-level comment + all nested replies)
825
955
  */
@@ -834,6 +964,29 @@ class SequoiaComments extends BaseElement {
834
964
  return `<div class="sequoia-thread">${commentsHtml}</div>`;
835
965
  }
836
966
 
967
+ /**
968
+ * Render a section of quote posts below the replies
969
+ * @param {Array} quotes - Array of PostView objects from getQuotes
970
+ */
971
+ renderQuotesSection(quotes) {
972
+ if (quotes.length === 0) return "";
973
+
974
+ const quotesHtml = quotes
975
+ .map((post) => {
976
+ return `<div class="sequoia-thread">${this.renderComment(post, false, 0)}</div>`;
977
+ })
978
+ .join("");
979
+
980
+ return `
981
+ <div class="sequoia-quotes-section">
982
+ <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4>
983
+ <div class="sequoia-comments-list">
984
+ ${quotesHtml}
985
+ </div>
986
+ </div>
987
+ `;
988
+ }
989
+
837
990
  /**
838
991
  * Render a single comment
839
992
  * @param {any} post - Post data
@@ -850,6 +1003,7 @@ class SequoiaComments extends BaseElement {
850
1003
  const profileUrl = `https://bsky.app/profile/${author.did}`;
851
1004
  const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
852
1005
  const timeAgo = formatRelativeTime(post.record.createdAt);
1006
+ const timeHtml = `<a href="${escapeHtml(buildBskyAppUrl(post.uri))}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>`;
853
1007
  const threadLineHtml = showThreadLine
854
1008
  ? '<div class="sequoia-thread-line"></div>'
855
1009
  : "";
@@ -866,7 +1020,7 @@ class SequoiaComments extends BaseElement {
866
1020
  ${escapeHtml(displayName)}
867
1021
  </a>
868
1022
  <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
869
- <span class="sequoia-comment-time">${timeAgo}</span>
1023
+ ${timeHtml}
870
1024
  </div>
871
1025
  <p class="sequoia-comment-text">${textHtml}</p>
872
1026
  </div>
package/dist/index.js CHANGED
@@ -103252,7 +103252,7 @@ function isDocumentRecord(value) {
103252
103252
  if (!value || typeof value !== "object")
103253
103253
  return false;
103254
103254
  const v = value;
103255
- return v.$type === "site.standard.document" && typeof v.title === "string" && typeof v.site === "string" && typeof v.path === "string" && typeof v.textContent === "string" && typeof v.publishedAt === "string" && (v.updatedAt === undefined || typeof v.updatedAt === "string");
103255
+ return v.$type === "site.standard.document" && typeof v.title === "string" && typeof v.site === "string" && typeof v.path === "string" && (v.textContent === undefined || typeof v.textContent === "string") && typeof v.publishedAt === "string" && (v.updatedAt === undefined || typeof v.updatedAt === "string");
103256
103256
  }
103257
103257
  async function fileExists3(filePath) {
103258
103258
  try {
@@ -104764,10 +104764,13 @@ Matching documents to local files:
104764
104764
  } else {
104765
104765
  contentHash = await getContentHash(localPost.rawContent);
104766
104766
  }
104767
+ const existing = state.posts[relativeFilePath];
104767
104768
  state.posts[relativeFilePath] = {
104768
104769
  contentHash,
104769
104770
  atUri: doc.uri,
104770
- lastPublished: doc.value.publishedAt
104771
+ lastPublished: doc.value.publishedAt,
104772
+ slug: localPost.slug,
104773
+ ...existing?.bskyPostRef ? { bskyPostRef: existing.bskyPostRef } : {}
104771
104774
  };
104772
104775
  } else {
104773
104776
  unmatchedCount++;
@@ -105319,24 +105322,7 @@ async function updateConfigFlow(config, configPath) {
105319
105322
  initialValue: true
105320
105323
  }));
105321
105324
  if (shouldSave) {
105322
- const configContent = generateConfigTemplate({
105323
- siteUrl: configUpdated.siteUrl,
105324
- contentDir: configUpdated.contentDir,
105325
- imagesDir: configUpdated.imagesDir,
105326
- publicDir: configUpdated.publicDir,
105327
- outputDir: configUpdated.outputDir,
105328
- pathPrefix: configUpdated.pathPrefix,
105329
- publicationUri: configUpdated.publicationUri,
105330
- pdsUrl: configUpdated.pdsUrl,
105331
- frontmatter: configUpdated.frontmatter,
105332
- ignore: configUpdated.ignore,
105333
- removeIndexFromSlug: configUpdated.removeIndexFromSlug,
105334
- stripDatePrefix: configUpdated.stripDatePrefix,
105335
- pathTemplate: configUpdated.pathTemplate,
105336
- textContentField: configUpdated.textContentField,
105337
- publishContent: configUpdated.publishContent,
105338
- bluesky: configUpdated.bluesky
105339
- });
105325
+ const configContent = JSON.stringify(configUpdated, null, 2);
105340
105326
  await fs16.writeFile(configPath, configContent);
105341
105327
  R2.success("Configuration saved!");
105342
105328
  } else {
@@ -105684,7 +105670,7 @@ Publish evergreen content to the ATmosphere
105684
105670
 
105685
105671
  > https://tangled.org/stevedylan.dev/sequoia
105686
105672
  `,
105687
- version: "0.5.4",
105673
+ version: "0.5.6",
105688
105674
  cmds: {
105689
105675
  add: addCommand,
105690
105676
  auth: authCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sequoia-cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sequoia": "dist/index.js"