A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * Linkify a writer's Bluesky bio the way the Bluesky client does.
3 *
4 * `app.bsky.actor.profile.description` is plain text — profile records carry no `facets`
5 * (unlike `app.bsky.feed.post`). bsky.app computes the links at render time via
6 * `RichText.detectFacets()`. We do the same with `detectFacetsWithoutResolution()`, which
7 * needs no network: we link mentions by handle (not DID), so nothing has to be resolved.
8 * That also keeps this off the SSRF-guarded fetch path entirely. (Decision 0015.)
9 *
10 * Returns ordered segments; the caller renders them with auto-escaping, never `set:html`.
11 */
12import { RichText } from '@atproto/api';
13import { atmosphereProfileUrl, atmosphereHashtagUrl } from '../social/atmosphere-url';
14
15export type BioSegment =
16 | { type: 'text'; text: string }
17 | { type: 'link'; text: string; href: string };
18
19/**
20 * The original `uri` if it is an http(s) URL, else null. `detectFacets` should only ever
21 * produce http(s) links, but this guarantees a hostile PDS can't smuggle e.g. a
22 * `javascript:` URL through. Returns the raw uri (not a normalised `URL.href`) so display
23 * fidelity matches what the writer typed.
24 */
25export function safeHttpHref( uri: string ): string | null {
26 try {
27 const { protocol } = new URL( uri );
28 return protocol === 'http:' || protocol === 'https:' ? uri : null;
29 } catch {
30 return null;
31 }
32}
33
34export function detectBioSegments( description: string ): BioSegment[] {
35 if ( ! description.trim() ) {
36 return [];
37 }
38
39 const richText = new RichText( { text: description } );
40 richText.detectFacetsWithoutResolution();
41
42 const segments: BioSegment[] = [];
43 for ( const segment of richText.segments() ) {
44 let href: string | null = null;
45
46 if ( segment.isLink() && segment.link ) {
47 href = safeHttpHref( segment.link.uri );
48 } else if ( segment.isMention() ) {
49 const handle = segment.text.replace( /^@/, '' );
50 href = handle ? atmosphereProfileUrl( handle ) : null;
51 } else if ( segment.isTag() ) {
52 const tag = segment.text.replace( /^#/, '' );
53 href = tag ? atmosphereHashtagUrl( tag ) : null;
54 }
55
56 segments.push(
57 href
58 ? { type: 'link', text: segment.text, href }
59 : { type: 'text', text: segment.text }
60 );
61 }
62
63 return segments;
64}