@aturi.to/waypoints 0.1.1 → 0.1.3

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/dist/index.js CHANGED
@@ -54,6 +54,11 @@ var COMPAT_FAMILIES = {
54
54
  id: "blento",
55
55
  name: "Blento",
56
56
  description: "Blento profiles."
57
+ },
58
+ "atproto-explorer": {
59
+ id: "atproto-explorer",
60
+ name: "Record explorers",
61
+ description: "Raw AT Protocol record explorers (pdsls, atp.tools, Aturi Explore) that render any record by its AT URI."
57
62
  }
58
63
  };
59
64
  var COMPAT_FAMILY_ORDER = [
@@ -67,8 +72,12 @@ var COMPAT_FAMILY_ORDER = [
67
72
  "streamplace",
68
73
  "popfeed",
69
74
  "sifa",
70
- "blento"
75
+ "blento",
76
+ "atproto-explorer"
71
77
  ];
78
+ function socialAppComposeIntent(origin) {
79
+ return { url: `${origin}/intent/compose`, textParam: "text" };
80
+ }
72
81
  var WAYPOINT_DESTINATIONS_DATA = {
73
82
  aturi: {
74
83
  id: "aturi",
@@ -128,6 +137,11 @@ var WAYPOINT_DESTINATIONS_DATA = {
128
137
  },
129
138
  supportedTypes: ["post", "profile", "list", "record"],
130
139
  category: "blueskyClients",
140
+ // Anisota implements the Bluesky shape deliberately, then extends it with
141
+ // `url`, `tags`, `channel`, and `title` (and answers `/post` with the same
142
+ // query). What's declared here is the interoperable subset every caller can
143
+ // rely on; the extras are Anisota-only and unrepresentable in this field.
144
+ composeIntent: socialAppComposeIntent("https://anisota.net"),
131
145
  redirectCompat: ["bluesky-social", "standard-site"],
132
146
  expectedCollections: ["app.bsky.", "net.anisota."]
133
147
  },
@@ -144,12 +158,19 @@ var WAYPOINT_DESTINATIONS_DATA = {
144
158
  if (collection === "app.bsky.feed.post") {
145
159
  return `https://bsky.app/profile/${handle}/post/${rkey}`;
146
160
  }
161
+ if (collection === "app.bsky.graph.list") {
162
+ return `https://bsky.app/profile/${handle}/lists/${rkey}`;
163
+ }
147
164
  return `https://bsky.app/profile/${handle}`;
148
165
  }
149
166
  return `https://bsky.app/profile/${handle}`;
150
167
  },
151
168
  supportedTypes: ["post", "profile", "list", "record"],
152
169
  category: "blueskyClients",
170
+ composeIntent: {
171
+ ...socialAppComposeIntent("https://bsky.app"),
172
+ appUrl: "bluesky://intent/compose"
173
+ },
153
174
  redirectCompat: ["bluesky-social"],
154
175
  expectedCollections: ["app.bsky."]
155
176
  },
@@ -166,12 +187,16 @@ var WAYPOINT_DESTINATIONS_DATA = {
166
187
  if (collection === "app.bsky.feed.post") {
167
188
  return `https://blacksky.community/profile/${handle}/post/${rkey}`;
168
189
  }
190
+ if (collection === "app.bsky.graph.list") {
191
+ return `https://blacksky.community/profile/${handle}/lists/${rkey}`;
192
+ }
169
193
  return `https://blacksky.community/profile/${handle}`;
170
194
  }
171
195
  return `https://blacksky.community/profile/${handle}`;
172
196
  },
173
197
  supportedTypes: ["post", "profile", "list", "record"],
174
198
  category: "blueskyForks",
199
+ composeIntent: socialAppComposeIntent("https://blacksky.community"),
175
200
  redirectCompat: ["bluesky-social"],
176
201
  expectedCollections: ["app.bsky."]
177
202
  },
@@ -180,7 +205,6 @@ var WAYPOINT_DESTINATIONS_DATA = {
180
205
  name: "Red Dwarf",
181
206
  description: (collection) => {
182
207
  if (collection === "app.bsky.feed.post") return "View post on reddwarf.app";
183
- if (collection === "app.bsky.graph.list") return "View list on reddwarf.app";
184
208
  return "View profile on reddwarf.app";
185
209
  },
186
210
  getUrl: (handle, collection, rkey) => {
@@ -192,8 +216,63 @@ var WAYPOINT_DESTINATIONS_DATA = {
192
216
  }
193
217
  return `https://reddwarf.app/profile/${handle}`;
194
218
  },
219
+ // No `list`: unlike the social-app forks in this family, Red Dwarf has no
220
+ // `/profile/:handle/lists/:rkey` route, so offering it for a list would
221
+ // silently land the user on the list's author instead.
222
+ supportedTypes: ["post", "profile", "record"],
223
+ category: "blueskyClients",
224
+ redirectCompat: ["bluesky-social"],
225
+ expectedCollections: ["app.bsky."]
226
+ },
227
+ impro: {
228
+ id: "impro",
229
+ name: "Impro",
230
+ description: (collection) => {
231
+ if (collection === "app.bsky.feed.post") return "View post on impro.social";
232
+ if (collection === "app.bsky.graph.list") return "View list on impro.social";
233
+ return "View profile on impro.social";
234
+ },
235
+ getUrl: (handle, collection, rkey) => {
236
+ if (collection && rkey) {
237
+ if (collection === "app.bsky.feed.post") {
238
+ return `https://impro.social/profile/${handle}/post/${rkey}`;
239
+ }
240
+ if (collection === "app.bsky.graph.list") {
241
+ return `https://impro.social/profile/${handle}/lists/${rkey}`;
242
+ }
243
+ return `https://impro.social/profile/${handle}`;
244
+ }
245
+ return `https://impro.social/profile/${handle}`;
246
+ },
195
247
  supportedTypes: ["post", "profile", "list", "record"],
196
- category: "blueskyForks",
248
+ category: "blueskyClients",
249
+ // Impro routes /intent/compose to its home view, which opens the composer
250
+ // for a signed-in user — but it never reads `?text`, so no textParam here.
251
+ composeIntent: { url: "https://impro.social/intent/compose" },
252
+ redirectCompat: ["bluesky-social"],
253
+ expectedCollections: ["app.bsky."]
254
+ },
255
+ lea: {
256
+ id: "lea",
257
+ name: "Lea",
258
+ description: (collection) => {
259
+ if (collection === "app.bsky.feed.post") return "View post on lea.ac";
260
+ return "View profile on lea.ac";
261
+ },
262
+ getUrl: (handle, collection, rkey) => {
263
+ if (collection && rkey) {
264
+ if (collection === "app.bsky.feed.post") {
265
+ return `https://lea.ac/profile/${handle}/post/${rkey}`;
266
+ }
267
+ return `https://lea.ac/profile/${handle}`;
268
+ }
269
+ return `https://lea.ac/profile/${handle}`;
270
+ },
271
+ // No `list`: like Red Dwarf, Lea serves only `/profile/:handle` and
272
+ // `/profile/:handle/post/:rkey` — `/lists/:rkey` 404s — so claiming list
273
+ // support would drop the user on an error page instead of the list.
274
+ supportedTypes: ["post", "profile", "record"],
275
+ category: "blueskyClients",
197
276
  redirectCompat: ["bluesky-social"],
198
277
  expectedCollections: ["app.bsky."]
199
278
  },
