[object Object]

← back to Marketing Command Center

bluesky: rich-text FACETS — #hashtags + links now clickable (UTF-8 byte-offset aware per docs.bsky.app)

19aea363d747909f57bfc092921264c0adde8e6e · 2026-07-17 09:24:36 -0700 · Steve Abrams

Files touched

Diff

commit 19aea363d747909f57bfc092921264c0adde8e6e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 17 09:24:36 2026 -0700

    bluesky: rich-text FACETS — #hashtags + links now clickable (UTF-8 byte-offset aware per docs.bsky.app)
---
 modules/channels/index.js | 33 ++++++++++++++++++++++++++++++++-
 1 file changed, 32 insertions(+), 1 deletion(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index e43cd20..cc2d3a8 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -436,6 +436,34 @@ async function postYouTube() {
   // for URL-only v1. Staged with a clear note until the upload pipeline is wired.
   return [{ ok: false, error: 'YouTube upload pipeline not wired (needs resumable binary upload)' }];
 }
+// Rich-text FACETS for a Bluesky post — makes #hashtags and links clickable.
+// Per the AT Protocol spec (docs.bsky.app), facet index offsets are UTF-8 BYTE
+// positions, NOT JS string indices — so emoji/multibyte in the caption don't shift
+// them. We measure with Buffer.byteLength on the substring up to each match.
+function detectFacets(text) {
+  const facets = [];
+  const byteLen = s => Buffer.byteLength(s, 'utf8');
+  // #hashtags — # then a non-digit, run of non-space; strip trailing punctuation
+  const tagRe = /(?:^|\s)(#[^\s#]+)/g;
+  let m;
+  while ((m = tagRe.exec(text)) !== null) {
+    let tag = m[1].replace(/[\p{P}]+$/u, '');          // drop trailing punctuation
+    if (tag.length < 2 || /^#\d/.test(tag)) continue;   // skip bare # / #123
+    const at = m.index + m[0].indexOf('#');
+    const byteStart = byteLen(text.slice(0, at));
+    facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(tag) }, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag.slice(1) }] });
+  }
+  // explicit http(s) links + bare domains (e.g. designerwallcoverings.com) → link facet
+  const urlRe = /(?:^|\s)(https?:\/\/[^\s]+|(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s]*)?)/gi;
+  while ((m = urlRe.exec(text)) !== null) {
+    let token = m[1].replace(/[.,;:!?)]+$/, '');
+    const uri = /^https?:\/\//i.test(token) ? token : 'https://' + token;
+    const at = m.index + m[0].indexOf(m[1]);
+    const byteStart = byteLen(text.slice(0, at));
+    facets.push({ index: { byteStart, byteEnd: byteStart + byteLen(token) }, features: [{ $type: 'app.bsky.richtext.facet#link', uri }] });
+  }
+  return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
+}
 async function postBluesky(content) {
   // AT Protocol: createSession (handle + app password) → optional uploadBlob for the
   // image → createRecord (app.bsky.feed.post). No OAuth, no review. 300-char limit.
@@ -462,7 +490,10 @@ async function postBluesky(content) {
       if (up.ok && uj.blob) embed = { $type: 'app.bsky.embed.images', images: [{ alt: (content.caption || '').slice(0, 280), image: uj.blob }] };
     } catch { /* post text-only if image fetch/upload fails */ }
   }
-  const record = { $type: 'app.bsky.feed.post', text: (content.caption || '').slice(0, 300), createdAt: new Date().toISOString() };
+  const text = (content.caption || '').slice(0, 300);
+  const record = { $type: 'app.bsky.feed.post', text, createdAt: new Date().toISOString() };
+  const facets = detectFacets(text);            // clickable #hashtags + links (docs.bsky.app)
+  if (facets.length) record.facets = facets;
   if (embed) record.embed = embed;
   const r = await fetch(`${PDS}/com.atproto.repo.createRecord`, {
     method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${jwt}` },

← c16d138 Vendor IG: mark BN Walls 'none found' (no Business-Discovery  ·  back to Marketing Command Center  ·  auto-save: 2026-07-17T09:46:05 (2 files) — modules/channels/ c333624 →