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