← 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
M modules/channels/index.js
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 →