@singi-labs/sifa-sdk 0.19.110 → 0.19.111

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.
Files changed (36) hide show
  1. package/dist/cards/app-url-patterns.js +37 -1
  2. package/dist/cards/app-url-patterns.js.map +1 -1
  3. package/dist/cards/resolve-card-url.js +15 -0
  4. package/dist/cards/resolve-card-url.js.map +1 -1
  5. package/dist/index.cjs +122 -6
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +12 -4
  8. package/dist/index.d.ts +12 -4
  9. package/dist/index.js +1 -1
  10. package/dist/{network-map-CInWwHXW.d.cts → network-map-BwM-Wvxt.d.cts} +2 -2
  11. package/dist/{network-map-zDjAIXXR.d.ts → network-map-CrkLq2Gz.d.ts} +2 -2
  12. package/dist/{profile-summary-0T0VS7Rz.d.ts → profile-summary-CwJymH9d.d.ts} +1 -1
  13. package/dist/{profile-summary-C5LlxPKK.d.cts → profile-summary-TkVxvll4.d.cts} +1 -1
  14. package/dist/query/fetchers/index.d.cts +5 -5
  15. package/dist/query/fetchers/index.d.ts +5 -5
  16. package/dist/query/hooks/index.d.cts +3 -3
  17. package/dist/query/hooks/index.d.ts +3 -3
  18. package/dist/query/index.d.cts +8 -8
  19. package/dist/query/index.d.ts +8 -8
  20. package/dist/stream/to-stream-card-vm.js +26 -0
  21. package/dist/stream/to-stream-card-vm.js.map +1 -1
  22. package/dist/stream/verbs.js +5 -3
  23. package/dist/stream/verbs.js.map +1 -1
  24. package/dist/taxonomy/activity-tiers.js +25 -0
  25. package/dist/taxonomy/activity-tiers.js.map +1 -1
  26. package/dist/taxonomy/app-categories.js +1 -0
  27. package/dist/taxonomy/app-categories.js.map +1 -1
  28. package/dist/taxonomy/app-category-map.js +12 -1
  29. package/dist/taxonomy/app-category-map.js.map +1 -1
  30. package/dist/{types-B0zZiWDH.d.ts → types-CQA_vO39.d.ts} +1 -1
  31. package/dist/{types-jE1ofoPI.d.cts → types-DMTHqi2m.d.cts} +1 -1
  32. package/dist/{use-user-settings-CCAw3_Q2.d.ts → use-user-settings-1zgi6qwl.d.ts} +2 -2
  33. package/dist/{use-user-settings-DjnTECGe.d.cts → use-user-settings-BmuAd8D4.d.cts} +2 -2
  34. package/dist/{user-settings-D_DjukR6.d.ts → user-settings-Btvrox4G.d.ts} +1 -1
  35. package/dist/{user-settings-C5sOgvhL.d.cts → user-settings-D4vdBf0A.d.cts} +1 -1
  36. package/package.json +1 -1
@@ -303,6 +303,36 @@ var APP_URL_PATTERNS = Object.freeze({
303
303
  // the shell returns 200 for any path, so the rkey itself is not checkable.
304
304
  urlPattern: "https://skymap.blue/users/{did}/maps/{rkey}",
305
305
  profileUrlPattern: "https://skymap.blue/users/{did}"
306
+ },
307
+ opendeck: {
308
+ // Nuxt SPA routes: /decks/:handle/:rkey and /profile/:handle. The shell
309
+ // returns 200 for any path, so the rkey itself is not checkable.
310
+ urlPattern: "https://opendeck.space/decks/{handle}/{rkey}",
311
+ profileUrlPattern: "https://opendeck.space/profile/{handle}"
312
+ },
313
+ chiverecipes: {
314
+ // Chive Recipes (not chive.pub, the preprint app above). Vite SPA routes: /recipe/:author/:rkey and /profile/:identifier. The shell
315
+ // returns 200 for any path. record.source is the imported original recipe,
316
+ // not the Chive page, so it never becomes the link.
317
+ urlPattern: "https://chive.pages.dev/recipe/{handle}/{rkey}",
318
+ profileUrlPattern: "https://chive.pages.dev/profile/{handle}"
319
+ },
320
+ atbb: {
321
+ // atBB forum pages are addressed by numeric appview ids (/topics/:id),
322
+ // which a record cannot supply, and there is no per-user page. Posts
323
+ // render non-clickable.
324
+ },
325
+ passingreads: {
326
+ // Verified live: /books/{bookId} server-renders the book page (bookId is
327
+ // the rkey of the org.passingreads.book.registration a note references;
328
+ // built in resolveCardUrl). /profiles/{handle} is the profile page.
329
+ profileUrlPattern: "https://www.passingreads.org/profiles/{handle}"
330
+ },
331
+ isnot: {
332
+ // Verified live: GET /reviews/{did}/{rkey} server-renders the review; an
333
+ // unknown rkey 404s. /reviews/{did} lists the person's reviews.
334
+ urlPattern: "https://isnot.at/reviews/{did}/{rkey}",
335
+ profileUrlPattern: "https://isnot.at/reviews/{did}"
306
336
  }
307
337
  });