@@ -222,7 +301,7 @@ var WAYPOINT_DESTINATIONS_DATA = {
222
301
  },
223
302
  supportedTypes: ["post", "profile", "list", "record"],
224
303
  category: "devTools",
225
- redirectCompat: []
304
+ redirectCompat: ["atproto-explorer"]
226
305
  },
227
306
  aturiExplore: {
228
307
  id: "aturiExplore",
@@ -243,7 +322,7 @@ var WAYPOINT_DESTINATIONS_DATA = {
243
322
  },
244
323
  supportedTypes: ["post", "profile", "list", "record"],
245
324
  category: "devTools",
246
- redirectCompat: []
325
+ redirectCompat: ["atproto-explorer"]
247
326
  },
248
327
  atptools: {
249
328
  id: "atptools",
@@ -258,7 +337,7 @@ var WAYPOINT_DESTINATIONS_DATA = {
258
337
  },
259
338
  supportedTypes: ["post", "profile", "list", "record"],
260
339
  category: "devTools",
261
- redirectCompat: []
340
+ redirectCompat: ["atproto-explorer"]
262
341
  },
263
342
  bluepy: {
264
343
  id: "bluepy",
@@ -294,56 +373,94 @@ var WAYPOINT_DESTINATIONS_DATA = {
294
373
  if (collection === "app.bsky.feed.post") {
295
374
  return `https://witchsky.app/profile/${handle}/post/${rkey}`;
296
375
  }
376
+ if (collection === "app.bsky.graph.list") {
377
+ return `https://witchsky.app/profile/${handle}/lists/${rkey}`;
378
+ }
297
379
  return `https://witchsky.app/profile/${handle}`;
298
380
  }
299
381
  return `https://witchsky.app/profile/${handle}`;
300
382
  },
301
383
  supportedTypes: ["post", "profile", "list", "record"],
302
384
  category: "blueskyForks",
385
+ composeIntent: socialAppComposeIntent("https://witchsky.app"),
303
386
  redirectCompat: ["bluesky-social"],
304
387
  expectedCollections: ["app.bsky."]
305
388
  },
