Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/syndicator-bluesky/lib/bluesky.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { getCanonicalUrl, isSameOrigin } from "@indiekit/util";

import {
createRichText,
getHtmlLinks,
getLinkFacets,
getPostImage,
getPostText,
getPostParts,
Expand Down Expand Up @@ -306,7 +308,11 @@ export class Bluesky {
this.includePermalink,
this.includeCategories,
);
const richText = await createRichText(client, text);
const richText = await createRichText(
client,
text,
getLinkFacets(text, getHtmlLinks(properties.content?.html || "")),
);

return this.postPost(richText, images, reply);
}
Expand Down
81 changes: 80 additions & 1 deletion packages/syndicator-bluesky/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,94 @@ const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
* Convert plain text to rich text
* @param {import("@atproto/api").Agent} client - AT Protocol agent
* @param {string} text - Text to convert
* @param {Array<object>} [facets] - Facets to add to those detected in text
* @returns {Promise<RichText>} Rich text
*/
export const createRichText = async (client, text) => {
export const createRichText = async (client, text, facets = []) => {
const rt = new RichText({ text });
await rt.detectFacets(client);

// Add facets that don’t overlap a detected one, such as a URL in the text
const detected = rt.facets || [];
const added = facets.filter((facet) =>
detected.every(
({ index }) =>
!(
facet.index.byteStart < index.byteEnd &&
index.byteStart < facet.index.byteEnd
),
),
);

if (added.length > 0) {
rt.facets = [...detected, ...added].toSorted(
(a, b) => a.index.byteStart - b.index.byteStart,
);
}

return rt;
};

/**
* Get links from HTML
* @param {string} html - HTML
* @returns {Array<{text: string, url: string}>} Link text and URL
*/
export const getHtmlLinks = (html) => {
const links = [];

for (const match of html.matchAll(
/<a\s[^>]*href=["'](?<url>https?:\/\/[^"']+)["'][^>]*>(?<html>.*?)<\/a>/gis,
)) {
const text = match.groups.html.replaceAll(/<[^>]+>/g, "").trim();
if (text) {
links.push({ text, url: match.groups.url });
}
}

return links;
};

/**
* Get link facets for link text found in post text
*
* Bluesky only links URLs it finds in the text, so the text of an HTML link
* needs a facet pointing at its URL. Facet indices are byte offsets.
* @param {string} text - Post text
* @param {Array<{text: string, url: string}>} links - Link text and URL
* @returns {Array<object>} Link facets
* @see {@link https://docs.bsky.app/docs/advanced-guides/post-richtext}
*/
export const getLinkFacets = (text, links) => {
const encoder = new TextEncoder();
const facets = [];
let searchFrom = 0;

for (const link of links) {
// A URL as its own text is detected by Bluesky already
if (link.text === link.url) {
continue;
}

const start = text.indexOf(link.text, searchFrom);
if (start === -1) {
continue;
}

const byteStart = encoder.encode(text.slice(0, start)).byteLength;
const byteEnd = byteStart + encoder.encode(link.text).byteLength;

facets.push({
index: { byteStart, byteEnd },
features: [{ $type: "app.bsky.richtext.facet#link", uri: link.url }],
});

searchFrom = start + link.text.length;
}

return facets;
};

/**
* Get post parts (UID and CID)
* @param {string} url - Post URL
Expand Down
25 changes: 25 additions & 0 deletions packages/syndicator-bluesky/test/unit/bluesky.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,31 @@ describe("syndicator-bluesky/lib/bluesky", () => {
assert.match(result, BLUESKY_POST_URL);
});

it("Posts a post with clickable link text to Bluesky", async () => {
const result = await bluesky.post(
{
content: {
html: '<p>I like <a href="https://cheese.example">cheese</a>.</p>',
},
url: "https://foo.bar",
},
me,
);
const post = await bluesky.getPost(result);
const facets = post.value.facets.filter(
(facet) => facet.features[0].uri === "https://cheese.example",
);

// The link text, and the URL appended to the text
assert.deepEqual(
facets.map((facet) => facet.index),
[
{ byteStart: 7, byteEnd: 13 },
{ byteStart: 15, byteEnd: 37 },
],
);
});

it("Posts a post with photo to Bluesky", async () => {
const result = await bluesky.post(
{
Expand Down
62 changes: 62 additions & 0 deletions packages/syndicator-bluesky/test/unit/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
createHashtags,
createRichText,
constrainImage,
getHtmlLinks,
getLinkFacets,
getPostImage,
getPostParts,
getPostText,
Expand Down Expand Up @@ -237,6 +239,66 @@ describe("syndicator-bluesky/lib/utils", async () => {
assert.equal(result, "Hello world, hello moon. https://moon.example");
});

it("Gets links from HTML", () => {
const result = getHtmlLinks(
'<p>Hello <a href="/hello">world</a>, hello <a href="https://moon.example"><em>moon</em> base</a>.</p>',
);

assert.deepEqual(result, [
{ text: "moon base", url: "https://moon.example" },
]);
});

it("Gets link facets for link text within post text", () => {
const result = getLinkFacets("Héllo moon, moon.", [
{ text: "moon", url: "https://moon.example" },
{ text: "moon", url: "https://moon.example/2" },
{ text: "https://sun.example", url: "https://sun.example" },
{ text: "mars", url: "https://mars.example" },
]);

assert.deepEqual(result, [
{
index: { byteStart: 7, byteEnd: 11 },
features: [
{
$type: "app.bsky.richtext.facet#link",
uri: "https://moon.example",
},
],
},
{
index: { byteStart: 13, byteEnd: 17 },
features: [
{
$type: "app.bsky.richtext.facet#link",
uri: "https://moon.example/2",
},
],
},
]);
});

it("Converts text to rich text with link facets, keeping detected ones", async () => {
const agent = network.getSeedClient();
const text = "foo https://bar.baz moon";
const facets = getLinkFacets(text, [
{ text: "moon", url: "https://moon.example" },
{ text: "bar.baz", url: "https://overlap.example" },
]);
const result = await createRichText(agent, text, facets);

assert.deepEqual(
result.facets.map(
(facet) =>
/** @type {import("@atproto/api").AppBskyRichtextFacet.Link} */ (
facet.features[0]
).uri,
),
["https://bar.baz", "https://moon.example"],
);
});

it("Converts Bluesky URI to post URL", () => {
const result = uriToPostUrl(
"https://app.example/profile",
Expand Down
Loading