bsky-richtext-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [1.0.0] — 2026-02-17
11
+
12
+ First stable release. 🎉
13
+
14
+ ### Added
15
+
16
+ #### Components
17
+
18
+ - **`<RichTextDisplay>`** — renders AT Protocol `{ text, facets }` records as interactive HTML
19
+ - Supports `app.bsky.richtext.facet#mention`, `#link`, and `#tag` facet types
20
+ - Default renderers link mentions to `bsky.app/profile`, hashtags to `bsky.app/hashtag`
21
+ - `renderMention`, `renderLink`, `renderTag` render props for fully custom output
22
+ - `mentionUrl`, `tagUrl`, `linkUrl` props for custom URL generation without replacing the full renderer
23
+ - `classNames` prop for targeted CSS class overrides (works with `generateClassNames()`)
24
+ - `disableLinks` prop to render all facets as plain text
25
+ - `linkProps` forwarded to every default `<a>` element
26
+ - Handles UTF-8 byte-offset arithmetic for multi-byte and emoji text
27
+
28
+ - **`<RichTextEditor>`** — TipTap-based WYSIWYG editor for composing AT Protocol richtext
29
+ - Built-in @mention autocomplete powered by the **Bluesky public API** (no auth required)
30
+ - Mention search is debounced (default 300 ms, configurable via `mentionSearchDebounceMs`)
31
+ - `onMentionQuery` prop to supply a custom search function (overrides the default)
32
+ - `disableDefaultMentionSearch` prop to disable the built-in search entirely
33
+ - URL decoration using a **stateless ProseMirror `DecorationSet`** (no stateful marks — URLs end cleanly when followed by a space)
34
+ - Hard-break (Shift+Enter) support
35
+ - Undo / redo history
36
+ - `onChange` emits a `RichTextRecord` with facets populated via `detectFacetsWithoutResolution()`
37
+ - `classNames` prop for deep-customisable styling (use `generateClassNames()`)
38
+ - Imperative `editorRef` API: `focus()`, `blur()`, `clear()`, `getText()`
39
+ - `editable` prop to toggle read-only mode
40
+ - Paste handler strips HTML formatting and inserts plain text
41
+
42
+ - **`<MentionSuggestionList>`** — default @mention autocomplete dropdown
43
+ - Avatar image support with initial-letter fallback
44
+ - `classNames` prop for full visual customisation
45
+ - `showAvatars` and `noResultsText` convenience props
46
+ - Keyboard navigation: ↑ / ↓ to move, Enter / Tab to select, Escape to dismiss
47
+ - Exported for use as a reference or reuse in custom popup implementations
48
+
49
+ #### Utilities
50
+
51
+ - **`generateClassNames(classNamesArray, cn?)`** — deep-merge utility for classNames objects
52
+ - Array API: pass multiple partial classNames objects merged left-to-right
53
+ - Later entries override earlier ones; strings at the same key are combined
54
+ - Recursively merges nested objects (e.g. `EditorClassNames.suggestion`)
55
+ - Falsy array entries (`undefined`, `null`, `false`) are silently skipped — enables clean conditional overrides
56
+ - Optional `cn` parameter accepts any class utility (`clsx`, `tailwind-merge`, etc.)
57
+
58
+ - **`searchBskyActors(query, limit?)`** — query the Bluesky public search API
59
+ - Unauthenticated, no API key required
60
+ - Returns an array of `MentionSuggestion` objects
61
+ - Fails gracefully (returns `[]`) on network errors or empty queries
62
+
63
+ - **`createDebouncedSearch(delayMs?)`** — create a debounced wrapper around `searchBskyActors`
64
+ - All pending callers during the debounce window receive the same result
65
+ - Default delay: 300 ms
66
+
67
+ - **`useRichText(record)`** — low-level hook that parses `RichTextRecord` into `RichTextSegment[]`
68
+
69
+ - **`parseRichText(record)`** — synchronous parser (non-hook variant)
70
+
71
+ - **`toShortUrl(url, maxLength?)`** — display-friendly URL shortener (strips protocol, truncates)
72
+
73
+ - **`isValidUrl(url)`** — URL validation utility
74
+
75
+ #### Types
76
+
77
+ - `RichTextRecord`, `Facet`, `ByteSlice`
78
+ - `MentionFeature`, `LinkFeature`, `TagFeature`, `FacetFeature`
79
+ - `RichTextSegment`
80
+ - `DisplayClassNames`, `EditorClassNames`, `SuggestionClassNames`
81
+ - `MentionSuggestion`, `RichTextEditorRef`
82
+ - `ClassNameFn`
83
+
84
+ #### Type guards
85
+
86
+ - `isMentionFeature()`, `isLinkFeature()`, `isTagFeature()`
87
+
88
+ #### Default classNames objects
89
+
90
+ - `defaultDisplayClassNames` — starting point for `<RichTextDisplay>` styling
91
+ - `defaultEditorClassNames` — starting point for `<RichTextEditor>` styling
92
+ - `defaultSuggestionClassNames` — starting point for `<MentionSuggestionList>` styling
93
+
94
+ #### CSS
95
+
96
+ - `styles.css` — structural-only (layout, box-sizing, word-break) — no colours or fonts
97
+ - Class-based selectors: `.bsky-richtext`, `.bsky-mention`, `.bsky-editor`, `.bsky-suggestions`, etc.
98
+
99
+ ---
100
+
101
+ [1.0.0]: https://github.com/satyam-mishra-pce/bsky-richtext-react/releases/tag/v1.0.0
package/README.md ADDED
@@ -0,0 +1,589 @@
1
+ # bsky-richtext-react
2
+
3
+ > React components for rendering and editing [Bluesky](https://bsky.app) richtext content — the [`app.bsky.richtext.facet`](https://atproto.com/lexicons/app-bsky-richtext) AT Protocol lexicon.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/bsky-richtext-react.svg)](https://www.npmjs.com/package/bsky-richtext-react)
6
+ [![CI](https://github.com/satyam-mishra-pce/bsky-richtext-react/actions/workflows/ci.yml/badge.svg)](https://github.com/satyam-mishra-pce/bsky-richtext-react/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Bundle Size](https://img.shields.io/bundlephobia/minzip/bsky-richtext-react)](https://bundlephobia.com/package/bsky-richtext-react)
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ - **`<RichTextDisplay>`** — Render AT Protocol richtext records (`text` + `facets`) as interactive HTML. Handles @mentions, links, and #hashtags with fully customisable renderers and URL resolvers.
15
+ - **`<RichTextEditor>`** — TipTap-based editor with real-time @mention autocomplete (powered by the **Bluesky public API** by default — no auth required), stateless URL decoration, undo/redo, and an imperative ref API.
16
+ - **`generateClassNames()`** — Deep-merge utility for the `classNames` prop system. Pass an array of partial classNames objects and get one merged result, optionally using your own `cn()` / `clsx` / `tailwind-merge` utility.
17
+ - **Headless by design** — Ships with layout-only CSS. Bring your own colours, fonts, and borders.
18
+ - **Fully typed** — TypeScript-first with complete type definitions for all AT Protocol facet types.
19
+ - **Tree-shakeable** — ESM + CJS dual build via `tsup`.
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ # bun (recommended)
27
+ bun add bsky-richtext-react
28
+
29
+ # npm
30
+ npm install bsky-richtext-react
31
+
32
+ # pnpm
33
+ pnpm add bsky-richtext-react
34
+ ```
35
+
36
+ Peer dependencies (if not already installed):
37
+
38
+ ```bash
39
+ bun add react react-dom
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Quick Start
45
+
46
+ ### Rendering a post
47
+
48
+ ```tsx
49
+ import { RichTextDisplay } from 'bsky-richtext-react'
50
+ import 'bsky-richtext-react/styles.css'
51
+
52
+ // Pass raw fields from an app.bsky.feed.post record
53
+ export function Post({ post }) {
54
+ return <RichTextDisplay value={{ text: post.text, facets: post.facets }} />
55
+ }
56
+ ```
57
+
58
+ ### Composing a post
59
+
60
+ ```tsx
61
+ import { RichTextEditor } from 'bsky-richtext-react'
62
+ import 'bsky-richtext-react/styles.css'
63
+
64
+ export function Composer() {
65
+ return (
66
+ <RichTextEditor
67
+ placeholder="What's on your mind?"
68
+ onChange={(record) => {
69
+ // record.text — plain UTF-8 text
70
+ // record.facets — mentions (as handles), links, hashtags
71
+ console.log(record)
72
+ }}
73
+ />
74
+ )
75
+ }
76
+ ```
77
+
78
+ > **The editor uses the Bluesky public API for @mention search by default.** Type `@` followed by a handle to see live suggestions — no API key or authentication required. See [Mention Search](#mention-search) to customise or disable this.
79
+
80
+ ---
81
+
82
+ ## Styling
83
+
84
+ ### 1. Import the structural CSS
85
+
86
+ ```ts
87
+ import 'bsky-richtext-react/styles.css'
88
+ ```
89
+
90
+ This only sets `display`, `word-break`, `box-sizing`, and flex layout rules. **No colours, fonts, or borders are applied.** You control all visual styling.
91
+
92
+ ### 2. Target the default class names
93
+
94
+ Every element rendered by the components carries a predictable CSS class that you can target directly:
95
+
96
+ #### `<RichTextDisplay>`
97
+
98
+ | Class | Element |
99
+ |-------|---------|
100
+ | `.bsky-richtext` | Root `<span>` |
101
+ | `.bsky-mention` | @mention `<a>` |
102
+ | `.bsky-link` | Link `<a>` |
103
+ | `.bsky-tag` | #hashtag `<a>` |
104
+
105
+ #### `<RichTextEditor>`
106
+
107
+ | Class | Element |
108
+ |-------|---------|
109
+ | `.bsky-editor` | Root wrapper `<div>` |
110
+ | `.bsky-editor-content` | ProseMirror wrapper |
111
+ | `.bsky-editor-mention` | Mention chip inside editor |
112
+ | `.autolink` | URL decoration span inside editor |
113
+
114
+ #### `<MentionSuggestionList>` (dropdown)
115
+
116
+ | Class | Element |
117
+ |-------|---------|
118
+ | `.bsky-suggestions` | Outer container |
119
+ | `.bsky-suggestion-item` | Each suggestion row (`<button>`) |
120
+ | `.bsky-suggestion-item-selected` | Currently highlighted row |
121
+ | `.bsky-suggestion-avatar` | Avatar wrapper |
122
+ | `.bsky-suggestion-avatar-img` | `<img>` element |
123
+ | `.bsky-suggestion-avatar-placeholder` | Initial-letter fallback |
124
+ | `.bsky-suggestion-text` | Text column (name + handle) |
125
+ | `.bsky-suggestion-name` | Display name |
126
+ | `.bsky-suggestion-handle` | `@handle` |
127
+ | `.bsky-suggestion-empty` | "No results" message |
128
+
129
+ ```css
130
+ /* Example: style the editor */
131
+ .bsky-editor .ProseMirror {
132
+ padding: 12px 16px;
133
+ border: 1px solid #e1e4e8;
134
+ border-radius: 8px;
135
+ font-size: 16px;
136
+ line-height: 1.5;
137
+ min-height: 120px;
138
+ }
139
+
140
+ /* Example: style mentions in a post */
141
+ .bsky-mention {
142
+ color: #0085ff;
143
+ font-weight: 600;
144
+ text-decoration: none;
145
+ }
146
+ .bsky-mention:hover { text-decoration: underline; }
147
+
148
+ /* Example: style the suggestion dropdown */
149
+ .bsky-suggestions {
150
+ background: #fff;
151
+ border: 1px solid #e1e4e8;
152
+ border-radius: 8px;
153
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
154
+ min-width: 240px;
155
+ padding: 4px;
156
+ }
157
+ .bsky-suggestion-item { padding: 8px 10px; border-radius: 6px; }
158
+ .bsky-suggestion-item-selected { background: #f0f4ff; }
159
+ ```
160
+
161
+ ### 3. Use `generateClassNames()` for targeted overrides
162
+
163
+ The `classNames` prop on each component accepts a nested object. Use `generateClassNames()` to cleanly layer your classes on top of the defaults without rewriting them from scratch:
164
+
165
+ ```tsx
166
+ import {
167
+ RichTextEditor,
168
+ generateClassNames,
169
+ defaultEditorClassNames,
170
+ } from 'bsky-richtext-react'
171
+
172
+ // Works with any class utility — clsx, tailwind-merge, your own cn()
173
+ import { cn } from '@/lib/utils'
174
+
175
+ <RichTextEditor
176
+ classNames={generateClassNames([
177
+ defaultEditorClassNames,
178
+ {
179
+ root: 'border rounded-lg p-3 focus-within:ring-2',
180
+ mention: 'text-blue-500 font-semibold',
181
+ suggestion: {
182
+ item: 'px-3 py-2 rounded-md',
183
+ itemSelected: 'bg-blue-50',
184
+ },
185
+ },
186
+ ], cn)}
187
+ />
188
+ ```
189
+
190
+ `generateClassNames()` accepts any number of partial classNames objects in the array. Entries are merged left-to-right; strings at the same key are combined using `cn()`. **Falsy entries are skipped**, so conditional overrides work naturally:
191
+
192
+ ```tsx
193
+ classNames={generateClassNames([
194
+ defaultEditorClassNames,
195
+ isCompact && { root: 'text-sm p-2' },
196
+ isDark && darkThemeClassNames,
197
+ ], cn)}
198
+ ```
199
+
200
+ #### Opting out of defaults
201
+
202
+ Pass a plain object to skip the defaults entirely:
203
+
204
+ ```tsx
205
+ <RichTextDisplay classNames={{ root: 'my-text', mention: 'my-mention' }} />
206
+ ```
207
+
208
+ ### 4. Tailwind integration
209
+
210
+ ```tsx
211
+ import { twMerge } from 'tailwind-merge'
212
+ import { clsx } from 'clsx'
213
+
214
+ const cn = (...inputs) => twMerge(clsx(inputs))
215
+
216
+ <RichTextEditor
217
+ classNames={generateClassNames([
218
+ defaultEditorClassNames,
219
+ { root: 'rounded-xl border border-gray-200 p-4' },
220
+ ], cn)}
221
+ />
222
+ ```
223
+
224
+ ---
225
+
226
+ ## API Reference
227
+
228
+ ### `<RichTextDisplay>`
229
+
230
+ ```tsx
231
+ import { RichTextDisplay } from 'bsky-richtext-react'
232
+
233
+ <RichTextDisplay value={post} />
234
+ ```
235
+
236
+ | Prop | Type | Default | Description |
237
+ |------|------|---------|-------------|
238
+ | `value` | `RichTextRecord \| string` | — | The richtext to render |
239
+ | `classNames` | `Partial<DisplayClassNames>` | defaults | CSS class names for styling (use `generateClassNames()`) |
240
+ | `renderMention` | `(props: MentionProps) => ReactNode` | `<a>` to bsky.app | Custom @mention renderer |
241
+ | `renderLink` | `(props: LinkProps) => ReactNode` | `<a>` with short URL | Custom link renderer |
242
+ | `renderTag` | `(props: TagProps) => ReactNode` | `<a>` to bsky.app | Custom #hashtag renderer |
243
+ | `mentionUrl` | `(did: string) => string` | `https://bsky.app/profile/${did}` | Generate @mention `href` |
244
+ | `tagUrl` | `(tag: string) => string` | `https://bsky.app/hashtag/${tag}` | Generate #hashtag `href` |
245
+ | `linkUrl` | `(uri: string) => string` | identity | Transform link `href` (e.g. proxy URLs) |
246
+ | `disableLinks` | `boolean` | `false` | Render all facets as plain text |
247
+ | `linkProps` | `AnchorHTMLAttributes` | — | Forwarded to every default `<a>` |
248
+ | `...spanProps` | `HTMLAttributes<HTMLSpanElement>` | — | Forwarded to root `<span>` |
249
+
250
+ #### Custom routing example
251
+
252
+ ```tsx
253
+ // Point mentions and hashtags to your own app routes
254
+ <RichTextDisplay
255
+ value={post}
256
+ mentionUrl={(did) => `/profile/${did}`}
257
+ tagUrl={(tag) => `/search?tag=${encodeURIComponent(tag)}`}
258
+ />
259
+ ```
260
+
261
+ #### Custom renderer example
262
+
263
+ ```tsx
264
+ import { Link } from 'react-router-dom'
265
+
266
+ <RichTextDisplay
267
+ value={post}
268
+ renderMention={({ text, did }) => (
269
+ <Link to={`/profile/${did}`} className="mention">{text}</Link>
270
+ )}
271
+ />
272
+ ```
273
+
274
+ ---
275
+
276
+ ### `<RichTextEditor>`
277
+
278
+ ```tsx
279
+ import { RichTextEditor } from 'bsky-richtext-react'
280
+
281
+ <RichTextEditor
282
+ placeholder="What's on your mind?"
283
+ onChange={(record) => setPost(record)}
284
+ />
285
+ ```
286
+
287
+ | Prop | Type | Default | Description |
288
+ |------|------|---------|-------------|
289
+ | `initialValue` | `RichTextRecord \| string` | — | Initial content (uncontrolled) |
290
+ | `onChange` | `(record: RichTextRecord) => void` | — | Called on every content change |
291
+ | `placeholder` | `string` | — | Placeholder text when empty |
292
+ | `onFocus` | `() => void` | — | Called when editor gains focus |
293
+ | `onBlur` | `() => void` | — | Called when editor loses focus |
294
+ | `classNames` | `Partial<EditorClassNames>` | defaults | CSS class names for styling (use `generateClassNames()`) |
295
+ | `onMentionQuery` | `(query: string) => Promise<MentionSuggestion[]>` | **Bluesky public API** | Custom mention search. Overrides the built-in search. |
296
+ | `mentionSearchDebounceMs` | `number` | `300` | Debounce delay (ms) for the built-in search. No effect when `onMentionQuery` is set. |
297
+ | `disableDefaultMentionSearch` | `boolean` | `false` | Disable the built-in Bluesky API search entirely |
298
+ | `renderMentionSuggestion` | `SuggestionOptions['render']` | tippy.js popup | Custom TipTap suggestion renderer factory |
299
+ | `mentionSuggestionOptions` | `DefaultSuggestionRendererOptions` | — | Options forwarded to the default renderer |
300
+ | `editorRef` | `Ref<RichTextEditorRef>` | — | Imperative ref |
301
+ | `editable` | `boolean` | `true` | Toggle read-only mode |
302
+ | `...divProps` | `HTMLAttributes<HTMLDivElement>` | — | Forwarded to root `<div>` |
303
+
304
+ #### `RichTextEditorRef`
305
+
306
+ ```ts
307
+ interface RichTextEditorRef {
308
+ focus(): void
309
+ blur(): void
310
+ clear(): void
311
+ getText(): string
312
+ }
313
+ ```
314
+
315
+ ```tsx
316
+ const editorRef = useRef<RichTextEditorRef>(null)
317
+
318
+ editorRef.current?.focus()
319
+ editorRef.current?.clear()
320
+ const text = editorRef.current?.getText()
321
+ ```
322
+
323
+ #### Mention Search
324
+
325
+ The editor searches Bluesky actors **by default** when the user types `@`:
326
+
327
+ ```tsx
328
+ // Built-in: uses https://public.api.bsky.app, debounced 300ms
329
+ <RichTextEditor placeholder="Type @ to search…" />
330
+
331
+ // Custom debounce
332
+ <RichTextEditor mentionSearchDebounceMs={500} />
333
+
334
+ // Your own search (e.g. from an authenticated agent)
335
+ <RichTextEditor
336
+ onMentionQuery={async (query) => {
337
+ const res = await agent.searchActors({ term: query, limit: 8 })
338
+ return res.data.actors.map((a) => ({
339
+ did: a.did,
340
+ handle: a.handle,
341
+ displayName: a.displayName,
342
+ avatarUrl: a.avatar,
343
+ }))
344
+ }}
345
+ />
346
+
347
+ // Disable built-in search (no suggestions unless you set onMentionQuery)
348
+ <RichTextEditor disableDefaultMentionSearch />
349
+ ```
350
+
351
+ ---
352
+
353
+ ### `<MentionSuggestionList>`
354
+
355
+ The default suggestion dropdown rendered inside the tippy.js popup. Exported so you can reuse or reference it in your own popup implementation.
356
+
357
+ ```tsx
358
+ import { MentionSuggestionList } from 'bsky-richtext-react'
359
+ ```
360
+
361
+ | Prop | Type | Default | Description |
362
+ |------|------|---------|-------------|
363
+ | `items` | `MentionSuggestion[]` | — | Suggestion items (from TipTap) |
364
+ | `command` | `SuggestionCommand` | — | TipTap command to insert selected item |
365
+ | `classNames` | `Partial<SuggestionClassNames>` | defaults | CSS class names for styling |
366
+ | `showAvatars` | `boolean` | `true` | Show / hide avatar images |
367
+ | `noResultsText` | `string` | `"No results"` | Empty-state message |
368
+
369
+ ---
370
+
371
+ ### `useRichText(record)`
372
+
373
+ Low-level hook. Parses a `RichTextRecord` into an array of typed segments.
374
+
375
+ ```ts
376
+ import { useRichText } from 'bsky-richtext-react'
377
+
378
+ const segments = useRichText({ text: post.text, facets: post.facets })
379
+ // => [{ text: 'Hello ', feature: undefined }, { text: '@alice', feature: MentionFeature }, ...]
380
+ ```
381
+
382
+ ```ts
383
+ interface RichTextSegment {
384
+ text: string
385
+ feature?: MentionFeature | LinkFeature | TagFeature
386
+ }
387
+ ```
388
+
389
+ ---
390
+
391
+ ## Utilities
392
+
393
+ ### `generateClassNames(classNamesArray, cn?)`
394
+
395
+ Deep-merge an array of partial classNames objects into one. String values at the same key are combined (space-joined or via `cn()`). Nested objects are merged recursively. Falsy entries are skipped.
396
+
397
+ ```ts
398
+ import {
399
+ generateClassNames,
400
+ defaultEditorClassNames,
401
+ defaultDisplayClassNames,
402
+ defaultSuggestionClassNames,
403
+ } from 'bsky-richtext-react'
404
+ ```
405
+
406
+ ```ts
407
+ // Merge defaults with overrides
408
+ const classNames = generateClassNames([
409
+ defaultEditorClassNames,
410
+ { root: 'my-editor', mention: 'text-blue-500' },
411
+ ])
412
+
413
+ // Deep nested override
414
+ const classNames = generateClassNames([
415
+ defaultEditorClassNames,
416
+ { suggestion: { item: 'px-3 py-2', itemSelected: 'bg-blue-50' } },
417
+ ])
418
+
419
+ // Conditional entries (falsy values are ignored)
420
+ const classNames = generateClassNames([
421
+ defaultEditorClassNames,
422
+ isCompact && { root: 'text-sm' },
423
+ isDark && darkThemeClassNames,
424
+ ])
425
+
426
+ // With a class utility for deduplication
427
+ import { cn } from '@/lib/utils'
428
+ const classNames = generateClassNames([defaultEditorClassNames, overrides], cn)
429
+ ```
430
+
431
+ **Signature:**
432
+ ```ts
433
+ function generateClassNames<T extends object>(
434
+ classNamesArray: (Partial<T> | undefined | null | false)[],
435
+ cn?: (...inputs: (string | undefined | null | false)[]) => string,
436
+ ): T
437
+ ```
438
+
439
+ ---
440
+
441
+ ### `searchBskyActors(query, limit?)`
442
+
443
+ Fetch actor suggestions from the Bluesky public API. No authentication required.
444
+
445
+ ```ts
446
+ import { searchBskyActors } from 'bsky-richtext-react'
447
+
448
+ const suggestions = await searchBskyActors('alice', 8)
449
+ // => [{ did, handle, displayName?, avatarUrl? }, ...]
450
+ ```
451
+
452
+ Returns `[]` on empty query, network error, or non-OK response.
453
+
454
+ ---
455
+
456
+ ### `createDebouncedSearch(delayMs?)`
457
+
458
+ Create a debounced wrapper around `searchBskyActors`. Rapid calls within the window are coalesced — only the last query fires a network request, but all pending callers receive the result.
459
+
460
+ ```ts
461
+ import { createDebouncedSearch } from 'bsky-richtext-react'
462
+
463
+ const debouncedSearch = createDebouncedSearch(400)
464
+
465
+ // Use as onMentionQuery
466
+ <RichTextEditor onMentionQuery={debouncedSearch} />
467
+ ```
468
+
469
+ ---
470
+
471
+ ### Other utilities
472
+
473
+ ```ts
474
+ import { toShortUrl, isValidUrl, parseRichText } from 'bsky-richtext-react'
475
+
476
+ toShortUrl('https://example.com/some/long/path?q=1')
477
+ // => 'example.com/some/long/path?q=1'
478
+
479
+ isValidUrl('not a url') // => false
480
+
481
+ parseRichText({ text, facets }) // => RichTextSegment[]
482
+ ```
483
+
484
+ ---
485
+
486
+ ## Types
487
+
488
+ All AT Protocol facet types are exported:
489
+
490
+ ```ts
491
+ import type {
492
+ RichTextRecord, // { text: string; facets?: Facet[] }
493
+ Facet, // { index: ByteSlice; features: FacetFeature[] }
494
+ ByteSlice, // { byteStart: number; byteEnd: number }
495
+ MentionFeature, // { $type: 'app.bsky.richtext.facet#mention'; did: string }
496
+ LinkFeature, // { $type: 'app.bsky.richtext.facet#link'; uri: string }
497
+ TagFeature, // { $type: 'app.bsky.richtext.facet#tag'; tag: string }
498
+ FacetFeature, // MentionFeature | LinkFeature | TagFeature
499
+ RichTextSegment, // { text: string; feature?: FacetFeature }
500
+ MentionSuggestion, // { did, handle, displayName?, avatarUrl? }
501
+ RichTextEditorRef, // { focus, blur, clear, getText }
502
+ } from 'bsky-richtext-react'
503
+ ```
504
+
505
+ ClassNames types:
506
+
507
+ ```ts
508
+ import type {
509
+ DisplayClassNames, // { root?, mention?, link?, tag? }
510
+ EditorClassNames, // { root?, content?, mention?, link?, suggestion?, ... }
511
+ SuggestionClassNames, // { root?, item?, itemSelected?, avatar?, name?, handle?, ... }
512
+ ClassNameFn, // (...inputs) => string — compatible with clsx/tailwind-merge
513
+ } from 'bsky-richtext-react'
514
+ ```
515
+
516
+ Type guards:
517
+
518
+ ```ts
519
+ import { isMentionFeature, isLinkFeature, isTagFeature } from 'bsky-richtext-react'
520
+
521
+ for (const { feature } of segments) {
522
+ if (isMentionFeature(feature)) { /* feature.did */ }
523
+ if (isLinkFeature(feature)) { /* feature.uri */ }
524
+ if (isTagFeature(feature)) { /* feature.tag */ }
525
+ }
526
+ ```
527
+
528
+ Default classNames objects (starting points for `generateClassNames()`):
529
+
530
+ ```ts
531
+ import {
532
+ defaultDisplayClassNames,
533
+ defaultEditorClassNames,
534
+ defaultSuggestionClassNames,
535
+ } from 'bsky-richtext-react'
536
+ ```
537
+
538
+ ---
539
+
540
+ ## AT Protocol Background
541
+
542
+ Richtext in AT Protocol is represented as a plain UTF-8 string (`text`) paired with an array of `facets`. Each facet maps a **byte range** (not a character range!) of the text to a semantic feature:
543
+
544
+ | Feature | `$type` | Description |
545
+ |---------|---------|-------------|
546
+ | Mention | `app.bsky.richtext.facet#mention` | Reference to another account (DID) |
547
+ | Link | `app.bsky.richtext.facet#link` | Hyperlink (full URI) |
548
+ | Tag | `app.bsky.richtext.facet#tag` | Hashtag (without `#`) |
549
+
550
+ > ⚠️ Byte offsets are **UTF-8**, but JavaScript strings are **UTF-16**. This library handles the conversion automatically via the `sliceByByteOffset` utility.
551
+
552
+ Full lexicon: [`lexicons/app/richtext/facet.json`](./lexicons/app/richtext/facet.json)
553
+ AT Protocol docs: [atproto.com/lexicons/app-bsky-richtext](https://atproto.com/lexicons/app-bsky-richtext)
554
+
555
+ ---
556
+
557
+ ## Development
558
+
559
+ ```bash
560
+ # Start Storybook (component playground)
561
+ bun run dev
562
+
563
+ # Build the library
564
+ bun run build
565
+
566
+ # Run tests
567
+ bun run test
568
+
569
+ # Type-check
570
+ bun run typecheck
571
+
572
+ # Lint
573
+ bun run lint
574
+
575
+ # Format
576
+ bun run format
577
+ ```
578
+
579
+ ---
580
+
581
+ ## Changelog
582
+
583
+ See [CHANGELOG.md](./CHANGELOG.md).
584
+
585
+ ---
586
+
587
+ ## License
588
+
589
+ MIT © 2026 [satyam-mishra-pce](https://github.com/satyam-mishra-pce)