306
- catsky: {
307
- id: "catsky",
308
- name: "Catsky",
389
+ deer: {
390
+ id: "deer",
391
+ name: "Deer",
309
392
  description: (collection) => {
310
- if (collection === "app.bsky.feed.post") return "View post on catsky.social";
311
- if (collection === "app.bsky.graph.list") return "View list on catsky.social";
312
- return "View profile on catsky.social";
393
+ if (collection === "app.bsky.feed.post") return "View post on deer.social";
394
+ if (collection === "app.bsky.graph.list") return "View list on deer.social";
395
+ return "View profile on deer.social";
313
396
  },
314
397
  getUrl: (handle, collection, rkey) => {
315
398
  if (collection && rkey) {
316
399
  if (collection === "app.bsky.feed.post") {
317
- return `https://catsky.social/profile/${handle}/post/${rkey}`;
400
+ return `https://deer.social/profile/${handle}/post/${rkey}`;
318
401
  }
319
- return `https://catsky.social/profile/${handle}`;
402
+ if (collection === "app.bsky.graph.list") {
403
+ return `https://deer.social/profile/${handle}/lists/${rkey}`;
404
+ }
405
+ return `https://deer.social/profile/${handle}`;
320
406
  }
321
- return `https://catsky.social/profile/${handle}`;
407
+ return `https://deer.social/profile/${handle}`;
322
408
  },
323
409
  supportedTypes: ["post", "profile", "list", "record"],
324
410
  category: "blueskyForks",
411
+ composeIntent: socialAppComposeIntent("https://deer.social"),
325
412
  redirectCompat: ["bluesky-social"],
326
413
  expectedCollections: ["app.bsky."]
327
414
  },
328
- deer: {
329
- id: "deer",
330
- name: "Deer",
415
+ mu: {
416
+ id: "mu",
417
+ name: "Mu",
331
418
  description: (collection) => {
332
- if (collection === "app.bsky.feed.post") return "View post on deer.social";
333
- if (collection === "app.bsky.graph.list") return "View list on deer.social";
334
- return "View profile on deer.social";
419
+ if (collection === "app.bsky.feed.post") return "View post on mu.social";
420
+ if (collection === "app.bsky.graph.list") return "View list on mu.social";
421
+ return "View profile on mu.social";
335
422
  },
336
423
  getUrl: (handle, collection, rkey) => {
337
424
  if (collection && rkey) {
338
425
  if (collection === "app.bsky.feed.post") {
339
- return `https://deer.social/profile/${handle}/post/${rkey}`;
426
+ return `https://mu.social/profile/${handle}/post/${rkey}`;
340
427
  }
341
- return `https://deer.social/profile/${handle}`;
428
+ if (collection === "app.bsky.graph.list") {
429
+ return `https://mu.social/profile/${handle}/lists/${rkey}`;
430
+ }
431
+ return `https://mu.social/profile/${handle}`;
342
432
  }
343
- return `https://deer.social/profile/${handle}`;
433
+ return `https://mu.social/profile/${handle}`;
434
+ },
435
+ supportedTypes: ["post", "profile", "list", "record"],
436
+ category: "blueskyForks",
437
+ composeIntent: socialAppComposeIntent("https://mu.social"),
438
+ redirectCompat: ["bluesky-social"],
439
+ expectedCollections: ["app.bsky."]
440
+ },
441
+ northsky: {
442
+ id: "northsky",
443
+ name: "Northsky",
444
+ description: (collection) => {
445
+ if (collection === "app.bsky.feed.post") return "View post on northsky.app";
446
+ if (collection === "app.bsky.graph.list") return "View list on northsky.app";
447
+ return "View profile on northsky.app";
448
+ },
449
+ getUrl: (handle, collection, rkey) => {
450
+ if (collection && rkey) {
451
+ if (collection === "app.bsky.feed.post") {
452
+ return `https://northsky.app/profile/${handle}/post/${rkey}`;
453
+ }
454
+ if (collection === "app.bsky.graph.list") {
455
+ return `https://northsky.app/profile/${handle}/lists/${rkey}`;
456
+ }
457
+ return `https://northsky.app/profile/${handle}`;
458
+ }
459
+ return `https://northsky.app/profile/${handle}`;
344
460
  },
345
461
  supportedTypes: ["post", "profile", "list", "record"],
346
462
  category: "blueskyForks",
463
+ composeIntent: socialAppComposeIntent("https://northsky.app"),
347
464
  redirectCompat: ["bluesky-social"],
348
465
  expectedCollections: ["app.bsky."]
349
466
  },
@@ -553,14 +670,54 @@ var WAYPOINT_DESTINATIONS_DATA = {
553
670
  category: "publications",
554
671
  redirectCompat: ["standard-site"],
555
672
  expectedCollections: ["pub.leaflet.", "site.standard."]
673
+ },
674
+ standardReader: {
675
+ id: "standardReader",
676
+ name: "Standard Reader",
677
+ description: (collection) => {
678
+ if (collection?.startsWith("site.standard.") || collection?.startsWith("pub.leaflet.")) {
679
+ return "Read document on standard-reader.app";
680
+ }
681
+ return "View documents on standard-reader.app";
682
+ },
683
+ getUrl: (handle, collection, rkey, did) => {
684
+ const identifier = did || handle;
685
+ if (collection && rkey && (collection.startsWith("site.standard.") || collection.startsWith("pub.leaflet."))) {
686
+ return `https://standard-reader.app/a/${identifier}/${rkey}`;
687
+ }
688
+ return `https://standard-reader.app/u/${identifier}`;
689
+ },
690
+ supportedTypes: ["post", "profile", "list", "record"],
691
+ category: "publications",
692
+ redirectCompat: ["standard-site"],
693
+ expectedCollections: ["pub.leaflet.", "site.standard."]
694
+ },
695
+ taproot: {
696
+ id: "taproot",
697
+ name: "Taproot",
698
+ description: (collection) => {
699
+ if (collection) return "Inspect record on atproto.at";
700
+ return "Browse repo on atproto.at";
701
+ },
702
+ getUrl: (handle, collection, rkey, did) => {
703
+ const identifier = did || handle;
704
+ if (collection && rkey) {
705
+ return `https://atproto.at/uri/at://${identifier}/${collection}/${rkey}`;
706
+ }
707
+ return `https://atproto.at/uri/at://${identifier}`;
708
+ },
709
+ supportedTypes: ["post", "profile", "list", "record"],
710
+ category: "devTools",
711
+ redirectCompat: []
556
712
  }
557
713
  };
558
714
  var WAYPOINT_ORDER = [
559
715
  "anisota",
560
716
  "bluesky",
561
717
  "bluepy",
562
- "blacksky",
563
718
  "reddwarf",
719
+ "impro",
720
+ "blacksky",
564
721
  "leaflet",
565
722
  "aturi",
566
723
  "pinksky",
@@ -574,13 +731,17 @@ var WAYPOINT_ORDER = [
574
731
  "anisotaReader",
575
732
  "offprint",
576
733
  "pckt",
734
+ "standardReader",
577
735
  "aturiExplore",
578
736
  "pdsls",
579
737
  "tangled",
580
738
  "atptools",
739
+ "taproot",
581
740
  "witchsky",
582
- "catsky",
583
- "deer"
741
+ "mu",
742
+ "deer",
743
+ "lea",
744
+ "northsky"
584
745
  ];
585
746
  function getWaypointDataForType(type) {
586
747
  return WAYPOINT_ORDER.map((id) => WAYPOINT_DESTINATIONS_DATA[id]).filter((waypoint) => waypoint.supportedTypes.includes(type));
@@ -674,13 +835,13 @@ var RECOMMENDED_WAYPOINTS = {
674
835
  label: "Recommended for Repos"
675
836
  },
676
837
  "record": {
677
- waypointIds: ["aturiExplore", "pdsls", "atptools"],
838
+ waypointIds: ["aturiExplore", "pdsls", "atptools", "taproot"],
678
839
  label: "Recommended for Records"
679
840
  }
680
841
  };
681
842
  var RECOMMENDED_NAMESPACE_PREFIXES = {
682
843
  "site.standard": {
683
- waypointIds: ["leaflet", "anisotaReader", "offprint", "pckt", "pdsls"],
844
+ waypointIds: ["leaflet", "standardReader", "anisotaReader", "offprint", "pckt", "pdsls"],
684
845
  label: "Recommended for Publications"
685
846
  },
686
847
  "pub.leaflet": {
@@ -747,6 +908,67 @@ function waypointActivity(waypoint, repoCollections) {
747
908
  }
748
909
  return "absent";
749
910
  }
911
+ var COMPOSE_INTENT_TEXT_PLACEHOLDER = "{text}";
912
+ function supportsComposeIntent(waypoint) {
913
+ return !!waypoint.composeIntent;
914
+ }
915
+ function getComposeIntentUrl(waypoint, text) {
916
+ const intent = waypoint.composeIntent;
917
+ if (!intent) return null;
918
+ return appendComposeText(intent.url, intent.textParam, text);
919
+ }
920
+ function getComposeIntentAppUrl(waypoint, text) {
921
+ const intent = waypoint.composeIntent;
922
+ if (!intent?.appUrl) return null;
923
+ return appendComposeText(intent.appUrl, intent.textParam, text);
924
+ }
925
+ function getComposeIntentTemplate(waypoint) {
926
+ const intent = waypoint.composeIntent;
927
+ if (!intent) return null;
928
+ if (!intent.textParam) return intent.url;
929
+ return `${intent.url}?${intent.textParam}=${COMPOSE_INTENT_TEXT_PLACEHOLDER}`;
930
+ }
931
+ function getComposeIntentWaypoints(type) {
932
+ const waypoints = type ? getWaypointDataForType(type) : WAYPOINT_ORDER.map((id) => WAYPOINT_DESTINATIONS_DATA[id]).filter(Boolean);
933
+ return waypoints.filter(supportsComposeIntent);
934
+ }
935
+ function describeComposeIntent(waypoint, text) {
936
+ const intent = waypoint.composeIntent;
937
+ if (!intent) return null;
938
+ const descriptor = {
939
+ url: getComposeIntentUrl(waypoint, text),
940
+ urlTemplate: getComposeIntentTemplate(waypoint),
941
+ textParam: intent.textParam ?? null,
942
+ prefillsText: !!intent.textParam
943
+ };
944
+ const appUrl = getComposeIntentAppUrl(waypoint, text);
945
+ if (appUrl) descriptor.appUrl = appUrl;
946
+ return descriptor;
947
+ }
948
+ function appendComposeText(base, textParam, text) {
949
+ if (!textParam || !text) return base;
950
+ return `${base}?${textParam}=${encodeURIComponent(text)}`;
951
+ }
952
+
953
+ // src/upstreamFetch.ts
954
+ var RETRY_DELAY_MS = 250;
955
+ async function upstreamFetch(url, { timeoutMs = 8e3, retries = 1, ...init } = {}) {
956
+ let lastError;
957
+ for (let attempt = 0; attempt <= retries; attempt++) {
958
+ try {
959
+ return await fetch(url, {
960
+ ...init,
961
+ signal: AbortSignal.timeout(timeoutMs)
962
+ });
963
+ } catch (error) {
964
+ lastError = error;
965
+ if (attempt < retries) {
966
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
967
+ }
968
+ }
969
+ }
970
+ throw lastError;
971
+ }
750
972
 
751
973
  // src/uriParser.ts
752
974
  function parseURI(handle, collection, rkey) {
@@ -789,25 +1011,29 @@ function parseURI(handle, collection, rkey) {
789
1011
  error: "Invalid URI structure"
790
1012
  };
791
1013
  }
792
- async function resolveHandle(handle) {
1014
+ async function resolveHandleStatus(handle) {
793
1015
  if (handle.startsWith("did:")) {
794
- return handle;
1016
+ return { did: handle };
795
1017
  }
796
1018
  try {
797
1019
  const apiUrl = process.env.NEXT_PUBLIC_BSKY_API_URL || "https://public.api.bsky.app";
798
- const response = await fetch(
1020
+ const response = await upstreamFetch(
799
1021
  `${apiUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
800
1022
  );
801
1023
  if (!response.ok) {
802
- return null;
1024
+ return { did: null, reason: response.status >= 500 ? "unavailable" : "not-found" };
803
1025
  }
804
1026
  const data = await response.json();
805
- return data.did || null;
1027
+ if (data.did) return { did: data.did };
1028
+ return { did: null, reason: "not-found" };
806
1029
  } catch (error) {
807
1030
  console.error("Error resolving handle:", error);
808
- return null;
1031
+ return { did: null, reason: "unavailable" };
809
1032
  }
810
1033
  }
1034
+ async function resolveHandle(handle) {
1035
+ return (await resolveHandleStatus(handle)).did;
1036
+ }
811
1037
  function getDisplayName(handle, did) {
812
1038
  if (handle.startsWith("did:")) {
813
1039
  return did ? `@${did.slice(0, 16)}...` : "Unknown";
@@ -820,16 +1046,30 @@ var BLUESKY_FAMILY = [
820
1046
  { source: "bluesky", hosts: ["bsky.app"] },
821
1047
  { source: "blacksky", hosts: ["blacksky.community"] },
822
1048
  { source: "reddwarf", hosts: ["reddwarf.app"] },
1049
+ { source: "impro", hosts: ["impro.social"] },
1050
+ { source: "lea", hosts: ["lea.ac"] },
823
1051
  { source: "witchsky", hosts: ["witchsky.app"] },
824
- { source: "catsky", hosts: ["catsky.social"] },
825
1052
  { source: "deer", hosts: ["deer.social"] },
826
- { source: "anisota", hosts: ["anisota.net"] }
1053
+ { source: "northsky", hosts: ["northsky.app"] },
1054
+ { source: "mu", hosts: ["mu.social"] },
1055
+ { source: "anisota", hosts: ["anisota.net"], matchSubdomains: true }
827
1056
  ];
1057
+ var SUBDOMAIN_HOSTS = BLUESKY_FAMILY.filter((f) => f.matchSubdomains).flatMap(
1058
+ (f) => f.hosts
1059
+ );
828
1060
  function normalizeHost(host) {
829
1061
  return host.toLowerCase().replace(/^www\./, "");
830
1062
  }
1063
+ function isSubdomainOf(host, base) {
1064
+ return host.endsWith(`.${base}`);
1065
+ }
1066
+ function hostMatchesConfig(host, config) {
1067
+ if (config.hosts.includes(host)) return true;
1068
+ if (!config.matchSubdomains) return false;
1069
+ return config.hosts.some((h) => isSubdomainOf(host, h));
1070
+ }
831
1071
  function matchBlueskyFamily(host, parts) {
832
- const entry = BLUESKY_FAMILY.find((f) => f.hosts.includes(host));
1072
+ const entry = BLUESKY_FAMILY.find((f) => hostMatchesConfig(host, f));
833
1073
  if (!entry) return null;
834
1074
  if (parts[0] !== "profile" || !parts[1]) return null;
835
1075
  const handle = parts[1];
@@ -871,6 +1111,19 @@ function matchBlueskyFamily(host, parts) {
871
1111
  }
872
1112
  };
873
1113
  }
1114
+ if (parts[2] === "document" && parts[3]) {
1115
+ return {
1116
+ source: entry.source,
1117
+ parsed: {
1118
+ type: "record",
1119
+ uri: `at://${handle}/site.standard.document/${parts[3]}`,
1120
+ handle,
1121
+ did,
1122
+ collection: "site.standard.document",
1123
+ rkey: parts[3]
1124
+ }
1125
+ };
1126
+ }
874
1127
  return {
875
1128
  source: entry.source,
876
1129
  parsed: {
@@ -1141,11 +1394,160 @@ function matchBlento(host, parts) {
1141
1394
  parsed: { type: "profile", uri: `at://${handle}`, handle, did }
1142
1395
  };
1143
1396
  }
1397
+ function matchStandardReader(host, parts) {
1398
+ if (host !== "standard-reader.app") return null;
1399
+ if (parts[0] === "a" && parts[1] && parts[2]) {
1400
+ const handle = parts[1];
1401
+ const did = handle.startsWith("did:") ? handle : void 0;
1402
+ const rkey = parts[2];
1403
+ return {
1404
+ source: "standardReader",
1405
+ parsed: {
1406
+ type: "record",
1407
+ uri: `at://${handle}/site.standard.document/${rkey}`,
1408
+ handle,
1409
+ did,
1410
+ collection: "site.standard.document",
1411
+ rkey
1412
+ }
1413
+ };
1414
+ }
1415
+ if (parts[0] === "u" && parts[1]) {
1416
+ const handle = parts[1];
1417
+ const did = handle.startsWith("did:") ? handle : void 0;
1418
+ return {
1419
+ source: "standardReader",
1420
+ parsed: { type: "profile", uri: `at://${handle}`, handle, did }
1421
+ };
1422
+ }
1423
+ return null;
1424
+ }
1425
+ function matchFlatRecordHost(host, parts, target) {
1426
+ if (host !== target.host) return null;
1427
+ const [handle, collection, rkey] = parts;
1428
+ if (!handle || !collection || !rkey) return null;
1429
+ if (!collection.includes(".")) return null;
1430
+ const did = handle.startsWith("did:") ? handle : void 0;
1431
+ return {
1432
+ source: target.source,
1433
+ parsed: {
1434
+ type: inferType(collection),
1435
+ uri: `at://${handle}/${collection}/${rkey}`,
1436
+ handle,
1437
+ did,
1438
+ collection,
1439
+ rkey
1440
+ }
1441
+ };
1442
+ }
1443
+ function matchTaproot(host, pathname) {
1444
+ if (host !== "atproto.at") return null;
1445
+ const m = pathname.replace(/^\/+/, "").match(/^uri\/at:\/{1,2}(.+)$/);
1446
+ if (!m) return null;
1447
+ const segments = m[1].split("/").filter(Boolean);
1448
+ if (segments.length === 0) return null;
1449
+ const handle = segments[0];
1450
+ const collection = segments[1];
1451
+ const rkey = segments[2];
1452
+ const did = handle.startsWith("did:") ? handle : void 0;
1453
+ if (collection && rkey) {
1454
+ return {
1455
+ source: "taproot",
1456
+ parsed: {
1457
+ type: inferType(collection),
1458
+ uri: `at://${handle}/${collection}/${rkey}`,
1459
+ handle,
1460
+ did,
1461
+ collection,
1462
+ rkey
1463
+ }
1464
+ };
1465
+ }
1466
+ return {
1467
+ source: "taproot",
1468
+ parsed: { type: "profile", uri: `at://${handle}`, handle, did }
1469
+ };
1470
+ }
1471
+ function matchAturi(host, parts) {
1472
+ if (host !== "aturi.to") return null;
1473
+ const isIdentifier = (seg2) => !!seg2 && (seg2.startsWith("did:") || seg2.includes("."));
1474
+ if (parts[0] === "explore" && isIdentifier(parts[1])) {
1475
+ const handle = parts[1];
1476
+ const did = handle.startsWith("did:") ? handle : void 0;
1477
+ const collection = parts[2];
1478
+ const rkey = parts[3];
1479
+ if (collection && rkey) {
1480
+ return {
1481
+ source: "aturiExplore",
1482
+ parsed: {
1483
+ type: inferType(collection),
1484
+ uri: `at://${handle}/${collection}/${rkey}`,
1485
+ handle,
1486
+ did,
1487
+ collection,
1488
+ rkey
1489
+ }
1490
+ };
1491
+ }
1492
+ return {
1493
+ source: "aturiExplore",
1494
+ parsed: { type: "profile", uri: `at://${handle}`, handle, did }
1495
+ };
1496
+ }
1497
+ if (parts[0] === "profile" && isIdentifier(parts[1])) {
1498
+ const handle = parts[1];
1499
+ const did = handle.startsWith("did:") ? handle : void 0;
1500
+ if (parts[2] === "post" && parts[3]) {
1501
+ return {
1502
+ source: "aturi",
1503
+ parsed: {
1504
+ type: "post",
1505
+ uri: `at://${handle}/app.bsky.feed.post/${parts[3]}`,
1506
+ handle,
1507
+ did,
1508
+ collection: "app.bsky.feed.post",
1509
+ rkey: parts[3]
1510
+ }
1511
+ };
1512
+ }
1513
+ if (parts[2] === "lists" && parts[3]) {
1514
+ return {
1515
+ source: "aturi",
1516
+ parsed: {
1517
+ type: "list",
1518
+ uri: `at://${handle}/app.bsky.graph.list/${parts[3]}`,
1519
+ handle,
1520
+ did,
1521
+ collection: "app.bsky.graph.list",
1522
+ rkey: parts[3]
1523
+ }
1524
+ };
1525
+ }
1526
+ if (parts[2] && parts[2].includes(".") && parts[3]) {
1527
+ return {
1528
+ source: "aturi",
1529
+ parsed: {
1530
+ type: inferType(parts[2]),
1531
+ uri: `at://${handle}/${parts[2]}/${parts[3]}`,
1532
+ handle,
1533
+ did,
1534
+ collection: parts[2],
1535
+ rkey: parts[3]
1536
+ }
1537
+ };
1538
+ }
1539
+ return {
1540
+ source: "aturi",
1541
+ parsed: { type: "profile", uri: `at://${handle}`, handle, did }
1542
+ };
1543
+ }
1544
+ return null;
1545
+ }
1144
1546
  function matchSupportedUrl(url) {
1145
1547
  const host = normalizeHost(url.hostname);
1146
1548
  const parts = url.pathname.split("/").filter(Boolean);
1147
1549
  const search = url.searchParams;
1148
- return matchBlueskyFamily(host, parts) || matchPinksky(host, parts, search) || matchLeaflet(host, parts) || matchTangled(host, parts) || matchMargin(host, parts) || matchPdsls(host, url.pathname) || matchAtpTools(host, url.pathname) || matchBluepy(host, url.pathname) || matchSemble(host, parts) || matchStreamplace(host, parts) || matchGrain(host, parts) || matchPopfeed(host, parts) || matchSifa(host, parts) || matchBlento(host, parts);
1550
+ return matchAturi(host, parts) || matchBlueskyFamily(host, parts) || matchPinksky(host, parts, search) || matchLeaflet(host, parts) || matchTangled(host, parts) || matchMargin(host, parts) || matchPdsls(host, url.pathname) || matchAtpTools(host, url.pathname) || matchBluepy(host, url.pathname) || matchSemble(host, parts) || matchStreamplace(host, parts) || matchGrain(host, parts) || matchPopfeed(host, parts) || matchSifa(host, parts) || matchBlento(host, parts) || matchStandardReader(host, parts) || matchFlatRecordHost(host, parts, { host: "offprint.app", source: "offprint" }) || matchFlatRecordHost(host, parts, { host: "pckt.blog", source: "pckt" }) || matchTaproot(host, url.pathname);
1149
1551
  }
1150
1552
  function parseAtUri(uri) {
1151
1553
  if (!uri.startsWith("at://")) return null;
@@ -1175,6 +1577,7 @@ function parseAtUri(uri) {
1175
1577
  };
1176
1578
  }
1177
1579
  var SUPPORTED_HOSTS = [
1580
+ "aturi.to",
1178
1581
  ...BLUESKY_FAMILY.flatMap((e) => e.hosts),
1179
1582
  "pinkleap.app",
1180
1583
  "leaflet.pub",
@@ -1188,8 +1591,17 @@ var SUPPORTED_HOSTS = [
1188
1591
  "grain.social",
1189
1592
  "popfeed.social",
1190
1593
  "sifa.id",
1191
- "blento.app"
1594
+ "blento.app",
1595
+ "standard-reader.app",
1596
+ "offprint.app",
1597
+ "pckt.blog",
1598
+ "atproto.at"
1192
1599
  ];
1600
+ function isSupportedHost(host) {
1601
+ const h = normalizeHost(host);
1602
+ if (SUPPORTED_HOSTS.includes(h)) return true;
1603
+ return SUBDOMAIN_HOSTS.some((base) => isSubdomainOf(h, base));
1604
+ }
1193
1605
 
1194
1606
  // src/resolve.ts
1195
1607
  var DID_REQUIRED_WAYPOINTS = /* @__PURE__ */ new Set([
@@ -1207,18 +1619,26 @@ function buildWaypointsForParsed(parsed, options = {}) {
1207
1619
  if (DID_REQUIRED_WAYPOINTS.has(w.id) && !did) return null;
1208
1620
  const url = w.getUrl(parsed.handle, parsed.collection, parsed.rkey, did);
1209
1621
  if (!url) return null;
1210
- return { id: w.id, name: w.name, category: w.category, url };
1622
+ return {
1623
+ id: w.id,
1624
+ name: w.name,
1625
+ category: w.category,
1626
+ url,
1627
+ composeIntent: describeComposeIntent(w, options.composeText)
1628
+ };
1211
1629
  }).filter((w) => !!w);
1212
1630
  const recommendedRaw = getRecommendedWaypointsData(type, parsed.collection);
1213
1631
  const availableIds = new Set(waypoints.map((w) => w.id));
1214
1632
  const ids = recommendedRaw.waypoints.map((w) => w.id).filter((id) => id !== exclude && availableIds.has(id));
1215
1633
  return { waypoints, recommended: { ids, label: recommendedRaw.label } };
1216
1634
  }
1217
- function resolveAtUri(uri) {
1635
+ function resolveAtUri(uri, options = {}) {
1218
1636
  const match = parseAtUri(uri);
1219
1637
  if (!match) return null;
1220
1638
  const { parsed, source } = match;
1221
- const { waypoints, recommended } = buildWaypointsForParsed(parsed);
1639
+ const { waypoints, recommended } = buildWaypointsForParsed(parsed, {
1640
+ composeText: options.composeText
1641
+ });
1222
1642
  return {
1223
1643
  parsed,
1224
1644
  source,
@@ -1260,7 +1680,8 @@ async function resolveUrl(url, options = {}) {
1260
1680
  }
1261
1681
  const { waypoints, recommended } = buildWaypointsForParsed(parsed, {
1262
1682
  did,
1263
- excludeSourceId: source
1683
+ excludeSourceId: source,
1684
+ composeText: options.composeText
1264
1685
  });
1265
1686
  return {
1266
1687
  parsed: { ...parsed, did },
@@ -1283,6 +1704,7 @@ async function resolveViaApi(input, options = {}) {
1283
1704
  throw new Error("resolveViaApi requires either `url` or `atUri`");
1284
1705
  }
1285
1706
  if (input.headDetect === false) params.set("headDetect", "false");
1707
+ if (input.composeText) params.set("composeText", input.composeText);
1286
1708
  const res = await fetchImpl(`${endpoint}?${params.toString()}`, {
1287
1709
  method: "GET",
1288
1710
  headers: { Accept: "application/json" },
@@ -1322,6 +1744,226 @@ async function detectAtUriInHead(url, timeoutMs) {
1322
1744
  }
1323
1745
  }
1324
1746
 
1325
- export { CATEGORY_ORDER, COMPAT_FAMILIES, COMPAT_FAMILY_ORDER, DID_REQUIRED_WAYPOINTS, SUPPORTED_HOSTS, WAYPOINT_CATEGORIES_DATA, WAYPOINT_DESTINATIONS_DATA, WAYPOINT_ORDER, buildWaypointsForParsed, getCategorizedWaypointsData, getDisplayName, getFeaturedWaypointData, getRecommendedWaypointsData, getWaypointCountData, getWaypointDataForType, matchSupportedUrl, parseAtUri, parseURI, resolveAtUri, resolveHandle, resolveUrl, resolveViaApi, waypointActivity };
1747
+ // src/universalLinks.ts
1748
+ var UNIVERSAL_LINK_ORIGIN = "https://aturi.to";
1749
+ var OEMBED_PATH = "/api/oembed";
1750
+ var OEMBED_COLLECTION = "app.bsky.feed.post";
1751
+ var OEMBED_LINK_TYPE = "application/json+oembed";
1752
+ function buildUniversalLink(input, options = {}) {
1753
+ const parsed = parseTarget(input);
1754
+ if (!parsed) return null;
1755
+ const origin = normalizeOrigin(options.origin);
1756
+ const did = options.did ?? parsed.did;
1757
+ const { collection, rkey } = parsed;
1758
+ const identifier = options.preferDid && did ? did : parsed.handle;
1759
+ let path;
1760
+ if (collection && rkey) {
1761
+ if (collection === "app.bsky.feed.post") {
1762
+ path = `/profile/${seg(identifier)}/post/${seg(rkey)}`;
1763
+ } else if (collection === "app.bsky.graph.list") {
1764
+ path = `/profile/${seg(identifier)}/lists/${seg(rkey)}`;
1765
+ } else {
1766
+ path = `/profile/${seg(did || parsed.handle)}/${seg(collection)}/${seg(rkey)}`;
1767
+ }
1768
+ } else {
1769
+ path = `/profile/${seg(identifier)}`;
1770
+ }
1771
+ return `${origin}${path}${queryString(options.params)}`;
1772
+ }
1773
+ function parseUniversalLink(url, options = {}) {
1774
+ let target;
1775
+ try {
1776
+ target = typeof url === "string" ? new URL(url.trim()) : url;
1777
+ } catch {
1778
+ return null;
1779
+ }
1780
+ if (!/^https?:$/.test(target.protocol)) return null;
1781
+ let expectedHost;
1782
+ try {
1783
+ expectedHost = bareHost(new URL(normalizeOrigin(options.origin)).hostname);
1784
+ } catch {
1785
+ return null;
1786
+ }
1787
+ if (bareHost(target.hostname) !== expectedHost) return null;
1788
+ const probe = new URL(target.toString());
1789
+ probe.protocol = "https:";
1790
+ probe.hostname = "aturi.to";
1791
+ probe.port = "";
1792
+ const match = matchSupportedUrl(probe);
1793
+ if (match && (match.source === "aturi" || match.source === "aturiExplore")) {
1794
+ return normalizeParsed(match.parsed);
1795
+ }
1796
+ return parseEmbeddedAtUri(probe.pathname) ?? parsePath(probe.pathname);
1797
+ }
1798
+ function isUniversalLink(url, options = {}) {
1799
+ return parseUniversalLink(url, options) !== null;
1800
+ }
1801
+ function describeUniversalLink(input, options = {}) {
1802
+ const parsed = parseTarget(input);
1803
+ if (!parsed) return null;
1804
+ const url = buildUniversalLink(parsed, options);
1805
+ if (!url) return null;
1806
+ const did = options.did ?? parsed.did ?? null;
1807
+ const collection = parsed.collection ?? null;
1808
+ const rkey = parsed.rkey ?? null;
1809
+ const authority = did ?? parsed.handle;
1810
+ const atUri = collection && rkey ? `at://${authority}/${collection}/${rkey}` : `at://${authority}`;
1811
+ const label = describeTarget(parsed);
1812
+ return {
1813
+ url,
1814
+ atUri,
1815
+ type: parsed.type,
1816
+ handle: parsed.handle,
1817
+ did,
1818
+ collection,
1819
+ rkey,
1820
+ label,
1821
+ share: {
1822
+ title: options.title ?? label,
1823
+ text: options.text ?? label,
1824
+ url
1825
+ },
1826
+ snippets: {
1827
+ url,
1828
+ atUri,
1829
+ markdown: `[${label}](${url})`,
1830
+ html: `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`
1831
+ },
1832
+ oembedUrl: buildOEmbedUrl(url, collection, options.origin)
1833
+ };
1834
+ }
1835
+ function buildUniversalLinkTags(input, options = {}) {
1836
+ const described = describeUniversalLink(input, options);
1837
+ if (!described) return null;
1838
+ const meta = [
1839
+ { name: "at:canonical", content: described.atUri }
1840
+ ];
1841
+ if (described.collection && described.rkey) {
1842
+ meta.push({
1843
+ name: "at:author",
1844
+ content: `at://${described.did ?? described.handle}`
1845
+ });
1846
+ }
1847
+ const link = [
1848
+ { rel: "alternate", href: described.atUri }
1849
+ ];
1850
+ if (described.oembedUrl) {
1851
+ link.push({
1852
+ rel: "alternate",
1853
+ type: OEMBED_LINK_TYPE,
1854
+ href: described.oembedUrl
1855
+ });
1856
+ }
1857
+ const html = [
1858
+ ...meta.map(
1859
+ (tag) => `<meta name="${escapeHtml(tag.name)}" content="${escapeHtml(tag.content)}" />`
1860
+ ),
1861
+ ...link.map((tag) => {
1862
+ const type = tag.type ? ` type="${escapeHtml(tag.type)}"` : "";
1863
+ return `<link rel="${escapeHtml(tag.rel)}"${type} href="${escapeHtml(tag.href)}" />`;
1864
+ })
1865
+ ].join("\n");
1866
+ return { meta, link, html };
1867
+ }
1868
+ function parseTarget(input) {
1869
+ if (typeof input !== "string") {
1870
+ return input && input.handle ? normalizeParsed(input) : null;
1871
+ }
1872
+ const trimmed = input.trim();
1873
+ if (!trimmed) return null;
1874
+ if (trimmed.toLowerCase().startsWith("at://")) {
1875
+ const parsed = parseAtUri(trimmed)?.parsed;
1876
+ return parsed ? normalizeParsed(parsed) : null;
1877
+ }
1878
+ if (/^https?:\/\//i.test(trimmed)) {
1879
+ let target;
1880
+ try {
1881
+ target = new URL(trimmed);
1882
+ } catch {
1883
+ return null;
1884
+ }
1885
+ const embedded = parseEmbeddedAtUri(target.pathname);
1886
+ if (embedded) return embedded;
1887
+ const matched = matchSupportedUrl(target)?.parsed;
1888
+ return matched ? normalizeParsed(matched) : null;
1889
+ }
1890
+ return parsePath(trimmed.startsWith("@") ? trimmed.slice(1) : trimmed);
1891
+ }
1892
+ function parsePath(path) {
1893
+ const segments = path.split("/").filter(Boolean);
1894
+ const handle = decodeSegment(segments[0] ?? "");
1895
+ if (!handle) return null;
1896
+ if (!handle.startsWith("did:") && !handle.includes(".")) return null;
1897
+ const parsed = parseAtUri(`at://${segments.join("/")}`)?.parsed;
1898
+ if (!parsed) return null;
1899
+ const collection = parsed.collection ? decodeSegment(parsed.collection) : void 0;
1900
+ const rkey = parsed.rkey ? decodeSegment(parsed.rkey) : void 0;
1901
+ return {
1902
+ ...parsed,
1903
+ handle,
1904
+ did: handle.startsWith("did:") ? handle : void 0,
1905
+ collection,
1906
+ rkey,
1907
+ uri: collection && rkey ? `at://${handle}/${collection}/${rkey}` : `at://${handle}`
1908
+ };
1909
+ }
1910
+ function normalizeParsed(parsed) {
1911
+ if (!parsed.handle.startsWith("@")) return parsed;
1912
+ const handle = parsed.handle.slice(1);
1913
+ if (!handle) return parsed;
1914
+ return {
1915
+ ...parsed,
1916
+ handle,
1917
+ uri: parsed.collection && parsed.rkey ? `at://${handle}/${parsed.collection}/${parsed.rkey}` : `at://${handle}`
1918
+ };
1919
+ }
1920
+ function parseEmbeddedAtUri(pathname) {
1921
+ const match = /^\/at:\/{1,2}(.+)$/i.exec(pathname);
1922
+ if (!match) return null;
1923
+ return parsePath(match[1]);
1924
+ }
1925
+ function buildOEmbedUrl(url, collection, origin) {
1926
+ if (collection !== OEMBED_COLLECTION) return null;
1927
+ return `${normalizeOrigin(origin)}${OEMBED_PATH}?url=${encodeURIComponent(url)}`;
1928
+ }
1929
+ function describeTarget(parsed) {
1930
+ const who = parsed.handle.startsWith("did:") ? parsed.handle : `@${parsed.handle}`;
1931
+ if (parsed.type === "post") return `Post by ${who}`;
1932
+ if (parsed.type === "list") return `List by ${who}`;
1933
+ if (parsed.collection) return `${parsed.collection} record by ${who}`;
1934
+ return who;
1935
+ }
1936
+ function normalizeOrigin(origin) {
1937
+ if (!origin) return UNIVERSAL_LINK_ORIGIN;
1938
+ return origin.trim().replace(/\/+$/, "") || UNIVERSAL_LINK_ORIGIN;
1939
+ }
1940
+ function bareHost(hostname) {
1941
+ return hostname.toLowerCase().replace(/^www\./, "");
1942
+ }
1943
+ function seg(value) {
1944
+ return encodeURIComponent(value).replace(/%3A/gi, ":");
1945
+ }
1946
+ function decodeSegment(value) {
1947
+ try {
1948
+ return decodeURIComponent(value);
1949
+ } catch {
1950
+ return value;
1951
+ }
1952
+ }
1953
+ function queryString(params) {
1954
+ if (!params) return "";
1955
+ const search = new URLSearchParams();
1956
+ for (const [key, value] of Object.entries(params)) {
1957
+ if (value === null || value === void 0 || value === "") continue;
1958
+ search.set(key, String(value));
1959
+ }
1960
+ const query = search.toString();
1961
+ return query ? `?${query}` : "";
1962
+ }
1963
+ function escapeHtml(value) {
1964
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1965
+ }
1966
+
1967
+ export { CATEGORY_ORDER, COMPAT_FAMILIES, COMPAT_FAMILY_ORDER, COMPOSE_INTENT_TEXT_PLACEHOLDER, DID_REQUIRED_WAYPOINTS, OEMBED_LINK_TYPE, SUPPORTED_HOSTS, UNIVERSAL_LINK_ORIGIN, WAYPOINT_CATEGORIES_DATA, WAYPOINT_DESTINATIONS_DATA, WAYPOINT_ORDER, buildUniversalLink, buildUniversalLinkTags, buildWaypointsForParsed, describeComposeIntent, describeUniversalLink, getCategorizedWaypointsData, getComposeIntentAppUrl, getComposeIntentTemplate, getComposeIntentUrl, getComposeIntentWaypoints, getDisplayName, getFeaturedWaypointData, getRecommendedWaypointsData, getWaypointCountData, getWaypointDataForType, isSupportedHost, isUniversalLink, matchSupportedUrl, parseAtUri, parseURI, parseUniversalLink, resolveAtUri, resolveHandle, resolveHandleStatus, resolveUrl, resolveViaApi, supportsComposeIntent, waypointActivity };
1326
1968
  //# sourceMappingURL=index.js.map
1327
1969
  //# sourceMappingURL=index.js.map