Laravel AT Protocol Client (alpha & unstable)
1<?php
2
3namespace SocialDept\AtpClient\RichText;
4
5use SocialDept\AtpResolver\Facades\Resolver;
6
7class FacetDetector
8{
9 /**
10 * Auto-detect all facets (mentions, links, tags) from text
11 */
12 public static function detect(string $text): array
13 {
14 $facets = [];
15
16 // Detect mentions
17 $facets = array_merge($facets, static::detectMentions($text));
18
19 // Detect URLs
20 $facets = array_merge($facets, static::detectLinks($text));
21
22 // Detect tags
23 $facets = array_merge($facets, static::detectTags($text));
24
25 // Sort facets by byte position
26 usort($facets, fn ($a, $b) => $a['index']['byteStart'] <=> $b['index']['byteStart']);
27
28 return $facets;
29 }
30
31 /**
32 * Detect mentions (@handle.bsky.social)
33 */
34 public static function detectMentions(string $text): array
35 {
36 $facets = [];
37 $pattern = '/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/';
38
39 preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE);
40
41 foreach ($matches[0] as $match) {
42 $handle = ltrim($match[0], '@');
43 $byteStart = $match[1];
44 $byteEnd = $byteStart + strlen($match[0]);
45
46 try {
47 $did = Resolver::handleToDid($handle);
48
49 $facets[] = [
50 'index' => [
51 'byteStart' => $byteStart,
52 'byteEnd' => $byteEnd,
53 ],
54 'features' => [
55 [
56 '$type' => 'app.bsky.richtext.facet#mention',
57 'did' => $did,
58 ],
59 ],
60 ];
61 } catch (\Exception $e) {
62 // Skip if handle cannot be resolved
63 continue;
64 }
65 }
66
67 return $facets;
68 }
69
70 /**
71 * Detect URLs (http:// and https://)
72 */
73 public static function detectLinks(string $text): array
74 {
75 $facets = [];
76 $pattern = '/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/';
77
78 preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE);
79
80 foreach ($matches[0] as $match) {
81 $url = $match[0];
82 $byteStart = $match[1];
83 $byteEnd = $byteStart + strlen($url);
84
85 $facets[] = [
86 'index' => [
87 'byteStart' => $byteStart,
88 'byteEnd' => $byteEnd,
89 ],
90 'features' => [
91 [
92 '$type' => 'app.bsky.richtext.facet#link',
93 'uri' => $url,
94 ],
95 ],
96 ];
97 }
98
99 return $facets;
100 }
101
102 /**
103 * Detect hashtags (#tag)
104 */
105 public static function detectTags(string $text): array
106 {
107 $facets = [];
108 $pattern = '/#([a-zA-Z0-9_]+)/u';
109
110 preg_match_all($pattern, $text, $matches, PREG_OFFSET_CAPTURE);
111
112 foreach ($matches[0] as $i => $match) {
113 $fullTag = $match[0]; // includes #
114 $tag = $matches[1][$i][0]; // without #
115 $byteStart = $match[1];
116 $byteEnd = $byteStart + strlen($fullTag);
117
118 $facets[] = [
119 'index' => [
120 'byteStart' => $byteStart,
121 'byteEnd' => $byteEnd,
122 ],
123 'features' => [
124 [
125 '$type' => 'app.bsky.richtext.facet#tag',
126 'tag' => $tag,
127 ],
128 ],
129 ];
130 }
131
132 return $facets;
133 }
134
135 /**
136 * Merge overlapping or adjacent facets
137 */
138 public static function mergeFacets(array $facets): array
139 {
140 if (empty($facets)) {
141 return [];
142 }
143
144 // Sort by byte position
145 usort($facets, fn ($a, $b) => $a['index']['byteStart'] <=> $b['index']['byteStart']);
146
147 $merged = [];
148 $current = $facets[0];
149
150 for ($i = 1; $i < count($facets); $i++) {
151 $next = $facets[$i];
152
153 // Check for overlap
154 if ($current['index']['byteEnd'] >= $next['index']['byteStart']) {
155 // Merge features
156 $current['features'] = array_merge($current['features'], $next['features']);
157 $current['index']['byteEnd'] = max($current['index']['byteEnd'], $next['index']['byteEnd']);
158 } else {
159 $merged[] = $current;
160 $current = $next;
161 }
162 }
163
164 $merged[] = $current;
165
166 return $merged;
167 }
168}