@singi-labs/sifa-sdk 0.19.104 → 0.19.106

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.
@@ -62,7 +62,9 @@ var APP_URL_PATTERNS = Object.freeze({
62
62
  beaconbits: { profileUrlPattern: "https://beaconbits.app" },
63
63
  bookhive: { profileUrlPattern: "https://bookhive.buzz/profile/{handle}" },
64
64
  colibri: { profileUrlPattern: "https://colibri.social" },
65
- collectivesocial: { profileUrlPattern: "https://app.collectivesocial.app" },
65
+ collectivesocial: { profileUrlPattern: "https://app.collectivesocial.app", itemLinks: false },
66
+ // No public per-image page and no profile URL.
67
+ pixl: { itemLinks: false },
66
68
  github: {},
67
69
  asq: {
68
70
  // Questions live at /q/{did}/{rkey}. Answer cards build their own URL
@@ -74,7 +76,8 @@ var APP_URL_PATTERNS = Object.freeze({
74
76
  passports: {
75
77
  // Per-record URLs don't exist on passports.social — its SvelteKit routes
76
78
  // stop at /profile/{handle}/{passport_slug}. Profile-level fallback only.
77
- profileUrlPattern: "https://passports.social/profile/{handle}"
79
+ profileUrlPattern: "https://passports.social/profile/{handle}",
80
+ itemLinks: false
78
81
  },
79
82
  leaflet: {
80
83
  // Per-document URLs are short and rkey-only: https://leaflet.pub/{rkey}.
@@ -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\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' },\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 },\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});\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":[]}
@@ -5,6 +5,7 @@ import {
5
5
  resolveCardHealth,
6
6
  resolveCardUrl
7
7
  } from "./resolve-card-url.js";
8
+ import { appLinksItems } from "./item-links.js";
8
9
  import { ACTIVITY_VISIBILITY_RULES, isVisibleActivityItem } from "./visibility.js";
9
10
  import {
10
11
  ADULT_CONTENT_LABELS,
@@ -15,6 +16,7 @@ export {
15
16
  ADULT_CONTENT_LABELS,
16
17
  APP_URL_PATTERNS,
17
18
  COLLECTION_TO_APP,
19
+ appLinksItems,
18
20
  getAppIdForCollection,
19
21
  hasAdultContent,
20
22
  isVisibleActivityItem,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cards/index.ts"],"sourcesContent":["export { APP_URL_PATTERNS, COLLECTION_TO_APP, type AppUrlPatterns } from './app-url-patterns.js';\nexport {\n getAppIdForCollection,\n resolveCardHealth,\n resolveCardUrl,\n type ActivityItemForUrl,\n type CardHealth,\n type CardHealthStrategy,\n} from './resolve-card-url.js';\nexport { ACTIVITY_VISIBILITY_RULES, isVisibleActivityItem } from './visibility.js';\nexport {\n ADULT_CONTENT_LABELS,\n hasAdultContent,\n type ActivityLabel,\n type AdultContentLabel,\n} from './adult-content.js';\n"],"mappings":";AAAA,SAAS,kBAAkB,yBAA8C;AACzE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,2BAA2B,6BAA6B;AACjE;AAAA,EACE;AAAA,EACA;AAAA,OAGK;","names":[]}
1
+ {"version":3,"sources":["../../src/cards/index.ts"],"sourcesContent":["export { APP_URL_PATTERNS, COLLECTION_TO_APP, type AppUrlPatterns } from './app-url-patterns.js';\nexport {\n getAppIdForCollection,\n resolveCardHealth,\n resolveCardUrl,\n type ActivityItemForUrl,\n type CardHealth,\n type CardHealthStrategy,\n} from './resolve-card-url.js';\nexport { appLinksItems } from './item-links.js';\nexport { ACTIVITY_VISIBILITY_RULES, isVisibleActivityItem } from './visibility.js';\nexport {\n ADULT_CONTENT_LABELS,\n hasAdultContent,\n type ActivityLabel,\n type AdultContentLabel,\n} from './adult-content.js';\n"],"mappings":";AAAA,SAAS,kBAAkB,yBAA8C;AACzE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AACP,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B,6BAA6B;AACjE;AAAA,EACE;AAAA,EACA;AAAA,OAGK;","names":[]}
@@ -0,0 +1,9 @@
1
+ // src/cards/item-links.ts
2
+ import { APP_URL_PATTERNS } from "./app-url-patterns.js";
3
+ function appLinksItems(appId) {
4
+ return APP_URL_PATTERNS[appId]?.itemLinks !== false;
5
+ }
6
+ export {
7
+ appLinksItems
8
+ };
9
+ //# sourceMappingURL=item-links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/cards/item-links.ts"],"sourcesContent":["import { APP_URL_PATTERNS } from './app-url-patterns.js';\n\n/**\n * Whether an app can link to a single item at all. False only for apps marked\n * `itemLinks: false` in {@link APP_URL_PATTERNS} (Passports, Collective Social,\n * Pixl): their items can only link a profile, a homepage, or nothing, so none\n * reaches the timeline and a source pill for them would open an empty feed.\n * Every other app counts as able to link, since items can also link through a\n * resolver branch or a URL on the record itself.\n */\nexport function appLinksItems(appId: string): boolean {\n return APP_URL_PATTERNS[appId]?.itemLinks !== false;\n}\n"],"mappings":";AAAA,SAAS,wBAAwB;AAU1B,SAAS,cAAc,OAAwB;AACpD,SAAO,iBAAiB,KAAK,GAAG,cAAc;AAChD;","names":[]}
@@ -11,6 +11,9 @@ function getAppIdForCollection(collection) {
11
11
  function urlHealth(url) {
12
12
  return url ? { url, strategy: "url" } : { url: null, strategy: "none" };
13
13
  }
14
+ function profileHealth(url) {
15
+ return url ? { url, strategy: "url", isProfileFallback: true } : { url: null, strategy: "none" };
16
+ }
14
17
  function recordHealth(url) {
15
18
  return url ? { url, strategy: "record" } : { url: null, strategy: "none" };
16
19
  }
@@ -39,7 +42,7 @@ function patternHealth(appId, vars, collection) {
39
42
  }
40
43
  if (patterns.profileUrlPattern) {
41
44
  const url = interpolate(patterns.profileUrlPattern, vars);
42
- if (url) return urlHealth(url);
45
+ if (url) return profileHealth(url);
43
46
  }
44
47
  return { url: null, strategy: "none" };
45
48
  }
@@ -90,7 +93,7 @@ function resolveCardHealth(item) {
90
93
  if (collection.startsWith("sh.tangled.")) {
91
94
  const tangled = resolveTangledUrl(collection, record, rkey, authorHandle ?? authorDid);
92
95
  if (tangled) return tangled.ownRecord ? recordHealth(tangled.url) : urlHealth(tangled.url);
93
- if (!authorHandle && authorDid) return urlHealth(`${TANGLED}/${authorDid}`);
96
+ if (!authorHandle && authorDid) return profileHealth(`${TANGLED}/${authorDid}`);
94
97
  return patternHealth("tangled", { handle: authorHandle, did: authorDid, rkey }, collection);
95
98
  }
96
99
  if (collection === "app.userinput.discussion" && authorDid) {
@@ -103,6 +106,13 @@ function resolveCardHealth(item) {
103
106
  if (parsed) return urlHealth(`https://userinput.app/d/${parsed.did}/${parsed.rkey}`);
104
107
  return { url: null, strategy: "none" };
105
108
  }
109
+ if (collection === "buzz.bookhive.book") {
110
+ const hiveId = stringOrNull(record.hiveId);
111
+ if (hiveId && /^bk_[A-Za-z0-9_-]+$/.test(hiveId)) {
112
+ return urlHealth(`https://bookhive.buzz/books/${hiveId}`);
113
+ }
114
+ return patternHealth("bookhive", { handle: authorHandle, did: authorDid, rkey }, collection);
115
+ }
106
116
  if (collection.startsWith("com.kipclip.") || collection.startsWith("community.lexicon.bookmarks.")) {
107
117
  const subject = stringOrNull(record.subject);
108
118
  if (subject) return urlHealth(subject);
@@ -117,7 +127,7 @@ function resolveCardHealth(item) {
117
127
  const source = stringOrNull(target.source);
118
128
  if (source) return urlHealth(source);
119
129
  }
120
- return urlHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);
130
+ return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);
121
131
  }
122
132
  if (collection === "community.lexicon.calendar.rsvp") {
123
133
  const subject = record.subject;
@@ -190,7 +200,7 @@ function resolveCardHealth(item) {
190
200
  return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed.rkey)}`);
191
201
  }
192
202
  }
193
- return urlHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);
203
+ return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);
194
204
  }
195
205
  if (collection === "fyi.atstore.listing.review") {
196
206
  const listingMeta = record.listingMeta;
@@ -198,7 +208,7 @@ function resolveCardHealth(item) {
198
208
  const slug = stringOrNull(listingMeta.slug);
199
209
  if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);
200
210
  }
201
- return urlHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);
211
+ return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);
202
212
  }
203
213
  if (collection === "social.crate.content") {
204
214
  return urlHealth(stringOrNull(record.canonicalUrl));
@@ -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\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 `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 urlHealth(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, 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 urlHealth(`${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 // 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 urlHealth(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 urlHealth(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 urlHealth(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 // 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
@@ -4102,7 +4102,9 @@ var APP_URL_PATTERNS = Object.freeze({
4102
4102
  beaconbits: { profileUrlPattern: "https://beaconbits.app" },
4103
4103
  bookhive: { profileUrlPattern: "https://bookhive.buzz/profile/{handle}" },
4104
4104
  colibri: { profileUrlPattern: "https://colibri.social" },
4105
- collectivesocial: { profileUrlPattern: "https://app.collectivesocial.app" },
4105
+ collectivesocial: { profileUrlPattern: "https://app.collectivesocial.app", itemLinks: false },
4106
+ // No public per-image page and no profile URL.
4107
+ pixl: { itemLinks: false },
4106
4108
  github: {},
4107
4109
  asq: {
4108
4110
  // Questions live at /q/{did}/{rkey}. Answer cards build their own URL
@@ -4114,7 +4116,8 @@ var APP_URL_PATTERNS = Object.freeze({
4114
4116
  passports: {
4115
4117
  // Per-record URLs don't exist on passports.social — its SvelteKit routes
4116
4118
  // stop at /profile/{handle}/{passport_slug}. Profile-level fallback only.
4117
- profileUrlPattern: "https://passports.social/profile/{handle}"
4119
+ profileUrlPattern: "https://passports.social/profile/{handle}",
4120
+ itemLinks: false
4118
4121
  },
4119
4122
  leaflet: {
4120
4123
  // Per-document URLs are short and rkey-only: https://leaflet.pub/{rkey}.
@@ -4456,6 +4459,9 @@ function getAppIdForCollection(collection) {
4456
4459
  function urlHealth(url) {
4457
4460
  return url ? { url, strategy: "url" } : { url: null, strategy: "none" };
4458
4461
  }
4462
+ function profileHealth(url) {
4463
+ return url ? { url, strategy: "url", isProfileFallback: true } : { url: null, strategy: "none" };
4464
+ }
4459
4465
  function recordHealth(url) {
4460
4466
  return url ? { url, strategy: "record" } : { url: null, strategy: "none" };
4461
4467
  }
@@ -4484,7 +4490,7 @@ function patternHealth(appId, vars, collection) {
4484
4490
  }
4485
4491
  if (patterns.profileUrlPattern) {
4486
4492
  const url = interpolate(patterns.profileUrlPattern, vars);
4487
- if (url) return urlHealth(url);
4493
+ if (url) return profileHealth(url);
4488
4494
  }
4489
4495
  return { url: null, strategy: "none" };
4490
4496
  }
@@ -4535,7 +4541,7 @@ function resolveCardHealth(item) {
4535
4541
  if (collection.startsWith("sh.tangled.")) {
4536
4542
  const tangled = resolveTangledUrl(collection, record, rkey, authorHandle ?? authorDid);
4537
4543
  if (tangled) return tangled.ownRecord ? recordHealth(tangled.url) : urlHealth(tangled.url);
4538
- if (!authorHandle && authorDid) return urlHealth(`${TANGLED}/${authorDid}`);
4544
+ if (!authorHandle && authorDid) return profileHealth(`${TANGLED}/${authorDid}`);
4539
4545
  return patternHealth("tangled", { handle: authorHandle, did: authorDid, rkey }, collection);
4540
4546
  }
4541
4547
  if (collection === "app.userinput.discussion" && authorDid) {
@@ -4548,6 +4554,13 @@ function resolveCardHealth(item) {
4548
4554
  if (parsed3) return urlHealth(`https://userinput.app/d/${parsed3.did}/${parsed3.rkey}`);
4549
4555
  return { url: null, strategy: "none" };
4550
4556
  }
4557
+ if (collection === "buzz.bookhive.book") {
4558
+ const hiveId = stringOrNull(record.hiveId);
4559
+ if (hiveId && /^bk_[A-Za-z0-9_-]+$/.test(hiveId)) {
4560
+ return urlHealth(`https://bookhive.buzz/books/${hiveId}`);
4561
+ }
4562
+ return patternHealth("bookhive", { handle: authorHandle, did: authorDid, rkey }, collection);
4563
+ }
4551
4564
  if (collection.startsWith("com.kipclip.") || collection.startsWith("community.lexicon.bookmarks.")) {
4552
4565
  const subject = stringOrNull(record.subject);
4553
4566
  if (subject) return urlHealth(subject);
@@ -4562,7 +4575,7 @@ function resolveCardHealth(item) {
4562
4575
  const source = stringOrNull(target.source);
4563
4576
  if (source) return urlHealth(source);
4564
4577
  }
4565
- return urlHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);
4578
+ return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);
4566
4579
  }
4567
4580
  if (collection === "community.lexicon.calendar.rsvp") {
4568
4581
  const subject = record.subject;
@@ -4635,7 +4648,7 @@ function resolveCardHealth(item) {
4635
4648
  return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed3.rkey)}`);
4636
4649
  }
4637
4650
  }
4638
- return urlHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);
4651
+ return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);
4639
4652
  }
4640
4653
  if (collection === "fyi.atstore.listing.review") {
4641
4654
  const listingMeta = record.listingMeta;
@@ -4643,7 +4656,7 @@ function resolveCardHealth(item) {
4643
4656
  const slug = stringOrNull(listingMeta.slug);
4644
4657
  if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);
4645
4658
  }
4646
- return urlHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);
4659
+ return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);
4647
4660
  }
4648
4661
  if (collection === "social.crate.content") {
4649
4662
  return urlHealth(stringOrNull(record.canonicalUrl));
@@ -4675,6 +4688,11 @@ function resolveCardUrl(item) {
4675
4688
  return resolveCardHealth(item).url;
4676
4689
  }
4677
4690
 
4691
+ // src/cards/item-links.ts
4692
+ function appLinksItems(appId) {
4693
+ return APP_URL_PATTERNS[appId]?.itemLinks !== false;
4694
+ }
4695
+
4678
4696
  // src/cards/visibility.ts
4679
4697
  function isNonEmptyString(value) {
4680
4698
  return typeof value === "string" && value.trim().length > 0;
@@ -5161,6 +5179,7 @@ var streamCardVMSchema = zod.z.lazy(
5161
5179
  timestamp: zod.z.string(),
5162
5180
  title: zod.z.string(),
5163
5181
  sourceUrl: zod.z.string().optional(),
5182
+ sourceUrlIsProfile: zod.z.boolean().optional(),
5164
5183
  body: streamCardBodySchema.optional(),
5165
5184
  media: zod.z.array(streamMediaSchema).optional(),
5166
5185
  externalLink: streamExternalLinkSchema.optional(),
@@ -5371,11 +5390,13 @@ function recordHasSubjectRef(record) {
5371
5390
  function resolveSourceUrl(item, record) {
5372
5391
  if (item.collection === "at.marque.domain") {
5373
5392
  const domain = asNonEmptyString(record.domain);
5374
- if (domain && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(domain)) return `https://${domain}`;
5393
+ if (domain && /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(domain)) {
5394
+ return { url: `https://${domain}`, isProfile: false };
5395
+ }
5375
5396
  }
5376
5397
  const authorDid = didFromUri(item.uri) ?? "";
5377
5398
  const authorHandle = asNonEmptyString(item.authorHandle) ?? asNonEmptyString(record.handle);
5378
- const url = resolveCardUrl({
5399
+ const health = resolveCardHealth({
5379
5400
  collection: item.collection,
5380
5401
  record,
5381
5402
  uri: item.uri,
@@ -5383,7 +5404,9 @@ function resolveSourceUrl(item, record) {
5383
5404
  authorDid,
5384
5405
  ...authorHandle ? { authorHandle } : {}
5385
5406
  });
5386
- return url && /^https?:\/\//i.test(url) ? url : void 0;
5407
+ const url = health.url;
5408
+ if (!url || !/^https?:\/\//i.test(url)) return void 0;
5409
+ return { url, isProfile: health.isProfileFallback === true };
5387
5410
  }
5388
5411
  function readTheme(record) {
5389
5412
  const theme = asRecord(record?.publicationTheme ?? record?.theme);
@@ -5973,8 +5996,11 @@ function toStreamCardVM(item, options = {}) {
5973
5996
  const theme = readTheme(record);
5974
5997
  if (theme) vm.theme = theme;
5975
5998
  if (record) {
5976
- const sourceUrl = resolveSourceUrl(item, record);
5977
- if (sourceUrl) vm.sourceUrl = sourceUrl;
5999
+ const source2 = resolveSourceUrl(item, record);
6000
+ if (source2) {
6001
+ vm.sourceUrl = source2.url;
6002
+ if (source2.isProfile) vm.sourceUrlIsProfile = true;
6003
+ }
5978
6004
  }
5979
6005
  if (item.hypercertDetails) vm.hypercertDetails = item.hypercertDetails;
5980
6006
  if (item.certifiedDetails) vm.certifiedDetails = item.certifiedDetails;
@@ -6041,6 +6067,9 @@ function isRich(vm) {
6041
6067
  if (vm.media && vm.media.length > 0) return true;
6042
6068
  if (vm.externalLink) return true;
6043
6069
  if (vm.body && RICH_BODY_KINDS.has(vm.body.kind)) return true;
6070
+ if (vm.body?.kind === "standard-site") {
6071
+ return Boolean(vm.body.title?.trim()) && /^https?:\/\//i.test(vm.body.coverImageUrl ?? "");
6072
+ }
6044
6073
  return false;
6045
6074
  }
6046
6075
  function isSelfAuthoredRich(vm) {
@@ -6083,10 +6112,14 @@ function streamCardContent(vm) {
6083
6112
  // src/stream/timeline-worthy.ts
6084
6113
  function destination(vm) {
6085
6114
  const subject = vm.subject;
6115
+ const own = vm.sourceUrlIsProfile && vm.verb !== "joined" ? void 0 : vm.sourceUrl;
6086
6116
  if (subject?.kind === "person") return subject.handle ? `/p/${subject.handle}` : null;
6087
- if (subject?.kind === "record") return subject.url ?? vm.sourceUrl ?? null;
6088
- if (subject?.kind === "post") return subject.post.sourceUrl ?? vm.sourceUrl ?? null;
6089
- return vm.sourceUrl ?? vm.externalLink?.url ?? null;
6117
+ if (subject?.kind === "record") return subject.url ?? own ?? null;
6118
+ if (subject?.kind === "post") {
6119
+ const nested = subject.post.sourceUrlIsProfile ? void 0 : subject.post.sourceUrl;
6120
+ return nested ?? own ?? null;
6121
+ }
6122
+ return own ?? vm.externalLink?.url ?? null;
6090
6123
  }
6091
6124
  function isTimelineWorthy(vm) {
6092
6125
  if (!destination(vm)) return false;
@@ -7811,7 +7844,7 @@ function describeSifaRecord(collection, value) {
7811
7844
  }
7812
7845
 
7813
7846
  // src/index.ts
7814
- var SIFA_SDK_VERSION = "0.19.104";
7847
+ var SIFA_SDK_VERSION = "0.19.106";
7815
7848
 
7816
7849
  exports.ACTIVITY_TIERS = ACTIVITY_TIERS;
7817
7850
  exports.ACTIVITY_VERBS = ACTIVITY_VERBS;
@@ -7953,6 +7986,7 @@ exports.WORKPLACE_TYPE_LABELS = WORKPLACE_TYPE_LABELS;
7953
7986
  exports.WORKPLACE_TYPE_OPTIONS = WORKPLACE_TYPE_OPTIONS;
7954
7987
  exports.actorShowsIdentity = actorShowsIdentity;
7955
7988
  exports.agentRefSchema = agentRefSchema;
7989
+ exports.appLinksItems = appLinksItems;
7956
7990
  exports.atUriSchema = atUriSchema;
7957
7991
  exports.blobProxyUrl = blobProxyUrl;
7958
7992
  exports.buildMetaDescription = buildMetaDescription;