308
338
  var COLLECTION_TO_APP = [
@@ -404,7 +434,13 @@ var COLLECTION_TO_APP = [
404
434
  // Onboard from the 2026-10-06 profile-of-the-day queue audit.
405
435
  ["supply.skillz.", "skillz"],
406
436
  ["place.birds.", "birdsplace"],
407
- ["blue.skymap.", "skymap"]
437
+ ["blue.skymap.", "skymap"],
438
+ // Onboard from the 2026-10-20 profile-of-the-day queue audit.
439
+ ["space.opendeck.", "opendeck"],
440
+ ["com.chive.", "chiverecipes"],
441
+ ["space.atbb.", "atbb"],
442
+ ["org.passingreads.", "passingreads"],
443
+ ["at.isnot.", "isnot"]
408
444
  ];
409
445
  export {
410
446
  APP_URL_PATTERNS,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cards/app-url-patterns.ts"],"sourcesContent":["/**\n * URL patterns per app, mirroring the sifa-web registry.\n *\n * These describe how to build a clickable URL for an activity card given\n * the author's handle/did and the record rkey. The patterns are templates\n * with `{handle}`, `{did}`, and `{rkey}` placeholders; each variable is\n * URI-encoded when interpolated.\n *\n * Tier semantics:\n * - `urlPattern`: per-item URL (a specific post/event/repo). Preferred.\n * - `profileUrlPattern`: per-user profile URL on the app. Fallback.\n *\n * Apps without either pattern are not clickable.\n */\nexport interface AppUrlPatterns {\n urlPattern?: string;\n profileUrlPattern?: string;\n /**\n * False when the app has no page for a single item, so nothing it records\n * can link to what the person did (the timeline never shows it). Omitted\n * means items can link, through a pattern, a resolver, or the record itself.\n */\n itemLinks?: false;\n}\n\nexport const APP_URL_PATTERNS: Readonly<Record<string, AppUrlPatterns>> = Object.freeze({\n bluesky: {\n urlPattern: 'https://bsky.app/profile/{handle}/post/{rkey}',\n profileUrlPattern: 'https://bsky.app/profile/{handle}',\n },\n tangled: {\n profileUrlPattern: 'https://tangled.sh/{handle}',\n },\n // Community-calendar events (community.lexicon.calendar.*, incl. records\n // originally authored in the decommissioned Smoke Signal). atmo.rsvp resolves\n // any such event by did+rkey, so links point there. Internal id kept as\n // `smokesignal` for continuity with existing token/color keys.\n smokesignal: {\n urlPattern: 'https://atmo.rsvp/p/{did}/e/{rkey}',\n profileUrlPattern: 'https://atmo.rsvp/p/{did}',\n },\n whitewind: {\n urlPattern: 'https://whtwnd.com/{handle}/{rkey}',\n profileUrlPattern: 'https://whtwnd.com/{handle}',\n },\n frontpage: {\n urlPattern: 'https://frontpage.fyi/post/{did}/{rkey}',\n profileUrlPattern: 'https://frontpage.fyi/profile/{did}',\n },\n linkat: {\n profileUrlPattern: 'https://linkat.blue/{handle}',\n },\n pastesphere: {\n urlPattern: 'https://pastesphere.link/user/{handle}/snippet/{rkey}',\n profileUrlPattern: 'https://pastesphere.link/user/{handle}',\n },\n kipclip: {\n profileUrlPattern: 'https://kipclip.com/{handle}',\n },\n keytrace: {\n profileUrlPattern: 'https://keytrace.dev/@{handle}',\n },\n sifa: {\n profileUrlPattern: 'https://sifa.id/p/{handle}',\n },\n popfeed: {\n urlPattern: 'https://popfeed.social/profile/{handle}',\n profileUrlPattern: 'https://popfeed.social/profile/{handle}',\n },\n streamplace: {\n profileUrlPattern: 'https://stream.place/{handle}',\n },\n semble: {\n profileUrlPattern: 'https://semble.so/profile/{handle}',\n },\n grain: {\n urlPattern: 'https://grain.social/profile/{did}/gallery/{rkey}',\n profileUrlPattern: 'https://grain.social/profile/{did}',\n },\n youandme: { profileUrlPattern: 'https://youandme.at' },\n // atmoBB per-item thread/reply URLs are built bespoke in resolve-card-url.ts\n // (raw-colon /t/{did}/{rkey}); this is only the member-profile fallback.\n atmobb: { profileUrlPattern: 'https://atmobb.app/members/{did}' },\n // anisota: upgraded with per-record URL pattern below.\n margin: { profileUrlPattern: 'https://margin.at' },\n beaconbits: { profileUrlPattern: 'https://beaconbits.app' },\n bookhive: { profileUrlPattern: 'https://bookhive.buzz/profile/{handle}' },\n colibri: { profileUrlPattern: 'https://colibri.social' },\n collectivesocial: { profileUrlPattern: 'https://app.collectivesocial.app', itemLinks: false },\n // No public per-image page and no profile URL.\n pixl: { itemLinks: false },\n github: {},\n asq: {\n // Questions live at /q/{did}/{rkey}. Answer cards build their own URL\n // from the parsed subject.uri (which points to the question), so the\n // pattern below is invoked only for question records.\n urlPattern: 'https://asq.fyi/q/{did}/{rkey}',\n profileUrlPattern: 'https://asq.fyi',\n },\n passports: {\n // Per-record URLs don't exist on passports.social — its SvelteKit routes\n // stop at /profile/{handle}/{passport_slug}. Profile-level fallback only.\n profileUrlPattern: 'https://passports.social/profile/{handle}',\n itemLinks: false,\n },\n leaflet: {\n // Per-document URLs are short and rkey-only: https://leaflet.pub/{rkey}.\n // Each publication has its own base_path subdomain (e.g. zzstoatzz.leaflet.pub)\n // but the canonical short URL renders the same document. There is no\n // per-user profile route on leaflet.pub, so the profile fallback points\n // at the marketing site.\n urlPattern: 'https://leaflet.pub/{rkey}',\n profileUrlPattern: 'https://leaflet.pub',\n },\n spark: {\n // Verified live: GET /post/{did}/{rkey} returns 200.\n urlPattern: 'https://sprk.so/post/{did}/{rkey}',\n profileUrlPattern: 'https://sprk.so/profile/{handle}',\n },\n anisota: {\n urlPattern: 'https://anisota.net/post/{did}/{rkey}',\n profileUrlPattern: 'https://anisota.net/profile/{handle}',\n },\n nooki: {\n // Per-post URLs use slug-based routing (e.g. /post/{slug}), not rkey —\n // can't be constructed from a record. Profile fallback only.\n profileUrlPattern: 'https://nooki.me/user/{handle}',\n },\n atstore: {\n // atstore.fyi has no user-profile pages (the legacy `/@{handle}` pattern\n // resolved to a 404), and no per-review deep link exists. The per-product\n // page (https://atstore.fyi/products/{slug}) is the most specific URL\n // available — resolveCardUrl builds it from record.listingMeta.slug,\n // which sifa-api enriches by resolving the review's `subject` to the\n // referenced listing.detail record.\n profileUrlPattern: 'https://atstore.fyi',\n },\n plyr: {\n // SPA — per-record URLs require JS routing. plyr.fm doesn't expose a\n // public per-handle profile page server-side either; falls back to root.\n profileUrlPattern: 'https://plyr.fm',\n },\n atfund: {\n // at.fund is auth-gated — `/give/{did}` 307s to `/` when not signed in,\n // and there's no public per-endorsement permalink. Profile fallback only,\n // pointing at the main /give listing.\n profileUrlPattern: 'https://www.at.fund/give',\n },\n crate: {\n // Crate (app.crate.social) is an authoring dashboard with no public\n // per-record viewer or per-handle profile page — records are designed to\n // render on the maker's own site. `content` cards link out via\n // record.canonicalUrl (handled in resolveCardUrl); `note` records have no\n // public URL and render non-clickable. So no app-level pattern applies.\n },\n guestbook: {\n // Guestbook (dev.baileytownsend.guestbook) is a self-hosted, cross-site\n // widget: each site owner renders their own guestbook wherever they choose,\n // so there is no canonical public per-entry viewer or per-handle profile\n // page. Entries render non-clickable. Same shape as crate `note`.\n },\n atmorsvp: {\n // atmo.rsvp events live at /p/{did}/e/{rkey}. The per-item pattern is used\n // for the event record itself; checkin records build their URL from the\n // referenced event uri in resolveCardUrl (mirrors smokesignal rsvp).\n urlPattern: 'https://atmo.rsvp/p/{did}/e/{rkey}',\n profileUrlPattern: 'https://atmo.rsvp/p/{did}',\n },\n opensocial: {\n // Open Social (opensocial.community) is community-management infrastructure\n // with no public per-membership permalink. Profile fallback only.\n profileUrlPattern: 'https://opensocial.community',\n },\n atcommons: {\n // eu.atcommons.member records are self-asserted and live in the member's\n // own repo; the commons sites (atproto.eu, atproto.nl) render a single\n // members directory with no per-member permalink. Profile fallback only.\n profileUrlPattern: 'https://atproto.eu/members',\n },\n kevara: {\n // Kevara (professional network) has no live public web surface yet — its\n // domain doesn't resolve and there's no per-record viewer. Recognized so\n // the speaker-directory card renders with the right pill, but non-clickable.\n },\n atcr: {\n // ATCR repositories live at /r/{handle}/{repository}, where the rkey of an\n // io.atcr.repo.page record is the repository name. Verified live: GET\n // /r/{handle}/{rkey} and /u/{handle} both return 200.\n urlPattern: 'https://atcr.io/r/{handle}/{rkey}',\n profileUrlPattern: 'https://atcr.io/u/{handle}',\n },\n kich: {\n // Kich recipe viewer. Verified: GET /recipes/{rkey} returns 200 (the SPA\n // renders the recipe by rkey); /recipe/{rkey} 404s. No public per-handle\n // profile page, so the profile fallback points at the app root.\n urlPattern: 'https://kich.io/recipes/{rkey}',\n profileUrlPattern: 'https://kich.io',\n },\n pinksea: {\n // PinkSea oekaki viewer. Verified live: GET /{did}/oekaki/{rkey} returns\n // 200 AND its og:image resolves to harbor.pinksea.art/{did}/{blobCid}\n // carrying the same CID as the record, so the route really is per-record\n // and not just an SPA catch-all.\n urlPattern: 'https://pinksea.art/{did}/oekaki/{rkey}',\n profileUrlPattern: 'https://pinksea.art/{did}',\n },\n hypercerts: {\n // No confirmed public per-record viewer. hyperscan.dev is a network-wide\n // search explorer (its input takes \"DID, handle, AT-URI, or PDS host\"),\n // and hypercerts.org 404s on every claim route tried. Recognized so the\n // pill renders, but cards stay non-clickable.\n },\n certified: {\n // certified.app is fully client-rendered and returns an identical\n // \"Loading\" shell for a real DID and a nonsense one, so no per-record\n // route could be verified. Non-clickable until one is confirmed.\n },\n impactindexer: {\n // Reviews are surfaced through hyperscan.dev/review, which has no\n // per-record route. Non-clickable.\n },\n recipe: {\n // recipe.exchange viewer. Verified: GET /recipes/{rkey} returns 200 (rkey\n // alone; no did needed) and /profiles/{handle} returns 200 for the author\n // page. /recipe/{rkey} 404s.\n urlPattern: 'https://recipe.exchange/recipes/{rkey}',\n profileUrlPattern: 'https://recipe.exchange/profiles/{handle}',\n },\n aetherdocs: {\n // Aether OS is a browser OS — presentations open inside it and there's no\n // public per-record viewer. The card embeds the deck from the record; the\n // outbound link points at the author's Aether OS space (mirrors aetheros).\n profileUrlPattern: 'https://aetheros.computer/{handle}',\n },\n zeens: {\n // Zeens (zeens.app) still uses its former app.photosky.* namespace. The\n // app redirects /collection/{handle}/{rkey} to the canonical path below.\n urlPattern: 'https://zeens.app/profile/{handle}/collections/{rkey}',\n profileUrlPattern: 'https://zeens.app/profile/{handle}',\n },\n chive: {\n // Chive's eprint route is a catch-all that decodes a single URI-encoded\n // at-uri segment: /eprints/<encodeURIComponent(at://did/nsid/rkey)>.\n // {did} and {rkey} are URI-encoded on interpolation, so the separators\n // are pre-encoded as literals here rather than resolved bespoke.\n urlPattern: 'https://chive.pub/eprints/at%3A%2F%2F{did}%2Fpub.chive.eprint.submission%2F{rkey}',\n profileUrlPattern: 'https://chive.pub/authors/{did}',\n },\n dropanchor: {\n // Dropanchor (dropanchor.app) renders each check-in at /checkin/{rkey} —\n // the record's own rkey, no did/handle needed. Documented as the OG-tagged\n // share URL in the dropanchorapp repos. A self-permalink for this record.\n urlPattern: 'https://dropanchor.app/checkin/{rkey}',\n },\n askeverything: {\n // Ask Everything (askeverything.app) keys its public answer route\n // (/answer/{id}) on an internal DB id, not the atproto rkey, so there's no\n // per-record permalink we can build from {did, rkey}. Its profile route\n // /profile/{handle} does resolve by atproto handle, so fall back to that.\n profileUrlPattern: 'https://askeverything.app/profile/{handle}',\n },\n greengale: {\n // GreenGale (greengale.app) renders each blog entry at /{handle}/{rkey}.\n // Verified live against a real post. Self-permalink for this record.\n urlPattern: 'https://greengale.app/{handle}/{rkey}',\n profileUrlPattern: 'https://greengale.app/{handle}',\n },\n kut: {\n // Kut (kut.social) is a community app. A membership record has no page of\n // its own; the card links to the community it joined, built from the\n // membership's community.uri in resolve-card-url. Homepage fallback only.\n profileUrlPattern: 'https://kut.social',\n },\n atmob: {\n // atmob (atmob.in) is a paste app. Its SPA viewer renders any paste by its\n // full at-uri via a `?uri=` query param (verified in the app's app.js:\n // `location.search = ?uri=${encodeURIComponent(uri)}`). Both paste.document\n // and paste.bundle share that viewer, so the per-record URL is built\n // bespoke from the item's own uri in resolve-card-url. No pattern here.\n },\n tokono: {\n // tokono (tokono.ma) hosts \"bring your own video\" records, but tokono.ma is\n // the owner's static personal site with no public per-video viewer route.\n // Cards render non-clickable.\n },\n portable: {\n // agency.portable (handle portable.agency) has no resolving website, and a\n // membership record has no page of its own. Cards render non-clickable.\n },\n rpgactor: {\n // rpg.actor renders one character page per player, holding the sprite and\n // every equipped item. Sprites (rkey self) and items (rkey = item id) have\n // no page of their own, so both link to the player. Verified live:\n // GET /{handle} returns 200 with og:url https://rpg.actor/{handle}.\n profileUrlPattern: 'https://rpg.actor/{handle}',\n },\n lexidraw: {\n // Verified live: GET /s/{did}/{rkey} returns 200 with <title> set to the\n // scene name and an og:image built from the record's thumb blob CID. Other\n // paths return the generic editor shell, so no profile page is assumed.\n urlPattern: 'https://lexidraw.app/s/{did}/{rkey}',\n },\n leadsheet: {\n // Routes from the SPA router: /sheet/:actor/:rkey and /u/:actor.\n urlPattern: 'https://leadsheet.fm/sheet/{did}/{rkey}',\n profileUrlPattern: 'https://leadsheet.fm/u/{handle}',\n },\n brickster: {\n // Verified live: GET /c/{did}/{rkey} server-renders <title>{record title}\n // | Brickster</title>. No per-user profile route found.\n urlPattern: 'https://brickster.at/c/{did}/{rkey}',\n },\n skillz: {\n // Verified live: GET /skills/{did}/{rkey} server-renders\n // <title>{slug} — skillz.supply</title>. No per-user profile route found.\n urlPattern: 'https://skillz.supply/skills/{did}/{rkey}',\n },\n birdsplace: {\n // Verified live: GET /u/{did}/{rkey} server-renders <title>{count} x\n // {species}</title>; an unknown rkey 404s. /u/{did} is the profile page.\n urlPattern: 'https://birds.place/u/{did}/{rkey}',\n profileUrlPattern: 'https://birds.place/u/{did}',\n },\n skymap: {\n // Nuxt SPA routes: /users/:username and /users/:username/maps/:mapId.\n // The username segment accepts a DID (verified live against a real map);\n // the shell returns 200 for any path, so the rkey itself is not checkable.\n urlPattern: 'https://skymap.blue/users/{did}/maps/{rkey}',\n profileUrlPattern: 'https://skymap.blue/users/{did}',\n },\n});\n\n/**\n * Map collection NSID prefixes to app ids. Order matters: longer / more\n * specific prefixes must come before broader ones.\n */\nexport const COLLECTION_TO_APP: ReadonlyArray<readonly [prefix: string, appId: string]> = [\n ['app.bsky.', 'bluesky'],\n ['sh.tangled.', 'tangled'],\n ['events.smokesignal.', 'smokesignal'],\n ['community.lexicon.calendar.', 'smokesignal'],\n ['community.lexicon.bookmarks.', 'kipclip'],\n ['com.kipclip.', 'kipclip'],\n ['blue.flashes.', 'flashes'],\n ['social.grain.', 'grain'],\n ['pics.pixl.', 'pixl'],\n ['com.whtwnd.', 'whitewind'],\n ['fyi.unravel.frontpage.', 'frontpage'],\n ['social.psky.', 'picosky'],\n ['blue.linkat.', 'linkat'],\n ['link.pastesphere.', 'pastesphere'],\n ['site.standard.', 'standard'],\n ['computer.aetheros.', 'aetheros'],\n ['space.roomy.', 'roomy'],\n ['dev.keytrace.', 'keytrace'],\n ['social.popfeed.', 'popfeed'],\n ['app.popsky.', 'popfeed'],\n ['place.stream.', 'streamplace'],\n ['app.sidetrail.', 'semble'],\n ['network.cosmik.', 'cosmik'],\n ['id.sifa.', 'sifa'],\n ['forum.barazo.', 'barazo'],\n ['xyz.statusphere.', 'statusphere'],\n ['at.youandme.', 'youandme'],\n ['net.anisota.', 'anisota'],\n ['at.margin.', 'margin'],\n ['app.beaconbits.', 'beaconbits'],\n ['buzz.bookhive.', 'bookhive'],\n ['social.colibri.', 'colibri'],\n ['social.passports.', 'passports'],\n ['fyi.asq.', 'asq'],\n ['pub.leaflet.', 'leaflet'],\n ['so.sprk.', 'spark'],\n ['community.nooki.', 'nooki'],\n ['fyi.atstore.', 'atstore'],\n ['fm.plyr.', 'plyr'],\n ['fund.at.', 'atfund'],\n ['social.crate.', 'crate'],\n ['quest.atmo.', 'atmorsvp'],\n ['community.opensocial.', 'opensocial'],\n ['eu.atcommons.', 'atcommons'],\n ['is.kevara.', 'kevara'],\n ['io.atcr.', 'atcr'],\n // Batch onboard — named pills for apps that were leaking as raw NSIDs.\n ['app.mcp.', 'mcp'],\n ['app.userinput.', 'userinput'],\n ['com.minomobi.', 'minomobi'],\n ['com.voxport.', 'voxport'],\n ['community.lexicon.badge.', 'badges'],\n ['dev.atvouch.', 'atvouch'],\n ['io.calabro.watsm.', 'watsm'],\n ['li.plonk.', 'plonk'],\n ['net.alternativeproto.', 'alternativeproto'],\n ['one.papili.', 'papili'],\n ['org.v-it.', 'vit'],\n ['st.lifepo.', 'lifepo'],\n ['stream.thought.', 'streamthought'],\n ['tech.waow.', 'waow'],\n ['wiki.lichen.', 'lichen'],\n // Onboard from aramzs.xyz profile audit.\n ['os.aether.', 'aetherdocs'],\n ['io.kich.recipe.recipe', 'kich'],\n // Onboard from moll.dev profile audit.\n ['exchange.recipe.recipe', 'recipe'],\n // Onboard from willow.sh profile audit.\n ['dev.baileytownsend.guestbook.', 'guestbook'],\n // Onboard from trezy.codes profile audit.\n ['app.atmobb.', 'atmobb'],\n // Onboard from aaronstevenwhite.io profile audit.\n ['pub.chive.', 'chive'],\n // Onboard from helene-cook.eu profile audit. Zeens kept its old NSID.\n ['app.photosky.', 'zeens'],\n // Onboard from the holke.xyz profile audit.\n ['org.hypercerts.', 'hypercerts'],\n ['app.certified.', 'certified'],\n ['org.impactindexer.', 'impactindexer'],\n ['com.shinolabs.pinksea.', 'pinksea'],\n // Onboard from tijs.org profile audit.\n ['app.dropanchor.', 'dropanchor'],\n // Onboard from angrydutchman.houseofmadness.org profile audit.\n ['app.askeverything.', 'askeverything'],\n ['app.greengale.', 'greengale'],\n ['social.kut.', 'kut'],\n // Onboard from the upcoming profile-of-the-day audit.\n ['in.atmob.paste.', 'atmob'],\n ['ma.tokono.byov.', 'tokono'],\n ['agency.portable.', 'portable'],\n ['actor.rpg.', 'rpgactor'],\n ['equipment.rpg.', 'rpgactor'],\n ['app.lexidraw.', 'lexidraw'],\n ['fm.leadsheet.', 'leadsheet'],\n ['at.brickster.', 'brickster'],\n // Onboard from the 2026-10-06 profile-of-the-day queue audit.\n ['supply.skillz.', 'skillz'],\n ['place.birds.', 'birdsplace'],\n ['blue.skymap.', 'skymap'],\n];\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/cards/app-url-patterns.ts"],"sourcesContent":["/**\n * URL patterns per app, mirroring the sifa-web registry.\n *\n * These describe how to build a clickable URL for an activity card given\n * the author's handle/did and the record rkey. The patterns are templates\n * with `{handle}`, `{did}`, and `{rkey}` placeholders; each variable is\n * URI-encoded when interpolated.\n *\n * Tier semantics:\n * - `urlPattern`: per-item URL (a specific post/event/repo). Preferred.\n * - `profileUrlPattern`: per-user profile URL on the app. Fallback.\n *\n * Apps without either pattern are not clickable.\n */\nexport interface AppUrlPatterns {\n urlPattern?: string;\n profileUrlPattern?: string;\n /**\n * False when the app has no page for a single item, so nothing it records\n * can link to what the person did (the timeline never shows it). Omitted\n * means items can link, through a pattern, a resolver, or the record itself.\n */\n itemLinks?: false;\n}\n\nexport const APP_URL_PATTERNS: Readonly<Record<string, AppUrlPatterns>> = Object.freeze({\n bluesky: {\n urlPattern: 'https://bsky.app/profile/{handle}/post/{rkey}',\n profileUrlPattern: 'https://bsky.app/profile/{handle}',\n },\n tangled: {\n profileUrlPattern: 'https://tangled.sh/{handle}',\n },\n // Community-calendar events (community.lexicon.calendar.*, incl. records\n // originally authored in the decommissioned Smoke Signal). atmo.rsvp resolves\n // any such event by did+rkey, so links point there. Internal id kept as\n // `smokesignal` for continuity with existing token/color keys.\n smokesignal: {\n urlPattern: 'https://atmo.rsvp/p/{did}/e/{rkey}',\n profileUrlPattern: 'https://atmo.rsvp/p/{did}',\n },\n whitewind: {\n urlPattern: 'https://whtwnd.com/{handle}/{rkey}',\n profileUrlPattern: 'https://whtwnd.com/{handle}',\n },\n frontpage: {\n urlPattern: 'https://frontpage.fyi/post/{did}/{rkey}',\n profileUrlPattern: 'https://frontpage.fyi/profile/{did}',\n },\n linkat: {\n profileUrlPattern: 'https://linkat.blue/{handle}',\n },\n pastesphere: {\n urlPattern: 'https://pastesphere.link/user/{handle}/snippet/{rkey}',\n profileUrlPattern: 'https://pastesphere.link/user/{handle}',\n },\n kipclip: {\n profileUrlPattern: 'https://kipclip.com/{handle}',\n },\n keytrace: {\n profileUrlPattern: 'https://keytrace.dev/@{handle}',\n },\n sifa: {\n profileUrlPattern: 'https://sifa.id/p/{handle}',\n },\n popfeed: {\n urlPattern: 'https://popfeed.social/profile/{handle}',\n profileUrlPattern: 'https://popfeed.social/profile/{handle}',\n },\n streamplace: {\n profileUrlPattern: 'https://stream.place/{handle}',\n },\n semble: {\n profileUrlPattern: 'https://semble.so/profile/{handle}',\n },\n grain: {\n urlPattern: 'https://grain.social/profile/{did}/gallery/{rkey}',\n profileUrlPattern: 'https://grain.social/profile/{did}',\n },\n youandme: { profileUrlPattern: 'https://youandme.at' },\n // atmoBB per-item thread/reply URLs are built bespoke in resolve-card-url.ts\n // (raw-colon /t/{did}/{rkey}); this is only the member-profile fallback.\n atmobb: { profileUrlPattern: 'https://atmobb.app/members/{did}' },\n // anisota: upgraded with per-record URL pattern below.\n margin: { profileUrlPattern: 'https://margin.at' },\n beaconbits: { profileUrlPattern: 'https://beaconbits.app' },\n bookhive: { profileUrlPattern: 'https://bookhive.buzz/profile/{handle}' },\n colibri: { profileUrlPattern: 'https://colibri.social' },\n collectivesocial: { profileUrlPattern: 'https://app.collectivesocial.app', itemLinks: false },\n // No public per-image page and no profile URL.\n pixl: { itemLinks: false },\n github: {},\n asq: {\n // Questions live at /q/{did}/{rkey}. Answer cards build their own URL\n // from the parsed subject.uri (which points to the question), so the\n // pattern below is invoked only for question records.\n urlPattern: 'https://asq.fyi/q/{did}/{rkey}',\n profileUrlPattern: 'https://asq.fyi',\n },\n passports: {\n // Per-record URLs don't exist on passports.social — its SvelteKit routes\n // stop at /profile/{handle}/{passport_slug}. Profile-level fallback only.\n profileUrlPattern: 'https://passports.social/profile/{handle}',\n itemLinks: false,\n },\n leaflet: {\n // Per-document URLs are short and rkey-only: https://leaflet.pub/{rkey}.\n // Each publication has its own base_path subdomain (e.g. zzstoatzz.leaflet.pub)\n // but the canonical short URL renders the same document. There is no\n // per-user profile route on leaflet.pub, so the profile fallback points\n // at the marketing site.\n urlPattern: 'https://leaflet.pub/{rkey}',\n profileUrlPattern: 'https://leaflet.pub',\n },\n spark: {\n // Verified live: GET /post/{did}/{rkey} returns 200.\n urlPattern: 'https://sprk.so/post/{did}/{rkey}',\n profileUrlPattern: 'https://sprk.so/profile/{handle}',\n },\n anisota: {\n urlPattern: 'https://anisota.net/post/{did}/{rkey}',\n profileUrlPattern: 'https://anisota.net/profile/{handle}',\n },\n nooki: {\n // Per-post URLs use slug-based routing (e.g. /post/{slug}), not rkey —\n // can't be constructed from a record. Profile fallback only.\n profileUrlPattern: 'https://nooki.me/user/{handle}',\n },\n atstore: {\n // atstore.fyi has no user-profile pages (the legacy `/@{handle}` pattern\n // resolved to a 404), and no per-review deep link exists. The per-product\n // page (https://atstore.fyi/products/{slug}) is the most specific URL\n // available — resolveCardUrl builds it from record.listingMeta.slug,\n // which sifa-api enriches by resolving the review's `subject` to the\n // referenced listing.detail record.\n profileUrlPattern: 'https://atstore.fyi',\n },\n plyr: {\n // SPA — per-record URLs require JS routing. plyr.fm doesn't expose a\n // public per-handle profile page server-side either; falls back to root.\n profileUrlPattern: 'https://plyr.fm',\n },\n atfund: {\n // at.fund is auth-gated — `/give/{did}` 307s to `/` when not signed in,\n // and there's no public per-endorsement permalink. Profile fallback only,\n // pointing at the main /give listing.\n profileUrlPattern: 'https://www.at.fund/give',\n },\n crate: {\n // Crate (app.crate.social) is an authoring dashboard with no public\n // per-record viewer or per-handle profile page — records are designed to\n // render on the maker's own site. `content` cards link out via\n // record.canonicalUrl (handled in resolveCardUrl); `note` records have no\n // public URL and render non-clickable. So no app-level pattern applies.\n },\n guestbook: {\n // Guestbook (dev.baileytownsend.guestbook) is a self-hosted, cross-site\n // widget: each site owner renders their own guestbook wherever they choose,\n // so there is no canonical public per-entry viewer or per-handle profile\n // page. Entries render non-clickable. Same shape as crate `note`.\n },\n atmorsvp: {\n // atmo.rsvp events live at /p/{did}/e/{rkey}. The per-item pattern is used\n // for the event record itself; checkin records build their URL from the\n // referenced event uri in resolveCardUrl (mirrors smokesignal rsvp).\n urlPattern: 'https://atmo.rsvp/p/{did}/e/{rkey}',\n profileUrlPattern: 'https://atmo.rsvp/p/{did}',\n },\n opensocial: {\n // Open Social (opensocial.community) is community-management infrastructure\n // with no public per-membership permalink. Profile fallback only.\n profileUrlPattern: 'https://opensocial.community',\n },\n atcommons: {\n // eu.atcommons.member records are self-asserted and live in the member's\n // own repo; the commons sites (atproto.eu, atproto.nl) render a single\n // members directory with no per-member permalink. Profile fallback only.\n profileUrlPattern: 'https://atproto.eu/members',\n },\n kevara: {\n // Kevara (professional network) has no live public web surface yet — its\n // domain doesn't resolve and there's no per-record viewer. Recognized so\n // the speaker-directory card renders with the right pill, but non-clickable.\n },\n atcr: {\n // ATCR repositories live at /r/{handle}/{repository}, where the rkey of an\n // io.atcr.repo.page record is the repository name. Verified live: GET\n // /r/{handle}/{rkey} and /u/{handle} both return 200.\n urlPattern: 'https://atcr.io/r/{handle}/{rkey}',\n profileUrlPattern: 'https://atcr.io/u/{handle}',\n },\n kich: {\n // Kich recipe viewer. Verified: GET /recipes/{rkey} returns 200 (the SPA\n // renders the recipe by rkey); /recipe/{rkey} 404s. No public per-handle\n // profile page, so the profile fallback points at the app root.\n urlPattern: 'https://kich.io/recipes/{rkey}',\n profileUrlPattern: 'https://kich.io',\n },\n pinksea: {\n // PinkSea oekaki viewer. Verified live: GET /{did}/oekaki/{rkey} returns\n // 200 AND its og:image resolves to harbor.pinksea.art/{did}/{blobCid}\n // carrying the same CID as the record, so the route really is per-record\n // and not just an SPA catch-all.\n urlPattern: 'https://pinksea.art/{did}/oekaki/{rkey}',\n profileUrlPattern: 'https://pinksea.art/{did}',\n },\n hypercerts: {\n // No confirmed public per-record viewer. hyperscan.dev is a network-wide\n // search explorer (its input takes \"DID, handle, AT-URI, or PDS host\"),\n // and hypercerts.org 404s on every claim route tried. Recognized so the\n // pill renders, but cards stay non-clickable.\n },\n certified: {\n // certified.app is fully client-rendered and returns an identical\n // \"Loading\" shell for a real DID and a nonsense one, so no per-record\n // route could be verified. Non-clickable until one is confirmed.\n },\n impactindexer: {\n // Reviews are surfaced through hyperscan.dev/review, which has no\n // per-record route. Non-clickable.\n },\n recipe: {\n // recipe.exchange viewer. Verified: GET /recipes/{rkey} returns 200 (rkey\n // alone; no did needed) and /profiles/{handle} returns 200 for the author\n // page. /recipe/{rkey} 404s.\n urlPattern: 'https://recipe.exchange/recipes/{rkey}',\n profileUrlPattern: 'https://recipe.exchange/profiles/{handle}',\n },\n aetherdocs: {\n // Aether OS is a browser OS — presentations open inside it and there's no\n // public per-record viewer. The card embeds the deck from the record; the\n // outbound link points at the author's Aether OS space (mirrors aetheros).\n profileUrlPattern: 'https://aetheros.computer/{handle}',\n },\n zeens: {\n // Zeens (zeens.app) still uses its former app.photosky.* namespace. The\n // app redirects /collection/{handle}/{rkey} to the canonical path below.\n urlPattern: 'https://zeens.app/profile/{handle}/collections/{rkey}',\n profileUrlPattern: 'https://zeens.app/profile/{handle}',\n },\n chive: {\n // Chive's eprint route is a catch-all that decodes a single URI-encoded\n // at-uri segment: /eprints/<encodeURIComponent(at://did/nsid/rkey)>.\n // {did} and {rkey} are URI-encoded on interpolation, so the separators\n // are pre-encoded as literals here rather than resolved bespoke.\n urlPattern: 'https://chive.pub/eprints/at%3A%2F%2F{did}%2Fpub.chive.eprint.submission%2F{rkey}',\n profileUrlPattern: 'https://chive.pub/authors/{did}',\n },\n dropanchor: {\n // Dropanchor (dropanchor.app) renders each check-in at /checkin/{rkey} —\n // the record's own rkey, no did/handle needed. Documented as the OG-tagged\n // share URL in the dropanchorapp repos. A self-permalink for this record.\n urlPattern: 'https://dropanchor.app/checkin/{rkey}',\n },\n askeverything: {\n // Ask Everything (askeverything.app) keys its public answer route\n // (/answer/{id}) on an internal DB id, not the atproto rkey, so there's no\n // per-record permalink we can build from {did, rkey}. Its profile route\n // /profile/{handle} does resolve by atproto handle, so fall back to that.\n profileUrlPattern: 'https://askeverything.app/profile/{handle}',\n },\n greengale: {\n // GreenGale (greengale.app) renders each blog entry at /{handle}/{rkey}.\n // Verified live against a real post. Self-permalink for this record.\n urlPattern: 'https://greengale.app/{handle}/{rkey}',\n profileUrlPattern: 'https://greengale.app/{handle}',\n },\n kut: {\n // Kut (kut.social) is a community app. A membership record has no page of\n // its own; the card links to the community it joined, built from the\n // membership's community.uri in resolve-card-url. Homepage fallback only.\n profileUrlPattern: 'https://kut.social',\n },\n atmob: {\n // atmob (atmob.in) is a paste app. Its SPA viewer renders any paste by its\n // full at-uri via a `?uri=` query param (verified in the app's app.js:\n // `location.search = ?uri=${encodeURIComponent(uri)}`). Both paste.document\n // and paste.bundle share that viewer, so the per-record URL is built\n // bespoke from the item's own uri in resolve-card-url. No pattern here.\n },\n tokono: {\n // tokono (tokono.ma) hosts \"bring your own video\" records, but tokono.ma is\n // the owner's static personal site with no public per-video viewer route.\n // Cards render non-clickable.\n },\n portable: {\n // agency.portable (handle portable.agency) has no resolving website, and a\n // membership record has no page of its own. Cards render non-clickable.\n },\n rpgactor: {\n // rpg.actor renders one character page per player, holding the sprite and\n // every equipped item. Sprites (rkey self) and items (rkey = item id) have\n // no page of their own, so both link to the player. Verified live:\n // GET /{handle} returns 200 with og:url https://rpg.actor/{handle}.\n profileUrlPattern: 'https://rpg.actor/{handle}',\n },\n lexidraw: {\n // Verified live: GET /s/{did}/{rkey} returns 200 with <title> set to the\n // scene name and an og:image built from the record's thumb blob CID. Other\n // paths return the generic editor shell, so no profile page is assumed.\n urlPattern: 'https://lexidraw.app/s/{did}/{rkey}',\n },\n leadsheet: {\n // Routes from the SPA router: /sheet/:actor/:rkey and /u/:actor.\n urlPattern: 'https://leadsheet.fm/sheet/{did}/{rkey}',\n profileUrlPattern: 'https://leadsheet.fm/u/{handle}',\n },\n brickster: {\n // Verified live: GET /c/{did}/{rkey} server-renders <title>{record title}\n // | Brickster</title>. No per-user profile route found.\n urlPattern: 'https://brickster.at/c/{did}/{rkey}',\n },\n skillz: {\n // Verified live: GET /skills/{did}/{rkey} server-renders\n // <title>{slug} — skillz.supply</title>. No per-user profile route found.\n urlPattern: 'https://skillz.supply/skills/{did}/{rkey}',\n },\n birdsplace: {\n // Verified live: GET /u/{did}/{rkey} server-renders <title>{count} x\n // {species}</title>; an unknown rkey 404s. /u/{did} is the profile page.\n urlPattern: 'https://birds.place/u/{did}/{rkey}',\n profileUrlPattern: 'https://birds.place/u/{did}',\n },\n skymap: {\n // Nuxt SPA routes: /users/:username and /users/:username/maps/:mapId.\n // The username segment accepts a DID (verified live against a real map);\n // the shell returns 200 for any path, so the rkey itself is not checkable.\n urlPattern: 'https://skymap.blue/users/{did}/maps/{rkey}',\n profileUrlPattern: 'https://skymap.blue/users/{did}',\n },\n opendeck: {\n // Nuxt SPA routes: /decks/:handle/:rkey and /profile/:handle. The shell\n // returns 200 for any path, so the rkey itself is not checkable.\n urlPattern: 'https://opendeck.space/decks/{handle}/{rkey}',\n profileUrlPattern: 'https://opendeck.space/profile/{handle}',\n },\n chiverecipes: {\n // Chive Recipes (not chive.pub, the preprint app above). Vite SPA routes: /recipe/:author/:rkey and /profile/:identifier. The shell\n // returns 200 for any path. record.source is the imported original recipe,\n // not the Chive page, so it never becomes the link.\n urlPattern: 'https://chive.pages.dev/recipe/{handle}/{rkey}',\n profileUrlPattern: 'https://chive.pages.dev/profile/{handle}',\n },\n atbb: {\n // atBB forum pages are addressed by numeric appview ids (/topics/:id),\n // which a record cannot supply, and there is no per-user page. Posts\n // render non-clickable.\n },\n passingreads: {\n // Verified live: /books/{bookId} server-renders the book page (bookId is\n // the rkey of the org.passingreads.book.registration a note references;\n // built in resolveCardUrl). /profiles/{handle} is the profile page.\n profileUrlPattern: 'https://www.passingreads.org/profiles/{handle}',\n },\n isnot: {\n // Verified live: GET /reviews/{did}/{rkey} server-renders the review; an\n // unknown rkey 404s. /reviews/{did} lists the person's reviews.\n urlPattern: 'https://isnot.at/reviews/{did}/{rkey}',\n profileUrlPattern: 'https://isnot.at/reviews/{did}',\n },\n});\n\n/**\n * Map collection NSID prefixes to app ids. Order matters: longer / more\n * specific prefixes must come before broader ones.\n */\nexport const COLLECTION_TO_APP: ReadonlyArray<readonly [prefix: string, appId: string]> = [\n ['app.bsky.', 'bluesky'],\n ['sh.tangled.', 'tangled'],\n ['events.smokesignal.', 'smokesignal'],\n ['community.lexicon.calendar.', 'smokesignal'],\n ['community.lexicon.bookmarks.', 'kipclip'],\n ['com.kipclip.', 'kipclip'],\n ['blue.flashes.', 'flashes'],\n ['social.grain.', 'grain'],\n ['pics.pixl.', 'pixl'],\n ['com.whtwnd.', 'whitewind'],\n ['fyi.unravel.frontpage.', 'frontpage'],\n ['social.psky.', 'picosky'],\n ['blue.linkat.', 'linkat'],\n ['link.pastesphere.', 'pastesphere'],\n ['site.standard.', 'standard'],\n ['computer.aetheros.', 'aetheros'],\n ['space.roomy.', 'roomy'],\n ['dev.keytrace.', 'keytrace'],\n ['social.popfeed.', 'popfeed'],\n ['app.popsky.', 'popfeed'],\n ['place.stream.', 'streamplace'],\n ['app.sidetrail.', 'semble'],\n ['network.cosmik.', 'cosmik'],\n ['id.sifa.', 'sifa'],\n ['forum.barazo.', 'barazo'],\n ['xyz.statusphere.', 'statusphere'],\n ['at.youandme.', 'youandme'],\n ['net.anisota.', 'anisota'],\n ['at.margin.', 'margin'],\n ['app.beaconbits.', 'beaconbits'],\n ['buzz.bookhive.', 'bookhive'],\n ['social.colibri.', 'colibri'],\n ['social.passports.', 'passports'],\n ['fyi.asq.', 'asq'],\n ['pub.leaflet.', 'leaflet'],\n ['so.sprk.', 'spark'],\n ['community.nooki.', 'nooki'],\n ['fyi.atstore.', 'atstore'],\n ['fm.plyr.', 'plyr'],\n ['fund.at.', 'atfund'],\n ['social.crate.', 'crate'],\n ['quest.atmo.', 'atmorsvp'],\n ['community.opensocial.', 'opensocial'],\n ['eu.atcommons.', 'atcommons'],\n ['is.kevara.', 'kevara'],\n ['io.atcr.', 'atcr'],\n // Batch onboard — named pills for apps that were leaking as raw NSIDs.\n ['app.mcp.', 'mcp'],\n ['app.userinput.', 'userinput'],\n ['com.minomobi.', 'minomobi'],\n ['com.voxport.', 'voxport'],\n ['community.lexicon.badge.', 'badges'],\n ['dev.atvouch.', 'atvouch'],\n ['io.calabro.watsm.', 'watsm'],\n ['li.plonk.', 'plonk'],\n ['net.alternativeproto.', 'alternativeproto'],\n ['one.papili.', 'papili'],\n ['org.v-it.', 'vit'],\n ['st.lifepo.', 'lifepo'],\n ['stream.thought.', 'streamthought'],\n ['tech.waow.', 'waow'],\n ['wiki.lichen.', 'lichen'],\n // Onboard from aramzs.xyz profile audit.\n ['os.aether.', 'aetherdocs'],\n ['io.kich.recipe.recipe', 'kich'],\n // Onboard from moll.dev profile audit.\n ['exchange.recipe.recipe', 'recipe'],\n // Onboard from willow.sh profile audit.\n ['dev.baileytownsend.guestbook.', 'guestbook'],\n // Onboard from trezy.codes profile audit.\n ['app.atmobb.', 'atmobb'],\n // Onboard from aaronstevenwhite.io profile audit.\n ['pub.chive.', 'chive'],\n // Onboard from helene-cook.eu profile audit. Zeens kept its old NSID.\n ['app.photosky.', 'zeens'],\n // Onboard from the holke.xyz profile audit.\n ['org.hypercerts.', 'hypercerts'],\n ['app.certified.', 'certified'],\n ['org.impactindexer.', 'impactindexer'],\n ['com.shinolabs.pinksea.', 'pinksea'],\n // Onboard from tijs.org profile audit.\n ['app.dropanchor.', 'dropanchor'],\n // Onboard from angrydutchman.houseofmadness.org profile audit.\n ['app.askeverything.', 'askeverything'],\n ['app.greengale.', 'greengale'],\n ['social.kut.', 'kut'],\n // Onboard from the upcoming profile-of-the-day audit.\n ['in.atmob.paste.', 'atmob'],\n ['ma.tokono.byov.', 'tokono'],\n ['agency.portable.', 'portable'],\n ['actor.rpg.', 'rpgactor'],\n ['equipment.rpg.', 'rpgactor'],\n ['app.lexidraw.', 'lexidraw'],\n ['fm.leadsheet.', 'leadsheet'],\n ['at.brickster.', 'brickster'],\n // Onboard from the 2026-10-06 profile-of-the-day queue audit.\n ['supply.skillz.', 'skillz'],\n ['place.birds.', 'birdsplace'],\n ['blue.skymap.', 'skymap'],\n // Onboard from the 2026-10-20 profile-of-the-day queue audit.\n ['space.opendeck.', 'opendeck'],\n ['com.chive.', 'chiverecipes'],\n ['space.atbb.', 'atbb'],\n ['org.passingreads.', 'passingreads'],\n ['at.isnot.', 'isnot'],\n];\n"],"mappings":"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","names":[]}
@@ -228,6 +228,21 @@ function resolveCardHealth(item) {
228
228
  if (collection === "exchange.recipe.recipe") {
229
229
  return patternHealth("recipe", { handle: authorHandle, did: authorDid, rkey }, collection);
230
230
  }
231
+ if (collection === "org.passingreads.book.note") {
232
+ const book = record.book;
233
+ if (book != null && typeof book === "object") {
234
+ const bookUri = stringOrNull(book.uri);
235
+ const parsed = bookUri ? parseAtUri(bookUri) : null;
236
+ if (parsed) {
237
+ return urlHealth(`https://www.passingreads.org/books/${encodeURIComponent(parsed.rkey)}`);
238
+ }
239
+ }
240
+ return patternHealth(
241
+ "passingreads",
242
+ { handle: authorHandle, did: authorDid, rkey },
243
+ collection
244
+ );
245
+ }
231
246
  if (collection.startsWith("in.atmob.paste.")) {
232
247
  if (uri) return recordHealth(`https://atmob.in/?uri=${encodeURIComponent(uri)}`);
233
248
  return { url: null, strategy: "none" };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cards/resolve-card-url.ts"],"sourcesContent":["import { APP_URL_PATTERNS, COLLECTION_TO_APP } from './app-url-patterns.js';\n\n/**\n * An activity item as consumed by the Sifa activity-card components and\n * the sifa-api external-URL health scanner. The shape mirrors the props\n * the cards already use, so the resolver returns the same URL the UI\n * renders.\n */\nexport interface ActivityItemForUrl {\n /** Collection NSID, e.g. \"sh.tangled.graph.repo\". */\n collection: string;\n /** The raw record from the PDS / API. */\n record: Record<string, unknown>;\n /** Full at-uri of the record (used by collections that derive URL from uri). */\n uri: string;\n /** Record key. */\n rkey: string;\n /** DID of the author of the record. */\n authorDid: string;\n /** Optional handle (cards prefer handle in URL paths when available). */\n authorHandle?: string;\n}\n\n/**\n * Map a collection NSID to its app id, mirroring the sifa-web prefix map.\n *\n * Falls back to the first two NSID segments (e.g. \"org.hyperboards\") when\n * the prefix is not registered, matching the existing card behaviour.\n */\nexport function getAppIdForCollection(collection: string): string {\n for (const [prefix, appId] of COLLECTION_TO_APP) {\n if (collection.startsWith(prefix)) return appId;\n }\n const parts = collection.split('.');\n if (parts.length >= 2) return `${parts[0]}.${parts[1]}`;\n return collection;\n}\n\n/**\n * How the activity-card health scanner should verify a card is still live.\n *\n * - `record`: the URL is a first-party permalink that renders THIS record\n * (e.g. a Bluesky post at bsky.app, a tangled repo, a smokesignal event).\n * The authoritative liveness check is \"does the record still exist on its\n * PDS\" — `com.atproto.repo.getRecord` on the item's own at-uri — NOT an\n * HTTP probe of the rendering app. Rendering apps (bsky.app, tangled.sh)\n * frequently answer HEAD with 404/405 while serving GET fine, which\n * false-positives every permalink as broken.\n * - `url`: the URL is a foreign or derived target — a bookmarked page, an\n * external publisher, a *different* record's page, or a profile page.\n * Its reachability is independent of this record, so probe the URL.\n * - `none`: the card has no clickable URL; nothing to check.\n */\nexport type CardHealthStrategy = 'record' | 'url' | 'none';\n\nexport interface CardHealth {\n /** The clickable URL the card renders. Null when non-clickable. */\n url: string | null;\n /** How the scanner should verify liveness. See {@link CardHealthStrategy}. */\n strategy: CardHealthStrategy;\n /**\n * True when `url` is only the author's profile or the app's homepage, used\n * because the record has no page of its own. Such a link does not point at\n * what the person did, so the timeline does not count it as one.\n */\n isProfileFallback?: boolean;\n}\n\n/** Build a `url`-strategy result (foreign/derived/profile target). */\nfunction urlHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url' } : { url: null, strategy: 'none' };\n}\n\n/** Build a profile / app-homepage fallback result (not a link to the record itself). */\nfunction profileHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url', isProfileFallback: true } : { url: null, strategy: 'none' };\n}\n\n/** Build a `record`-strategy result (self-permalink of this record). */\nfunction recordHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'record' } : { url: null, strategy: 'none' };\n}\n\nfunction interpolate(pattern: string, vars: Record<string, string | undefined>): string | null {\n let result = pattern;\n for (const [key, value] of Object.entries(vars)) {\n if (result.includes(`{${key}}`)) {\n if (!value) return null;\n result = result.replaceAll(`{${key}}`, encodeURIComponent(value));\n }\n }\n return result;\n}\n\n/**\n * Whether the per-item `urlPattern` for `appId` is appropriate for `collection`.\n *\n * For bluesky, the per-item URL pattern only matches `app.bsky.feed.post` —\n * other `app.bsky.*` collections (`actor.status`, `graph.cancellation`, etc.)\n * use rkeys that would 404 at `bsky.app/profile/{handle}/post/{rkey}`. Fall\n * back to the profile URL for those. See sifa-web#1070 / sifa-web#1073.\n *\n * When `collection` is undefined we err on the safe side for bluesky and\n * prefer the profile URL.\n */\nfunction shouldUseItemPattern(appId: string, collection: string | undefined): boolean {\n if (appId === 'bluesky') {\n return collection === 'app.bsky.feed.post';\n }\n return true;\n}\n\n/**\n * Resolve a pattern-based URL, reporting which tier produced it:\n * - `item`: a per-item permalink (renders this specific record) -> record-checkable.\n * - `profile`: the app's profile page (not this record) -> url-checkable.\n * - `null`: no pattern applied.\n */\nfunction patternHealth(\n appId: string,\n vars: { handle?: string; did?: string; rkey?: string },\n collection?: string,\n): CardHealth {\n const patterns = APP_URL_PATTERNS[appId];\n if (!patterns) return { url: null, strategy: 'none' };\n\n if (patterns.urlPattern && shouldUseItemPattern(appId, collection)) {\n const url = interpolate(patterns.urlPattern, vars);\n if (url) return recordHealth(url);\n }\n if (patterns.profileUrlPattern) {\n const url = interpolate(patterns.profileUrlPattern, vars);\n if (url) return profileHealth(url);\n }\n return { url: null, strategy: 'none' };\n}\n\n/**\n * Valid tangled repo slug: alphanumeric, dot, dash, underscore. No whitespace,\n * slashes, or URL-encodable special characters. Multi-segment aggregate `name`\n * values produce 404 URLs, so callers fall back to the profile URL when\n * invalid. See sifa-web#1071 / sifa-web#1072.\n */\nconst TANGLED_REPO_SLUG = /^[a-zA-Z0-9._-]+$/;\n\nfunction stringOrNull(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n// at://did:plc:xxx/community.lexicon.calendar.event/rkey\n// -> { did: \"did:plc:xxx\", rkey: \"rkey\" }\nfunction parseAtUri(atUri: string): { did: string; rkey: string } | null {\n const match = atUri.match(/^at:\\/\\/(did:[^/]+)\\/[^/]+\\/(.+)$/);\n if (!match || !match[1] || !match[2]) return null;\n return { did: match[1], rkey: match[2] };\n}\n\nconst TANGLED = 'https://tangled.sh';\n\n/** A Tangled repo reference (a repo DID, or an `at://` repo record uri) as its tangled.sh base URL. */\nfunction tangledRepoBase(ref: unknown): string | null {\n const value = stringOrNull(ref);\n if (!value) return null;\n if (value.startsWith('did:')) return `${TANGLED}/${value}`;\n const parsed = parseAtUri(value);\n if (parsed && TANGLED_REPO_SLUG.test(parsed.rkey))\n return `${TANGLED}/${parsed.did}/${parsed.rkey}`;\n return null;\n}\n\n/**\n * The per-item tangled.sh URL for a Tangled record, or null when none can be\n * built. `ownRecord` is true when the URL is the record's own page (a repo).\n */\nfunction resolveTangledUrl(\n collection: string,\n record: Record<string, unknown>,\n rkey: string,\n owner: string,\n): { url: string; ownRecord: boolean } | null {\n if (collection === 'sh.tangled.repo.pull' || collection === 'sh.tangled.repo.issue') {\n const target =\n record.target && typeof record.target === 'object'\n ? (record.target as Record<string, unknown>)\n : {};\n const base =\n tangledRepoBase(target.repoDid) ??\n tangledRepoBase(target.repo) ??\n tangledRepoBase(record.repoDid) ??\n tangledRepoBase(record.repo);\n if (!base) return null;\n const list = collection === 'sh.tangled.repo.pull' ? 'pulls' : 'issues';\n return { url: `${base}/${list}`, ownRecord: false };\n }\n if (collection === 'sh.tangled.feed.star') {\n const starred = tangledRepoBase(record.subjectDid) ?? tangledRepoBase(record.subject);\n return starred ? { url: starred, ownRecord: false } : null;\n }\n const repoDid = tangledRepoBase(record.repoDid);\n if (repoDid) return { url: repoDid, ownRecord: true };\n const repoName = stringOrNull(record.name) ?? (collection === 'sh.tangled.repo' ? rkey : null);\n if (repoName && owner && TANGLED_REPO_SLUG.test(repoName)) {\n return { url: `${TANGLED}/${owner}/${repoName}`, ownRecord: true };\n }\n return null;\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item **and** how its\n * liveness should be checked — the single source of truth shared by:\n * - sifa-web activity cards (which render `.url`), and\n * - the sifa-api link-health scanner (which switches on `.strategy`: probe the\n * record's PDS for `record`-strategy cards, probe the URL for `url`).\n *\n * See {@link CardHealthStrategy} for why self-permalinks (bsky posts, etc.)\n * must be verified by record existence rather than an HTTP probe of the app.\n *\n * Resolution order:\n * 1. Per-collection bespoke logic (tangled, userinput, bookhive, kipclip, margin, smokesignal\n * rsvp, standard documents). These mirror the inline logic in the\n * individual card components.\n * 2. Generic `record.url` field (used by hyperboards and similar).\n * 3. Pattern-based per-item / profile URL from the registry.\n */\nexport function resolveCardHealth(item: ActivityItemForUrl): CardHealth {\n const { collection, record, uri, rkey, authorDid, authorHandle } = item;\n const appId = getAppIdForCollection(collection);\n\n // --- Per-collection bespoke logic (mirrors the card components) ---\n\n // Tangled. tangled.sh resolves both DIDs and handles in the path, so a link\n // never depends on the author handle being known:\n // - a repo: by its repo DID when the record carries one, else owner + slug\n // (`record.name`, or the rkey for `sh.tangled.repo`, whose newer records\n // use the slug as the rkey). Only valid slugs: multi-segment aggregate\n // names would 404 (sifa-web#1071).\n // - a pull / issue: the target repo's pulls / issues list. The record carries\n // no PR or issue number, so the single item is not addressable.\n // - a star: the starred repo.\n // Anything else falls back to the owner's profile.\n if (collection.startsWith('sh.tangled.')) {\n const tangled = resolveTangledUrl(collection, record, rkey, authorHandle ?? authorDid);\n // A repo link is this record's own page; a pull / issue / star links to a\n // different record (the target repo), so probe that page itself.\n if (tangled) return tangled.ownRecord ? recordHealth(tangled.url) : urlHealth(tangled.url);\n if (!authorHandle && authorDid) return profileHealth(`${TANGLED}/${authorDid}`);\n return patternHealth('tangled', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // User Input: a discussion has its own page; a reply links to the discussion\n // it belongs to (its `subject`).\n if (collection === 'app.userinput.discussion' && authorDid) {\n return recordHealth(`https://userinput.app/d/${authorDid}/${rkey}`);\n }\n if (collection === 'app.userinput.reply') {\n const subject = record.subject;\n const subjectUri =\n subject != null && typeof subject === 'object'\n ? stringOrNull((subject as Record<string, unknown>).uri)\n : null;\n const parsed = subjectUri?.includes('/app.userinput.discussion/')\n ? parseAtUri(subjectUri)\n : null;\n if (parsed) return urlHealth(`https://userinput.app/d/${parsed.did}/${parsed.rkey}`);\n return { url: null, strategy: 'none' };\n }\n\n // BookHive: a book log links to the book's page by the record's `hiveId`.\n // The book page is a different record (shared by every reader), so its\n // reachability is probed as a URL.\n if (collection === 'buzz.bookhive.book') {\n const hiveId = stringOrNull(record.hiveId);\n if (hiveId && /^bk_[A-Za-z0-9_-]+$/.test(hiveId)) {\n return urlHealth(`https://bookhive.buzz/books/${hiveId}`);\n }\n return patternHealth('bookhive', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // Kipclip / community.lexicon.bookmarks: the bookmark subject IS the URL —\n // a foreign page whose reachability is independent of the bookmark record.\n if (\n collection.startsWith('com.kipclip.') ||\n collection.startsWith('community.lexicon.bookmarks.')\n ) {\n const subject = stringOrNull(record.subject);\n if (subject) return urlHealth(subject);\n return patternHealth('kipclip', { handle: authorHandle, did: authorDid, rkey });\n }\n\n // Margin bookmark: card returns null entirely without record.source\n if (collection === 'at.margin.bookmark') {\n return urlHealth(stringOrNull(record.source));\n }\n\n // Margin note / annotation: both are W3C web annotations whose `target.source`\n // is the annotated page URL. Link to that source; fall back to the margin app.\n if (collection === 'at.margin.annotation' || collection === 'at.margin.note') {\n const target = record.target;\n if (target != null && typeof target === 'object') {\n const source = stringOrNull((target as Record<string, unknown>).source);\n if (source) return urlHealth(source);\n }\n return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);\n }\n\n // Community-calendar RSVP: parse the linked event uri. The URL points at a\n // *different* record (the event), so probe the URL, not this RSVP record.\n // Rendered via atmo.rsvp, which resolves any community.lexicon.calendar event\n // by did+rkey (Smoke Signal is decommissioned; its permalinks are retired).\n if (collection === 'community.lexicon.calendar.rsvp') {\n const subject = record.subject;\n if (subject != null && typeof subject === 'object') {\n const subjectUri = stringOrNull((subject as Record<string, unknown>).uri);\n if (subjectUri) {\n const parsed = parseAtUri(subjectUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // Community-calendar event itself: use item's own uri -> self-permalink on\n // atmo.rsvp (same rationale as the RSVP block above).\n if (collection === 'community.lexicon.calendar.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp event itself: use item's own uri -> self-permalink.\n if (collection === 'quest.atmo.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp checkin: parse the linked event uri (record.event) and link to\n // that event's page (mirrors smokesignal rsvp). The URL points at a\n // *different* record (the event), so probe the URL. The checkin's own rkey is\n // not addressable, so without a valid event reference it's non-clickable.\n if (collection === 'quest.atmo.checkin') {\n const eventUri = stringOrNull(record.event);\n if (eventUri) {\n const parsed = parseAtUri(eventUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB forum (atmobb.app): the universal viewer renders any thread at\n // /t/{thread-owner-did}/{thread-rkey}. A discussion reply has no own URL — it\n // lives inside its parent thread, so parse record.thread.uri and link there.\n // That URL points at a *different* record (the thread), so probe the URL.\n if (collection === 'app.atmobb.discussion.reply') {\n const thread = record.thread;\n if (thread != null && typeof thread === 'object') {\n const threadUri = stringOrNull((thread as Record<string, unknown>).uri);\n if (threadUri) {\n const parsed = parseAtUri(threadUri);\n if (parsed) {\n return urlHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB thread itself: build the per-thread URL from the item's own uri — a\n // self-permalink for this record (record-checkable). Other app.atmobb.*\n // collections (actor.profile, forum.*) are excluded upstream by sifa-api and\n // never reach the resolver.\n if (collection === 'app.atmobb.discussion.thread') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // Kut membership: a `social.kut.community.membership` record has no page of\n // its own. Link to the community it joined, parsed from record.community.uri.\n // Kut renders communities at /community/{slug}, where the slug is the\n // community-profile record's rkey (verified live). The community is a\n // *different* record, so probe the URL.\n if (collection === 'social.kut.community.membership') {\n const community = record.community;\n const communityUri =\n typeof community === 'string'\n ? community\n : // Narrow the object branch to read an optional `uri`. The membership\n // lexicon carries `community` as a bare at-uri string today; the cast\n // tolerates a future strongRef `{ uri, cid }` shape without assuming it.\n community != null && typeof community === 'object'\n ? stringOrNull((community as Record<string, unknown>).uri)\n : null;\n if (communityUri) {\n const parsed = parseAtUri(communityUri);\n if (parsed) {\n return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed.rkey)}`);\n }\n }\n return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);\n }\n\n // atstore reviews: no per-review URL exists on atstore.fyi, and user profile\n // pages don't exist either. Deep-link to the reviewed product instead.\n // The slug comes from record.listingMeta (enriched by sifa-api from the\n // review's `subject` at-uri → fyi.atstore.listing.detail record). The product\n // page is a *different* subject, so probe the URL.\n if (collection === 'fyi.atstore.listing.review') {\n const listingMeta = record.listingMeta;\n if (listingMeta != null && typeof listingMeta === 'object') {\n const slug = stringOrNull((listingMeta as Record<string, unknown>).slug);\n if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);\n }\n return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);\n }\n\n // Crate content: link to the canonical published location the maker\n // recorded (a YouTube/podcast/personal-site URL) — a foreign page. Crate is\n // an authoring dashboard with no public per-record viewer, so `note`\n // records — which carry no canonicalUrl — render non-clickable (null).\n if (collection === 'social.crate.content') {\n return urlHealth(stringOrNull(record.canonicalUrl));\n }\n if (collection === 'social.crate.note') {\n return { url: null, strategy: 'none' };\n }\n\n // Standard documents: siteUrl + path (or just siteUrl) — an external site.\n if (collection.startsWith('site.standard.')) {\n const siteUrl = stringOrNull(record.siteUrl);\n const path = stringOrNull(record.path);\n if (siteUrl && path) return urlHealth(`${siteUrl}${path}`);\n if (siteUrl) return urlHealth(siteUrl);\n // Fall through to generic record.url / patterns\n }\n\n // Kich recipe: always link to the Kich recipe page. record.url is the\n // *source* the recipe was imported from (a YouTube video, a blog), which the\n // card shows as secondary attribution — not the primary link. Must run before\n // the generic record.url fallback below, which would otherwise hijack it. The\n // recipe page is a first-party permalink for this record.\n if (collection === 'io.kich.recipe.recipe') {\n return patternHealth('kich', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // recipe.exchange: same shape as Kich — the record's attribution.url points\n // at the original source (a blog/video), not the app. Always link to the\n // recipe.exchange page. Must run before the generic fallback below.\n if (collection === 'exchange.recipe.recipe') {\n return patternHealth('recipe', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // atmob paste (atmob.in): the SPA viewer renders any paste by its full at-uri\n // via a `?uri=` query param (verified in the app's app.js). Both\n // paste.document and paste.bundle share that viewer, so build the URL from the\n // item's own uri — a self-permalink for this record (record-checkable).\n if (collection.startsWith('in.atmob.paste.')) {\n if (uri) return recordHealth(`https://atmob.in/?uri=${encodeURIComponent(uri)}`);\n return { url: null, strategy: 'none' };\n }\n\n // --- Generic fallbacks ---\n\n // record.url is a common ad-hoc per-item URL (hyperboards, etc.). It is an\n // arbitrary foreign URL, so probe it directly.\n const recordUrl = stringOrNull(record.url);\n if (recordUrl) return urlHealth(recordUrl);\n\n // Pattern-based fallback. Pass `collection` so app-specific guards (bluesky\n // restricting /post/{rkey} to app.bsky.feed.post) can apply. The tier\n // (per-item vs profile) decides record- vs url-strategy.\n return patternHealth(appId, { handle: authorHandle, did: authorDid, rkey }, collection);\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item — the same URL\n * the activity-card UI in sifa-web renders. Thin wrapper over\n * {@link resolveCardHealth}; returns `null` when no link is appropriate.\n */\nexport function resolveCardUrl(item: ActivityItemForUrl): string | null {\n return resolveCardHealth(item).url;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/cards/resolve-card-url.ts"],"sourcesContent":["import { APP_URL_PATTERNS, COLLECTION_TO_APP } from './app-url-patterns.js';\n\n/**\n * An activity item as consumed by the Sifa activity-card components and\n * the sifa-api external-URL health scanner. The shape mirrors the props\n * the cards already use, so the resolver returns the same URL the UI\n * renders.\n */\nexport interface ActivityItemForUrl {\n /** Collection NSID, e.g. \"sh.tangled.graph.repo\". */\n collection: string;\n /** The raw record from the PDS / API. */\n record: Record<string, unknown>;\n /** Full at-uri of the record (used by collections that derive URL from uri). */\n uri: string;\n /** Record key. */\n rkey: string;\n /** DID of the author of the record. */\n authorDid: string;\n /** Optional handle (cards prefer handle in URL paths when available). */\n authorHandle?: string;\n}\n\n/**\n * Map a collection NSID to its app id, mirroring the sifa-web prefix map.\n *\n * Falls back to the first two NSID segments (e.g. \"org.hyperboards\") when\n * the prefix is not registered, matching the existing card behaviour.\n */\nexport function getAppIdForCollection(collection: string): string {\n for (const [prefix, appId] of COLLECTION_TO_APP) {\n if (collection.startsWith(prefix)) return appId;\n }\n const parts = collection.split('.');\n if (parts.length >= 2) return `${parts[0]}.${parts[1]}`;\n return collection;\n}\n\n/**\n * How the activity-card health scanner should verify a card is still live.\n *\n * - `record`: the URL is a first-party permalink that renders THIS record\n * (e.g. a Bluesky post at bsky.app, a tangled repo, a smokesignal event).\n * The authoritative liveness check is \"does the record still exist on its\n * PDS\" — `com.atproto.repo.getRecord` on the item's own at-uri — NOT an\n * HTTP probe of the rendering app. Rendering apps (bsky.app, tangled.sh)\n * frequently answer HEAD with 404/405 while serving GET fine, which\n * false-positives every permalink as broken.\n * - `url`: the URL is a foreign or derived target — a bookmarked page, an\n * external publisher, a *different* record's page, or a profile page.\n * Its reachability is independent of this record, so probe the URL.\n * - `none`: the card has no clickable URL; nothing to check.\n */\nexport type CardHealthStrategy = 'record' | 'url' | 'none';\n\nexport interface CardHealth {\n /** The clickable URL the card renders. Null when non-clickable. */\n url: string | null;\n /** How the scanner should verify liveness. See {@link CardHealthStrategy}. */\n strategy: CardHealthStrategy;\n /**\n * True when `url` is only the author's profile or the app's homepage, used\n * because the record has no page of its own. Such a link does not point at\n * what the person did, so the timeline does not count it as one.\n */\n isProfileFallback?: boolean;\n}\n\n/** Build a `url`-strategy result (foreign/derived/profile target). */\nfunction urlHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url' } : { url: null, strategy: 'none' };\n}\n\n/** Build a profile / app-homepage fallback result (not a link to the record itself). */\nfunction profileHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url', isProfileFallback: true } : { url: null, strategy: 'none' };\n}\n\n/** Build a `record`-strategy result (self-permalink of this record). */\nfunction recordHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'record' } : { url: null, strategy: 'none' };\n}\n\nfunction interpolate(pattern: string, vars: Record<string, string | undefined>): string | null {\n let result = pattern;\n for (const [key, value] of Object.entries(vars)) {\n if (result.includes(`{${key}}`)) {\n if (!value) return null;\n result = result.replaceAll(`{${key}}`, encodeURIComponent(value));\n }\n }\n return result;\n}\n\n/**\n * Whether the per-item `urlPattern` for `appId` is appropriate for `collection`.\n *\n * For bluesky, the per-item URL pattern only matches `app.bsky.feed.post` —\n * other `app.bsky.*` collections (`actor.status`, `graph.cancellation`, etc.)\n * use rkeys that would 404 at `bsky.app/profile/{handle}/post/{rkey}`. Fall\n * back to the profile URL for those. See sifa-web#1070 / sifa-web#1073.\n *\n * When `collection` is undefined we err on the safe side for bluesky and\n * prefer the profile URL.\n */\nfunction shouldUseItemPattern(appId: string, collection: string | undefined): boolean {\n if (appId === 'bluesky') {\n return collection === 'app.bsky.feed.post';\n }\n return true;\n}\n\n/**\n * Resolve a pattern-based URL, reporting which tier produced it:\n * - `item`: a per-item permalink (renders this specific record) -> record-checkable.\n * - `profile`: the app's profile page (not this record) -> url-checkable.\n * - `null`: no pattern applied.\n */\nfunction patternHealth(\n appId: string,\n vars: { handle?: string; did?: string; rkey?: string },\n collection?: string,\n): CardHealth {\n const patterns = APP_URL_PATTERNS[appId];\n if (!patterns) return { url: null, strategy: 'none' };\n\n if (patterns.urlPattern && shouldUseItemPattern(appId, collection)) {\n const url = interpolate(patterns.urlPattern, vars);\n if (url) return recordHealth(url);\n }\n if (patterns.profileUrlPattern) {\n const url = interpolate(patterns.profileUrlPattern, vars);\n if (url) return profileHealth(url);\n }\n return { url: null, strategy: 'none' };\n}\n\n/**\n * Valid tangled repo slug: alphanumeric, dot, dash, underscore. No whitespace,\n * slashes, or URL-encodable special characters. Multi-segment aggregate `name`\n * values produce 404 URLs, so callers fall back to the profile URL when\n * invalid. See sifa-web#1071 / sifa-web#1072.\n */\nconst TANGLED_REPO_SLUG = /^[a-zA-Z0-9._-]+$/;\n\nfunction stringOrNull(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n// at://did:plc:xxx/community.lexicon.calendar.event/rkey\n// -> { did: \"did:plc:xxx\", rkey: \"rkey\" }\nfunction parseAtUri(atUri: string): { did: string; rkey: string } | null {\n const match = atUri.match(/^at:\\/\\/(did:[^/]+)\\/[^/]+\\/(.+)$/);\n if (!match || !match[1] || !match[2]) return null;\n return { did: match[1], rkey: match[2] };\n}\n\nconst TANGLED = 'https://tangled.sh';\n\n/** A Tangled repo reference (a repo DID, or an `at://` repo record uri) as its tangled.sh base URL. */\nfunction tangledRepoBase(ref: unknown): string | null {\n const value = stringOrNull(ref);\n if (!value) return null;\n if (value.startsWith('did:')) return `${TANGLED}/${value}`;\n const parsed = parseAtUri(value);\n if (parsed && TANGLED_REPO_SLUG.test(parsed.rkey))\n return `${TANGLED}/${parsed.did}/${parsed.rkey}`;\n return null;\n}\n\n/**\n * The per-item tangled.sh URL for a Tangled record, or null when none can be\n * built. `ownRecord` is true when the URL is the record's own page (a repo).\n */\nfunction resolveTangledUrl(\n collection: string,\n record: Record<string, unknown>,\n rkey: string,\n owner: string,\n): { url: string; ownRecord: boolean } | null {\n if (collection === 'sh.tangled.repo.pull' || collection === 'sh.tangled.repo.issue') {\n const target =\n record.target && typeof record.target === 'object'\n ? (record.target as Record<string, unknown>)\n : {};\n const base =\n tangledRepoBase(target.repoDid) ??\n tangledRepoBase(target.repo) ??\n tangledRepoBase(record.repoDid) ??\n tangledRepoBase(record.repo);\n if (!base) return null;\n const list = collection === 'sh.tangled.repo.pull' ? 'pulls' : 'issues';\n return { url: `${base}/${list}`, ownRecord: false };\n }\n if (collection === 'sh.tangled.feed.star') {\n const starred = tangledRepoBase(record.subjectDid) ?? tangledRepoBase(record.subject);\n return starred ? { url: starred, ownRecord: false } : null;\n }\n const repoDid = tangledRepoBase(record.repoDid);\n if (repoDid) return { url: repoDid, ownRecord: true };\n const repoName = stringOrNull(record.name) ?? (collection === 'sh.tangled.repo' ? rkey : null);\n if (repoName && owner && TANGLED_REPO_SLUG.test(repoName)) {\n return { url: `${TANGLED}/${owner}/${repoName}`, ownRecord: true };\n }\n return null;\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item **and** how its\n * liveness should be checked — the single source of truth shared by:\n * - sifa-web activity cards (which render `.url`), and\n * - the sifa-api link-health scanner (which switches on `.strategy`: probe the\n * record's PDS for `record`-strategy cards, probe the URL for `url`).\n *\n * See {@link CardHealthStrategy} for why self-permalinks (bsky posts, etc.)\n * must be verified by record existence rather than an HTTP probe of the app.\n *\n * Resolution order:\n * 1. Per-collection bespoke logic (tangled, userinput, bookhive, kipclip, margin, smokesignal\n * rsvp, standard documents). These mirror the inline logic in the\n * individual card components.\n * 2. Generic `record.url` field (used by hyperboards and similar).\n * 3. Pattern-based per-item / profile URL from the registry.\n */\nexport function resolveCardHealth(item: ActivityItemForUrl): CardHealth {\n const { collection, record, uri, rkey, authorDid, authorHandle } = item;\n const appId = getAppIdForCollection(collection);\n\n // --- Per-collection bespoke logic (mirrors the card components) ---\n\n // Tangled. tangled.sh resolves both DIDs and handles in the path, so a link\n // never depends on the author handle being known:\n // - a repo: by its repo DID when the record carries one, else owner + slug\n // (`record.name`, or the rkey for `sh.tangled.repo`, whose newer records\n // use the slug as the rkey). Only valid slugs: multi-segment aggregate\n // names would 404 (sifa-web#1071).\n // - a pull / issue: the target repo's pulls / issues list. The record carries\n // no PR or issue number, so the single item is not addressable.\n // - a star: the starred repo.\n // Anything else falls back to the owner's profile.\n if (collection.startsWith('sh.tangled.')) {\n const tangled = resolveTangledUrl(collection, record, rkey, authorHandle ?? authorDid);\n // A repo link is this record's own page; a pull / issue / star links to a\n // different record (the target repo), so probe that page itself.\n if (tangled) return tangled.ownRecord ? recordHealth(tangled.url) : urlHealth(tangled.url);\n if (!authorHandle && authorDid) return profileHealth(`${TANGLED}/${authorDid}`);\n return patternHealth('tangled', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // User Input: a discussion has its own page; a reply links to the discussion\n // it belongs to (its `subject`).\n if (collection === 'app.userinput.discussion' && authorDid) {\n return recordHealth(`https://userinput.app/d/${authorDid}/${rkey}`);\n }\n if (collection === 'app.userinput.reply') {\n const subject = record.subject;\n const subjectUri =\n subject != null && typeof subject === 'object'\n ? stringOrNull((subject as Record<string, unknown>).uri)\n : null;\n const parsed = subjectUri?.includes('/app.userinput.discussion/')\n ? parseAtUri(subjectUri)\n : null;\n if (parsed) return urlHealth(`https://userinput.app/d/${parsed.did}/${parsed.rkey}`);\n return { url: null, strategy: 'none' };\n }\n\n // BookHive: a book log links to the book's page by the record's `hiveId`.\n // The book page is a different record (shared by every reader), so its\n // reachability is probed as a URL.\n if (collection === 'buzz.bookhive.book') {\n const hiveId = stringOrNull(record.hiveId);\n if (hiveId && /^bk_[A-Za-z0-9_-]+$/.test(hiveId)) {\n return urlHealth(`https://bookhive.buzz/books/${hiveId}`);\n }\n return patternHealth('bookhive', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // Kipclip / community.lexicon.bookmarks: the bookmark subject IS the URL —\n // a foreign page whose reachability is independent of the bookmark record.\n if (\n collection.startsWith('com.kipclip.') ||\n collection.startsWith('community.lexicon.bookmarks.')\n ) {\n const subject = stringOrNull(record.subject);\n if (subject) return urlHealth(subject);\n return patternHealth('kipclip', { handle: authorHandle, did: authorDid, rkey });\n }\n\n // Margin bookmark: card returns null entirely without record.source\n if (collection === 'at.margin.bookmark') {\n return urlHealth(stringOrNull(record.source));\n }\n\n // Margin note / annotation: both are W3C web annotations whose `target.source`\n // is the annotated page URL. Link to that source; fall back to the margin app.\n if (collection === 'at.margin.annotation' || collection === 'at.margin.note') {\n const target = record.target;\n if (target != null && typeof target === 'object') {\n const source = stringOrNull((target as Record<string, unknown>).source);\n if (source) return urlHealth(source);\n }\n return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);\n }\n\n // Community-calendar RSVP: parse the linked event uri. The URL points at a\n // *different* record (the event), so probe the URL, not this RSVP record.\n // Rendered via atmo.rsvp, which resolves any community.lexicon.calendar event\n // by did+rkey (Smoke Signal is decommissioned; its permalinks are retired).\n if (collection === 'community.lexicon.calendar.rsvp') {\n const subject = record.subject;\n if (subject != null && typeof subject === 'object') {\n const subjectUri = stringOrNull((subject as Record<string, unknown>).uri);\n if (subjectUri) {\n const parsed = parseAtUri(subjectUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // Community-calendar event itself: use item's own uri -> self-permalink on\n // atmo.rsvp (same rationale as the RSVP block above).\n if (collection === 'community.lexicon.calendar.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp event itself: use item's own uri -> self-permalink.\n if (collection === 'quest.atmo.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp checkin: parse the linked event uri (record.event) and link to\n // that event's page (mirrors smokesignal rsvp). The URL points at a\n // *different* record (the event), so probe the URL. The checkin's own rkey is\n // not addressable, so without a valid event reference it's non-clickable.\n if (collection === 'quest.atmo.checkin') {\n const eventUri = stringOrNull(record.event);\n if (eventUri) {\n const parsed = parseAtUri(eventUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB forum (atmobb.app): the universal viewer renders any thread at\n // /t/{thread-owner-did}/{thread-rkey}. A discussion reply has no own URL — it\n // lives inside its parent thread, so parse record.thread.uri and link there.\n // That URL points at a *different* record (the thread), so probe the URL.\n if (collection === 'app.atmobb.discussion.reply') {\n const thread = record.thread;\n if (thread != null && typeof thread === 'object') {\n const threadUri = stringOrNull((thread as Record<string, unknown>).uri);\n if (threadUri) {\n const parsed = parseAtUri(threadUri);\n if (parsed) {\n return urlHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB thread itself: build the per-thread URL from the item's own uri — a\n // self-permalink for this record (record-checkable). Other app.atmobb.*\n // collections (actor.profile, forum.*) are excluded upstream by sifa-api and\n // never reach the resolver.\n if (collection === 'app.atmobb.discussion.thread') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // Kut membership: a `social.kut.community.membership` record has no page of\n // its own. Link to the community it joined, parsed from record.community.uri.\n // Kut renders communities at /community/{slug}, where the slug is the\n // community-profile record's rkey (verified live). The community is a\n // *different* record, so probe the URL.\n if (collection === 'social.kut.community.membership') {\n const community = record.community;\n const communityUri =\n typeof community === 'string'\n ? community\n : // Narrow the object branch to read an optional `uri`. The membership\n // lexicon carries `community` as a bare at-uri string today; the cast\n // tolerates a future strongRef `{ uri, cid }` shape without assuming it.\n community != null && typeof community === 'object'\n ? stringOrNull((community as Record<string, unknown>).uri)\n : null;\n if (communityUri) {\n const parsed = parseAtUri(communityUri);\n if (parsed) {\n return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed.rkey)}`);\n }\n }\n return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);\n }\n\n // atstore reviews: no per-review URL exists on atstore.fyi, and user profile\n // pages don't exist either. Deep-link to the reviewed product instead.\n // The slug comes from record.listingMeta (enriched by sifa-api from the\n // review's `subject` at-uri → fyi.atstore.listing.detail record). The product\n // page is a *different* subject, so probe the URL.\n if (collection === 'fyi.atstore.listing.review') {\n const listingMeta = record.listingMeta;\n if (listingMeta != null && typeof listingMeta === 'object') {\n const slug = stringOrNull((listingMeta as Record<string, unknown>).slug);\n if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);\n }\n return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);\n }\n\n // Crate content: link to the canonical published location the maker\n // recorded (a YouTube/podcast/personal-site URL) — a foreign page. Crate is\n // an authoring dashboard with no public per-record viewer, so `note`\n // records — which carry no canonicalUrl — render non-clickable (null).\n if (collection === 'social.crate.content') {\n return urlHealth(stringOrNull(record.canonicalUrl));\n }\n if (collection === 'social.crate.note') {\n return { url: null, strategy: 'none' };\n }\n\n // Standard documents: siteUrl + path (or just siteUrl) — an external site.\n if (collection.startsWith('site.standard.')) {\n const siteUrl = stringOrNull(record.siteUrl);\n const path = stringOrNull(record.path);\n if (siteUrl && path) return urlHealth(`${siteUrl}${path}`);\n if (siteUrl) return urlHealth(siteUrl);\n // Fall through to generic record.url / patterns\n }\n\n // Kich recipe: always link to the Kich recipe page. record.url is the\n // *source* the recipe was imported from (a YouTube video, a blog), which the\n // card shows as secondary attribution — not the primary link. Must run before\n // the generic record.url fallback below, which would otherwise hijack it. The\n // recipe page is a first-party permalink for this record.\n if (collection === 'io.kich.recipe.recipe') {\n return patternHealth('kich', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // recipe.exchange: same shape as Kich — the record's attribution.url points\n // at the original source (a blog/video), not the app. Always link to the\n // recipe.exchange page. Must run before the generic fallback below.\n if (collection === 'exchange.recipe.recipe') {\n return patternHealth('recipe', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // PassingReads note: the note has no page of its own. It links to the book\n // page of the registration it references (book.uri rkey is the bookId).\n // Without a usable reference, fall through to the profile pattern.\n if (collection === 'org.passingreads.book.note') {\n const book = record.book;\n if (book != null && typeof book === 'object') {\n const bookUri = stringOrNull((book as Record<string, unknown>).uri);\n const parsed = bookUri ? parseAtUri(bookUri) : null;\n if (parsed) {\n return urlHealth(`https://www.passingreads.org/books/${encodeURIComponent(parsed.rkey)}`);\n }\n }\n return patternHealth(\n 'passingreads',\n { handle: authorHandle, did: authorDid, rkey },\n collection,\n );\n }\n\n // atmob paste (atmob.in): the SPA viewer renders any paste by its full at-uri\n // via a `?uri=` query param (verified in the app's app.js). Both\n // paste.document and paste.bundle share that viewer, so build the URL from the\n // item's own uri — a self-permalink for this record (record-checkable).\n if (collection.startsWith('in.atmob.paste.')) {\n if (uri) return recordHealth(`https://atmob.in/?uri=${encodeURIComponent(uri)}`);\n return { url: null, strategy: 'none' };\n }\n\n // --- Generic fallbacks ---\n\n // record.url is a common ad-hoc per-item URL (hyperboards, etc.). It is an\n // arbitrary foreign URL, so probe it directly.\n const recordUrl = stringOrNull(record.url);\n if (recordUrl) return urlHealth(recordUrl);\n\n // Pattern-based fallback. Pass `collection` so app-specific guards (bluesky\n // restricting /post/{rkey} to app.bsky.feed.post) can apply. The tier\n // (per-item vs profile) decides record- vs url-strategy.\n return patternHealth(appId, { handle: authorHandle, did: authorDid, rkey }, collection);\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item — the same URL\n * the activity-card UI in sifa-web renders. Thin wrapper over\n * {@link resolveCardHealth}; returns `null` when no link is appropriate.\n */\nexport function resolveCardUrl(item: ActivityItemForUrl): string | null {\n return resolveCardHealth(item).url;\n}\n"],"mappings":"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","names":[]}
package/dist/index.cjs CHANGED
@@ -1203,6 +1203,7 @@ var APP_CATEGORIES = {
1203
1203
  Domains: { phosphorIcon: "Globe" },
1204
1204
  Endorsements: { phosphorIcon: "HandHeart" },
1205
1205
  Events: { phosphorIcon: "CalendarBlank" },
1206
+ Flashcards: { phosphorIcon: "Cards" },
1206
1207
  Impact: { phosphorIcon: "Certificate" },
1207
1208
  Links: { phosphorIcon: "LinkSimple" },
1208
1209
  Lists: { phosphorIcon: "ListBullets" },
@@ -1395,8 +1396,19 @@ var APP_CATEGORY_MAP = {
1395
1396
  // supply.skillz.skill — authored agent skills (SKILL.md) on skillz.supply
1396
1397
  birdsplace: "Places",
1397
1398
  // place.birds.sighting — logged bird sightings with photo, notes, and location
1398
- skymap: "Places"
1399
+ skymap: "Places",
1399
1400
  // blue.skymap.geo.map — authored maps of places (features inline)
1401
+ // 2026-10-20 profile-of-the-day queue onboards.
1402
+ opendeck: "Flashcards",
1403
+ // space.opendeck.deck — authored language-flashcard decks
1404
+ chiverecipes: "Recipes",
1405
+ // com.chive.recipe — authored recipes on chive.pages.dev
1406
+ atbb: "Posts",
1407
+ // space.atbb.post — forum posts and replies on atBB forums
1408
+ passingreads: "Reviews",
1409
+ // org.passingreads.book.note — notes on shared physical books
1410
+ isnot: "Reviews"
1411
+ // at.isnot.review — adjective-tag micro-reviews (is / is not)
1400
1412
  };
1401
1413
  function isKnownAppId(appId) {
1402
1414
  return Object.prototype.hasOwnProperty.call(APP_CATEGORY_MAP, appId);
@@ -2336,6 +2348,31 @@ var activity_tiers_default = {
2336
2348
  tier: "creation",
2337
2349
  app: "skymap",
2338
2350
  notes: "An authored map with inline point features (title, description, tags)."
2351
+ },
2352
+ "space.opendeck.deck": {
2353
+ tier: "creation",
2354
+ app: "opendeck",
2355
+ notes: "An authored language-flashcard deck (title, summary, source and target language)."
2356
+ },
2357
+ "com.chive.recipe": {
2358
+ tier: "creation",
2359
+ app: "chiverecipes",
2360
+ notes: "An authored recipe with ingredients, steps, and an image."
2361
+ },
2362
+ "space.atbb.post": {
2363
+ tier: "creation",
2364
+ app: "atbb",
2365
+ notes: "A forum post or reply on an atBB forum."
2366
+ },
2367
+ "org.passingreads.book.note": {
2368
+ tier: "creation",
2369
+ app: "passingreads",
2370
+ notes: "A free-text note on a registered physical book that is passed on."
2371
+ },
2372
+ "at.isnot.review": {
2373
+ tier: "creation",
2374
+ app: "isnot",
2375
+ notes: "A micro-review of a subject (e.g. a game) as weighted is / is-not adjectives."
2339
2376
  }
2340
2377
  }
2341
2378
  };
@@ -4405,6 +4442,36 @@ var APP_URL_PATTERNS = Object.freeze({
4405
4442
  // the shell returns 200 for any path, so the rkey itself is not checkable.
4406
4443
  urlPattern: "https://skymap.blue/users/{did}/maps/{rkey}",
4407
4444
  profileUrlPattern: "https://skymap.blue/users/{did}"
4445
+ },
4446
+ opendeck: {
4447
+ // Nuxt SPA routes: /decks/:handle/:rkey and /profile/:handle. The shell
4448
+ // returns 200 for any path, so the rkey itself is not checkable.
4449
+ urlPattern: "https://opendeck.space/decks/{handle}/{rkey}",
4450
+ profileUrlPattern: "https://opendeck.space/profile/{handle}"
4451
+ },
4452
+ chiverecipes: {
4453
+ // Chive Recipes (not chive.pub, the preprint app above). Vite SPA routes: /recipe/:author/:rkey and /profile/:identifier. The shell
4454
+ // returns 200 for any path. record.source is the imported original recipe,
4455
+ // not the Chive page, so it never becomes the link.
4456
+ urlPattern: "https://chive.pages.dev/recipe/{handle}/{rkey}",
4457
+ profileUrlPattern: "https://chive.pages.dev/profile/{handle}"
4458
+ },
4459
+ atbb: {
4460
+ // atBB forum pages are addressed by numeric appview ids (/topics/:id),
4461
+ // which a record cannot supply, and there is no per-user page. Posts
4462
+ // render non-clickable.
4463
+ },
4464
+ passingreads: {
4465
+ // Verified live: /books/{bookId} server-renders the book page (bookId is
4466
+ // the rkey of the org.passingreads.book.registration a note references;
4467
+ // built in resolveCardUrl). /profiles/{handle} is the profile page.
4468
+ profileUrlPattern: "https://www.passingreads.org/profiles/{handle}"
4469
+ },
4470
+ isnot: {
4471
+ // Verified live: GET /reviews/{did}/{rkey} server-renders the review; an
4472
+ // unknown rkey 404s. /reviews/{did} lists the person's reviews.
4473
+ urlPattern: "https://isnot.at/reviews/{did}/{rkey}",
4474
+ profileUrlPattern: "https://isnot.at/reviews/{did}"
4408
4475
  }
4409
4476
  });
4410
4477
  var COLLECTION_TO_APP = [
@@ -4506,7 +4573,13 @@ var COLLECTION_TO_APP = [
4506
4573
  // Onboard from the 2026-10-06 profile-of-the-day queue audit.
4507
4574
  ["supply.skillz.", "skillz"],
4508
4575
  ["place.birds.", "birdsplace"],
4509
- ["blue.skymap.", "skymap"]
4576
+ ["blue.skymap.", "skymap"],
4577
+ // Onboard from the 2026-10-20 profile-of-the-day queue audit.
4578
+ ["space.opendeck.", "opendeck"],
4579
+ ["com.chive.", "chiverecipes"],
4580
+ ["space.atbb.", "atbb"],
4581
+ ["org.passingreads.", "passingreads"],
4582
+ ["at.isnot.", "isnot"]
4510
4583
  ];
4511
4584
 
4512
4585
  // src/cards/resolve-card-url.ts
@@ -4738,6 +4811,21 @@ function resolveCardHealth(item) {
4738
4811
  if (collection === "exchange.recipe.recipe") {
4739
4812
  return patternHealth("recipe", { handle: authorHandle, did: authorDid, rkey }, collection);
4740
4813
  }
4814
+ if (collection === "org.passingreads.book.note") {
4815
+ const book = record.book;
4816
+ if (book != null && typeof book === "object") {
4817
+ const bookUri = stringOrNull(book.uri);
4818
+ const parsed3 = bookUri ? parseAtUri(bookUri) : null;
4819
+ if (parsed3) {
4820
+ return urlHealth(`https://www.passingreads.org/books/${encodeURIComponent(parsed3.rkey)}`);
4821
+ }
4822
+ }
4823
+ return patternHealth(
4824
+ "passingreads",
4825
+ { handle: authorHandle, did: authorDid, rkey },
4826
+ collection
4827
+ );
4828
+ }
4741
4829
  if (collection.startsWith("in.atmob.paste.")) {
4742
4830
  if (uri) return recordHealth(`https://atmob.in/?uri=${encodeURIComponent(uri)}`);
4743
4831
  return { url: null, strategy: "none" };
@@ -4896,8 +4984,8 @@ function getPublisherFromSiteUrl(siteUrl) {
4896
4984
 
4897
4985
  // src/stream/verbs.json
4898
4986
  var verbs_default = {
4899
- version: "1.7.0",
4900
- updated: "2026-09-26",
4987
+ version: "1.8.0",
4988
+ updated: "2026-10-20",
4901
4989
  defaultVerb: "created",
4902
4990
  verbs: {
4903
4991
  "actor.rpg.sprite": "styledCharacter",
@@ -4909,6 +4997,7 @@ var verbs_default = {
4909
4997
  "app.sidetrail.trail": "shipped",
4910
4998
  "app.sidetrail.walk": "shipped",
4911
4999
  "app.userinput.reply": "replied",
5000
+ "at.isnot.review": "reviewed",
4912
5001
  "at.margin.annotation": "annotated",
4913
5002
  "at.margin.bookmark": "bookmarked",
4914
5003
  "at.margin.note": "annotated",
@@ -4949,7 +5038,8 @@ var verbs_default = {
4949
5038
  "social.popfeed.feed.note": "posted",
4950
5039
  "social.popfeed.feed.post": "posted",
4951
5040
  "social.popfeed.feed.review": "reviewed",
4952
- "social.psky.feed.post": "posted"
5041
+ "social.psky.feed.post": "posted",
5042
+ "space.atbb.post": "posted"
4953
5043
  }
4954
5044
  };
4955
5045
 
@@ -5843,6 +5933,30 @@ function readBodyField(value) {
5843
5933
  function httpUrl(value) {
5844
5934
  return typeof value === "string" && /^https?:\/\//i.test(value) ? value : void 0;
5845
5935
  }
5936
+ var ISNOT_DIRECTIONS = {
5937
+ "2": "is really",
5938
+ "1": "is",
5939
+ "0": "may be",
5940
+ "-1": "is not",
5941
+ "-2": "really isn't"
5942
+ };
5943
+ function applyIsnotReview(vm, record) {
5944
+ const subject = record.subject;
5945
+ const title = subject != null && typeof subject === "object" ? asNonEmptyString(subject.title) : void 0;
5946
+ if (!title) return withGeneric(vm);
5947
+ const phrases = (Array.isArray(record.tags) ? record.tags : []).flatMap((tag) => {
5948
+ if (tag == null || typeof tag !== "object") return [];
5949
+ const t = tag;
5950
+ const adjective = asNonEmptyString(t.adjective);
5951
+ const direction = asFiniteNumber(t.direction);
5952
+ if (!adjective || direction === void 0) return [];
5953
+ const wording = ISNOT_DIRECTIONS[String(direction)];
5954
+ return wording ? [{ direction, phrase: `${wording} ${adjective}` }] : [];
5955
+ }).sort((a, b) => b.direction - a.direction).map((p) => p.phrase);
5956
+ const joined = phrases.length > 1 ? `${phrases.slice(0, -1).join(", ")} and ${phrases[phrases.length - 1]}` : phrases[0] ?? "";
5957
+ vm.body = { kind: "text", text: joined ? `${title} ${joined}` : title };
5958
+ return vm;
5959
+ }
5846
5960
  var GENERIC_TEXT_ORDER = [
5847
5961
  "text",
5848
5962
  "title",
@@ -6104,6 +6218,8 @@ function toStreamCardVM(item, options = {}) {
6104
6218
  return record ? applyFediversePost(vm, record) : withGeneric(vm);
6105
6219
  case "place.birds.sighting":
6106
6220
  return record ? applyBirdSighting(vm, item, record) : withGeneric(vm);
6221
+ case "at.isnot.review":
6222
+ return record ? applyIsnotReview(vm, record) : withGeneric(vm);
6107
6223
  }
6108
6224
  if (record && item.collection.startsWith("social.popfeed.feed.")) {
6109
6225
  return applyMediaReview(vm, item, record);
@@ -7928,7 +8044,7 @@ function describeSifaRecord(collection, value) {
7928
8044
  }
7929
8045
 
7930
8046
  // src/index.ts
7931
- var SIFA_SDK_VERSION = "0.19.110";
8047
+ var SIFA_SDK_VERSION = "0.19.111";
7932
8048
 
7933
8049
  exports.ACTIVITY_TIERS = ACTIVITY_TIERS;
7934
8050
  exports.ACTIVITY_VERBS = ACTIVITY_VERBS;