@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
360 lines (359 loc) • 13.4 kB
JavaScript
/**
* @fileoverview Utilities for detecting Bluesky facets in text.
* @author Nicholas C. Zakas
*/
/* global TextEncoder */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { createRequire } from "node:module";
//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------
export const BLUESKY_URL_FACET = "app.bsky.richtext.facet#link";
export const BLUESKY_TAG_FACET = "app.bsky.richtext.facet#tag";
export const BLUESKY_MENTION_FACET = "app.bsky.richtext.facet#mention";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {Object} ByteRange
* @property {number} byteStart The byte offset of the start of the range.
* @property {number} byteEnd The byte offset of the end of
*/
/**
* @typedef {Object} URIDetails
* @property {string} uri The URI of the facet.
* @property {ByteRange} byteRange The byte range of the facet in the text.
*/
/**
* @typedef {Object} TagDetails
* @property {string} tag The tag of the facet.
* @property {ByteRange} byteRange The byte range of the facet in the text.
*/
/**
* @typedef {Object} MentionDetails
* @property {string} handle The handle of the mentioned user.
* @property {ByteRange} byteRange The byte range of the facet in the text.
*/
/**
* @typedef {Object} BlueSkyFacet
* @property {ByteRange} index The byte range of the facet in the text.
* @property {Array<BlueSkyURIFacetFeature|BlueSkyTagFacetFeature|BlueSkyMentionFacetFeature>} features The features of the facet.
*/
//-----------------------------------------------------------------------------
// Patterns
//-----------------------------------------------------------------------------
/*
* The following four patterns were taken from the atproto project.
* https://github.com/bluesky-social/atproto
*
* Copyright (c) 2022-2024 Bluesky PBC, and Contributors
* Licensed under the Apache License, Version 2.0
* http://www.apache.org/licenses/LICENSE-2.0
*/
// const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z][a-zA-Z0-9.-]*[a-zA-Z0-9]|[a-zA-Z])(\b)/g;
const URL_REGEX = /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim;
const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu;
/**
* `\ufe0f` emoji modifier
* `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete)
*/
const TAG_REGEX =
// eslint-disable-next-line no-misleading-character-class
/(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu;
const LEADING_WHITESPACE_REGEX = /^\s/u;
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const require = createRequire(import.meta.url);
const tlds = require("tlds");
const encoder = new TextEncoder();
/**
* A Bluesky URI facet feature.
*/
class BlueSkyURIFacetFeature {
/**
* The URI of the facet.
* @type {string}
*/
uri;
/**
* The type of facet.
* @type {string}
* @const
*/
$type = BLUESKY_URL_FACET;
/**
* Creates a new instance.
* @param {string} uri The URI of the facet.
*/
constructor(uri) {
this.uri = uri;
}
}
/**
* A Bluesky tag facet feature.
*/
class BlueSkyTagFacetFeature {
/**
* The tag of the facet.
* @type {string}
*/
tag;
/**
* The type of facet.
* @type {string}
* @const
*/
$type = BLUESKY_TAG_FACET;
/**
* Creates a new instance.
* @param {string} tag The tag of the facet.
*/
constructor(tag) {
this.tag = tag;
}
}
/**
* A Bluesky mention facet feature.
*/
class BlueSkyMentionFacetFeature {
/**
* The DID of the mentioned user.
* Note: When created by detectMentions(), this initially contains the handle.
* The BlueSky strategy resolves handles to actual DIDs before posting.
* @type {string}
*/
did;
/**
* The type of facet.
* @type {string}
* @const
*/
$type = BLUESKY_MENTION_FACET;
/**
* Creates a new instance.
* @param {string} did The DID of the mentioned user (initially the handle, resolved by the strategy).
*/
constructor(did) {
this.did = did;
}
}
/**
* Determines if a given domain contains a valid TLD.
* @param {string} domain The domain to check.
* @returns {boolean} True if the domain has a valid TLD, false otherwise.
*/
function hasValidTLD(domain) {
const dotLocation = domain.lastIndexOf(".");
const tld = domain.slice(dotLocation + 1);
return tlds.indexOf(tld) !== -1;
}
/**
* Gets the byte offsets for a given range of text.
* @param {string} text The text to search.
* @param {number} start The start of the range.
* @param {number} end The end of the range.
* @returns {ByteRange} An object with `byteStart` and `byteEnd` properties.
*/
function getByteOffsets(text, start, end) {
return {
byteStart: encoder.encode(text.slice(0, start)).byteLength,
byteEnd: encoder.encode(text.slice(0, end)).byteLength,
};
}
/**
* Truncates a URL to 27 characters, ensuring the last three characters are '...'.
* If the provided URL is 27 characters or shorter, it's returned unchanged.
* @param {string} url The URL to truncate.
* @returns {string} The truncated URL (27 chars) or the original if shorter.
*/
export function truncateUrl(url) {
const MAX = 27;
const ELLIPSIS = "...";
if (url.length <= MAX) {
return url;
}
// Keep total length at MAX, reserving 3 chars for ellipsis
const keep = MAX - ELLIPSIS.length;
return url.slice(0, keep) + ELLIPSIS;
}
/**
* Detects all URLs in the given text and returns an object containing the
* detected URIs (with byte ranges based on the original text) and a new text
* where each URL has been replaced with a truncated representation.
* @param {string} text The text to search.
* @returns {{ uris: URIDetails[], text: string }} An object with `uris` and updated `text`.
*/
function detectURLs(text) {
// Reset regex state in case it's been used elsewhere
URL_REGEX.lastIndex = 0;
const uris = [];
let match;
// Build newText incrementally as we process matches so indices remain
// correct and truncation uses the original displayed substring.
let newText = "";
let lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
const original = match[2];
let uri = original;
// if it doesn't start with http then we need to check the domain
if (!uri.startsWith("http")) {
const domain = match.groups?.domain;
if (!domain || !hasValidTLD(domain)) {
continue;
}
// we made it here so let's add the protocol for the stored URI
uri = `https://${uri}`;
}
// now calculate the location of the URL (character offsets)
let start = match.index;
// strip any leading whitespace from overall match
if (LEADING_WHITESPACE_REGEX.test(match[0])) {
start += 1;
}
let end = start + original.length;
// strip any ending punctation for the stored URI. The byte range
// will be adjusted later based on the actual matched trailing
// punctuation length to avoid double-subtraction.
if (TRAILING_PUNCTUATION_REGEX.test(uri)) {
uri = uri.replace(TRAILING_PUNCTUATION_REGEX, "");
}
// append the segment before this URL
newText += text.slice(lastIndex, start);
// For display/truncation, use the original substring from the
// original text but exclude any trailing punctuation that was
// removed from the stored URI. This ensures the facet range does
// not include punctuation characters.
const trailingMatch = match[2].match(TRAILING_PUNCTUATION_REGEX);
const trailingLength = trailingMatch ? trailingMatch[0].length : 0;
const displayText = text.slice(start, end - trailingLength);
// Determine the displayed/truncated version that will be inserted
// into the new text and compute its byte offsets relative to the
// truncated text (newText + display).
const display = truncateUrl(displayText);
const displayStartByte = encoder.encode(newText).byteLength;
const displayEndByte = encoder.encode(newText + display).byteLength;
uris.push({
uri,
byteRange: {
byteStart: displayStartByte,
byteEnd: displayEndByte,
},
});
newText += display;
lastIndex = end;
}
// append any remaining text after the last match
newText += text.slice(lastIndex);
return { uris, text: newText };
}
/**
* Detects all hashtags in the given text and returns an array noting the byte location
* of the hashtags in the text.
* @param {string} text The text to search.
* @returns {TagDetails[]} An array of TagDetails objects.
*/
function detectTags(text) {
// Reset regex state in case it's been used elsewhere
TAG_REGEX.lastIndex = 0;
const matches = [];
let match;
while ((match = TAG_REGEX.exec(text)) !== null) {
let tag = match[2];
// Skip if the tag is empty or undefined
if (!tag) {
continue;
}
// Strip any trailing punctuation (similar to URLs)
if (TRAILING_PUNCTUATION_REGEX.test(tag)) {
tag = tag.replace(TRAILING_PUNCTUATION_REGEX, "");
}
// Calculate the location of the hashtag (includes the # symbol)
let start = match.index;
// Strip any leading whitespace from overall match
if (LEADING_WHITESPACE_REGEX.test(match[0])) {
start += 1;
}
// Start after the # symbol
const hashSymbolStart = start;
const tagEnd = hashSymbolStart + 1 + tag.length;
matches.push({
tag,
byteRange: getByteOffsets(text, hashSymbolStart, tagEnd),
});
}
return matches;
}
/**
* Detects all mentions in the given text and returns an array noting the byte location
* of the mentions in the text.
* @param {string} text The text to search.
* @returns {MentionDetails[]} An array of MentionDetails objects.
*/
function detectMentions(text) {
// Reset regex state in case it's been used elsewhere
MENTION_REGEX.lastIndex = 0;
const matches = [];
let match;
while ((match = MENTION_REGEX.exec(text)) !== null) {
let handle = match[3];
// Skip if the handle is empty or undefined
if (!handle) {
continue;
}
// Strip any trailing punctuation (similar to URLs)
if (TRAILING_PUNCTUATION_REGEX.test(handle)) {
handle = handle.replace(TRAILING_PUNCTUATION_REGEX, "");
}
// Calculate the location of the mention (includes the @ symbol)
let start = match.index;
// Strip any leading whitespace or punctuation from overall match
if (LEADING_WHITESPACE_REGEX.test(match[0])) {
start += 1;
}
else if (match[1] && match[1] !== "") {
// If there's a prefix character like '(', adjust the start position
start += match[1].length;
}
// Start at the @ symbol position
const atSymbolStart = start;
const mentionEnd = atSymbolStart + 1 + handle.length;
matches.push({
handle,
byteRange: getByteOffsets(text, atSymbolStart, mentionEnd),
});
}
return matches;
}
/**
* Detects rich text facets in the given text.
* This function first detects URLs and replaces them with truncated
* representations for further detection of tags and mentions. It returns an
* object containing the detected facets (with byte ranges calculated against
* the original text) and the updated text where URLs are truncated.
* @param {string} text The text to search.
* @returns {{facets: Array<BlueSkyFacet>, text: string}} An object with `facets` (array of BlueSkyFacet)
* and `text` (the text with URLs replaced by truncated versions).
*/
export function detectFacets(text) {
// detect URLs first and get the text with truncated URLs for further detection
const { uris, text: truncatedText } = detectURLs(text);
const facets = [
...uris.map(url => ({
index: url.byteRange,
features: [new BlueSkyURIFacetFeature(url.uri)],
})),
...detectTags(truncatedText).map(tag => ({
index: tag.byteRange,
features: [new BlueSkyTagFacetFeature(tag.tag)],
})),
...detectMentions(truncatedText).map(mention => ({
index: mention.byteRange,
features: [new BlueSkyMentionFacetFeature(mention.handle)],
})),
];
return { facets, text: truncatedText };
}