@singi-labs/sifa-sdk 0.19.105 → 0.19.107

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.
@@ -106,6 +106,13 @@ function resolveCardHealth(item) {
106
106
  if (parsed) return urlHealth(`https://userinput.app/d/${parsed.did}/${parsed.rkey}`);
107
107
  return { url: null, strategy: "none" };
108
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
+ }
109
116
  if (collection.startsWith("com.kipclip.") || collection.startsWith("community.lexicon.bookmarks.")) {
110
117
  const subject = stringOrNull(record.subject);
111
118
  if (subject) return urlHealth(subject);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cards/resolve-card-url.ts"],"sourcesContent":["import { APP_URL_PATTERNS, COLLECTION_TO_APP } from './app-url-patterns.js';\n\n/**\n * An activity item as consumed by the Sifa activity-card components and\n * the sifa-api external-URL health scanner. The shape mirrors the props\n * the cards already use, so the resolver returns the same URL the UI\n * renders.\n */\nexport interface ActivityItemForUrl {\n /** Collection NSID, e.g. \"sh.tangled.graph.repo\". */\n collection: string;\n /** The raw record from the PDS / API. */\n record: Record<string, unknown>;\n /** Full at-uri of the record (used by collections that derive URL from uri). */\n uri: string;\n /** Record key. */\n rkey: string;\n /** DID of the author of the record. */\n authorDid: string;\n /** Optional handle (cards prefer handle in URL paths when available). */\n authorHandle?: string;\n}\n\n/**\n * Map a collection NSID to its app id, mirroring the sifa-web prefix map.\n *\n * Falls back to the first two NSID segments (e.g. \"org.hyperboards\") when\n * the prefix is not registered, matching the existing card behaviour.\n */\nexport function getAppIdForCollection(collection: string): string {\n for (const [prefix, appId] of COLLECTION_TO_APP) {\n if (collection.startsWith(prefix)) return appId;\n }\n const parts = collection.split('.');\n if (parts.length >= 2) return `${parts[0]}.${parts[1]}`;\n return collection;\n}\n\n/**\n * How the activity-card health scanner should verify a card is still live.\n *\n * - `record`: the URL is a first-party permalink that renders THIS record\n * (e.g. a Bluesky post at bsky.app, a tangled repo, a smokesignal event).\n * The authoritative liveness check is \"does the record still exist on its\n * PDS\" — `com.atproto.repo.getRecord` on the item's own at-uri — NOT an\n * HTTP probe of the rendering app. Rendering apps (bsky.app, tangled.sh)\n * frequently answer HEAD with 404/405 while serving GET fine, which\n * false-positives every permalink as broken.\n * - `url`: the URL is a foreign or derived target — a bookmarked page, an\n * external publisher, a *different* record's page, or a profile page.\n * Its reachability is independent of this record, so probe the URL.\n * - `none`: the card has no clickable URL; nothing to check.\n */\nexport type CardHealthStrategy = 'record' | 'url' | 'none';\n\nexport interface CardHealth {\n /** The clickable URL the card renders. Null when non-clickable. */\n url: string | null;\n /** How the scanner should verify liveness. See {@link CardHealthStrategy}. */\n strategy: CardHealthStrategy;\n /**\n * True when `url` is only the author's profile or the app's homepage, used\n * because the record has no page of its own. Such a link does not point at\n * what the person did, so the timeline does not count it as one.\n */\n isProfileFallback?: boolean;\n}\n\n/** Build a `url`-strategy result (foreign/derived/profile target). */\nfunction urlHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url' } : { url: null, strategy: 'none' };\n}\n\n/** Build a profile / app-homepage fallback result (not a link to the record itself). */\nfunction profileHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url', isProfileFallback: true } : { url: null, strategy: 'none' };\n}\n\n/** Build a `record`-strategy result (self-permalink of this record). */\nfunction recordHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'record' } : { url: null, strategy: 'none' };\n}\n\nfunction interpolate(pattern: string, vars: Record<string, string | undefined>): string | null {\n let result = pattern;\n for (const [key, value] of Object.entries(vars)) {\n if (result.includes(`{${key}}`)) {\n if (!value) return null;\n result = result.replaceAll(`{${key}}`, encodeURIComponent(value));\n }\n }\n return result;\n}\n\n/**\n * Whether the per-item `urlPattern` for `appId` is appropriate for `collection`.\n *\n * For bluesky, the per-item URL pattern only matches `app.bsky.feed.post` —\n * other `app.bsky.*` collections (`actor.status`, `graph.cancellation`, etc.)\n * use rkeys that would 404 at `bsky.app/profile/{handle}/post/{rkey}`. Fall\n * back to the profile URL for those. See sifa-web#1070 / sifa-web#1073.\n *\n * When `collection` is undefined we err on the safe side for bluesky and\n * prefer the profile URL.\n */\nfunction shouldUseItemPattern(appId: string, collection: string | undefined): boolean {\n if (appId === 'bluesky') {\n return collection === 'app.bsky.feed.post';\n }\n return true;\n}\n\n/**\n * Resolve a pattern-based URL, reporting which tier produced it:\n * - `item`: a per-item permalink (renders this specific record) -> record-checkable.\n * - `profile`: the app's profile page (not this record) -> url-checkable.\n * - `null`: no pattern applied.\n */\nfunction patternHealth(\n appId: string,\n vars: { handle?: string; did?: string; rkey?: string },\n collection?: string,\n): CardHealth {\n const patterns = APP_URL_PATTERNS[appId];\n if (!patterns) return { url: null, strategy: 'none' };\n\n if (patterns.urlPattern && shouldUseItemPattern(appId, collection)) {\n const url = interpolate(patterns.urlPattern, vars);\n if (url) return recordHealth(url);\n }\n if (patterns.profileUrlPattern) {\n const url = interpolate(patterns.profileUrlPattern, vars);\n if (url) return profileHealth(url);\n }\n return { url: null, strategy: 'none' };\n}\n\n/**\n * Valid tangled repo slug: alphanumeric, dot, dash, underscore. No whitespace,\n * slashes, or URL-encodable special characters. Multi-segment aggregate `name`\n * values produce 404 URLs, so callers fall back to the profile URL when\n * invalid. See sifa-web#1071 / sifa-web#1072.\n */\nconst TANGLED_REPO_SLUG = /^[a-zA-Z0-9._-]+$/;\n\nfunction stringOrNull(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n// at://did:plc:xxx/community.lexicon.calendar.event/rkey\n// -> { did: \"did:plc:xxx\", rkey: \"rkey\" }\nfunction parseAtUri(atUri: string): { did: string; rkey: string } | null {\n const match = atUri.match(/^at:\\/\\/(did:[^/]+)\\/[^/]+\\/(.+)$/);\n if (!match || !match[1] || !match[2]) return null;\n return { did: match[1], rkey: match[2] };\n}\n\nconst TANGLED = 'https://tangled.sh';\n\n/** A Tangled repo reference (a repo DID, or an `at://` repo record uri) as its tangled.sh base URL. */\nfunction tangledRepoBase(ref: unknown): string | null {\n const value = stringOrNull(ref);\n if (!value) return null;\n if (value.startsWith('did:')) return `${TANGLED}/${value}`;\n const parsed = parseAtUri(value);\n if (parsed && TANGLED_REPO_SLUG.test(parsed.rkey))\n return `${TANGLED}/${parsed.did}/${parsed.rkey}`;\n return null;\n}\n\n/**\n * The per-item tangled.sh URL for a Tangled record, or null when none can be\n * built. `ownRecord` is true when the URL is the record's own page (a repo).\n */\nfunction resolveTangledUrl(\n collection: string,\n record: Record<string, unknown>,\n rkey: string,\n owner: string,\n): { url: string; ownRecord: boolean } | null {\n if (collection === 'sh.tangled.repo.pull' || collection === 'sh.tangled.repo.issue') {\n const target =\n record.target && typeof record.target === 'object'\n ? (record.target as Record<string, unknown>)\n : {};\n const base =\n tangledRepoBase(target.repoDid) ??\n tangledRepoBase(target.repo) ??\n tangledRepoBase(record.repoDid) ??\n tangledRepoBase(record.repo);\n if (!base) return null;\n const list = collection === 'sh.tangled.repo.pull' ? 'pulls' : 'issues';\n return { url: `${base}/${list}`, ownRecord: false };\n }\n if (collection === 'sh.tangled.feed.star') {\n const starred = tangledRepoBase(record.subjectDid) ?? tangledRepoBase(record.subject);\n return starred ? { url: starred, ownRecord: false } : null;\n }\n const repoDid = tangledRepoBase(record.repoDid);\n if (repoDid) return { url: repoDid, ownRecord: true };\n const repoName = stringOrNull(record.name) ?? (collection === 'sh.tangled.repo' ? rkey : null);\n if (repoName && owner && TANGLED_REPO_SLUG.test(repoName)) {\n return { url: `${TANGLED}/${owner}/${repoName}`, ownRecord: true };\n }\n return null;\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item **and** how its\n * liveness should be checked — the single source of truth shared by:\n * - sifa-web activity cards (which render `.url`), and\n * - the sifa-api link-health scanner (which switches on `.strategy`: probe the\n * record's PDS for `record`-strategy cards, probe the URL for `url`).\n *\n * See {@link CardHealthStrategy} for why self-permalinks (bsky posts, etc.)\n * must be verified by record existence rather than an HTTP probe of the app.\n *\n * Resolution order:\n * 1. Per-collection bespoke logic (tangled, 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 // Kipclip / community.lexicon.bookmarks: the bookmark subject IS the URL —\n // a foreign page whose reachability is independent of the bookmark record.\n if (\n collection.startsWith('com.kipclip.') ||\n collection.startsWith('community.lexicon.bookmarks.')\n ) {\n const subject = stringOrNull(record.subject);\n if (subject) return urlHealth(subject);\n return patternHealth('kipclip', { handle: authorHandle, did: authorDid, rkey });\n }\n\n // Margin bookmark: card returns null entirely without record.source\n if (collection === 'at.margin.bookmark') {\n return urlHealth(stringOrNull(record.source));\n }\n\n // Margin note / annotation: both are W3C web annotations whose `target.source`\n // is the annotated page URL. Link to that source; fall back to the margin app.\n if (collection === 'at.margin.annotation' || collection === 'at.margin.note') {\n const target = record.target;\n if (target != null && typeof target === 'object') {\n const source = stringOrNull((target as Record<string, unknown>).source);\n if (source) return urlHealth(source);\n }\n return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);\n }\n\n // Community-calendar RSVP: parse the linked event uri. The URL points at a\n // *different* record (the event), so probe the URL, not this RSVP record.\n // Rendered via atmo.rsvp, which resolves any community.lexicon.calendar event\n // by did+rkey (Smoke Signal is decommissioned; its permalinks are retired).\n if (collection === 'community.lexicon.calendar.rsvp') {\n const subject = record.subject;\n if (subject != null && typeof subject === 'object') {\n const subjectUri = stringOrNull((subject as Record<string, unknown>).uri);\n if (subjectUri) {\n const parsed = parseAtUri(subjectUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // Community-calendar event itself: use item's own uri -> self-permalink on\n // atmo.rsvp (same rationale as the RSVP block above).\n if (collection === 'community.lexicon.calendar.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp event itself: use item's own uri -> self-permalink.\n if (collection === 'quest.atmo.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp checkin: parse the linked event uri (record.event) and link to\n // that event's page (mirrors smokesignal rsvp). The URL points at a\n // *different* record (the event), so probe the URL. The checkin's own rkey is\n // not addressable, so without a valid event reference it's non-clickable.\n if (collection === 'quest.atmo.checkin') {\n const eventUri = stringOrNull(record.event);\n if (eventUri) {\n const parsed = parseAtUri(eventUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB forum (atmobb.app): the universal viewer renders any thread at\n // /t/{thread-owner-did}/{thread-rkey}. A discussion reply has no own URL — it\n // lives inside its parent thread, so parse record.thread.uri and link there.\n // That URL points at a *different* record (the thread), so probe the URL.\n if (collection === 'app.atmobb.discussion.reply') {\n const thread = record.thread;\n if (thread != null && typeof thread === 'object') {\n const threadUri = stringOrNull((thread as Record<string, unknown>).uri);\n if (threadUri) {\n const parsed = parseAtUri(threadUri);\n if (parsed) {\n return urlHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB thread itself: build the per-thread URL from the item's own uri — a\n // self-permalink for this record (record-checkable). Other app.atmobb.*\n // collections (actor.profile, forum.*) are excluded upstream by sifa-api and\n // never reach the resolver.\n if (collection === 'app.atmobb.discussion.thread') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // Kut membership: a `social.kut.community.membership` record has no page of\n // its own. Link to the community it joined, parsed from record.community.uri.\n // Kut renders communities at /community/{slug}, where the slug is the\n // community-profile record's rkey (verified live). The community is a\n // *different* record, so probe the URL.\n if (collection === 'social.kut.community.membership') {\n const community = record.community;\n const communityUri =\n typeof community === 'string'\n ? community\n : // Narrow the object branch to read an optional `uri`. The membership\n // lexicon carries `community` as a bare at-uri string today; the cast\n // tolerates a future strongRef `{ uri, cid }` shape without assuming it.\n community != null && typeof community === 'object'\n ? stringOrNull((community as Record<string, unknown>).uri)\n : null;\n if (communityUri) {\n const parsed = parseAtUri(communityUri);\n if (parsed) {\n return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed.rkey)}`);\n }\n }\n return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);\n }\n\n // atstore reviews: no per-review URL exists on atstore.fyi, and user profile\n // pages don't exist either. Deep-link to the reviewed product instead.\n // The slug comes from record.listingMeta (enriched by sifa-api from the\n // review's `subject` at-uri → fyi.atstore.listing.detail record). The product\n // page is a *different* subject, so probe the URL.\n if (collection === 'fyi.atstore.listing.review') {\n const listingMeta = record.listingMeta;\n if (listingMeta != null && typeof listingMeta === 'object') {\n const slug = stringOrNull((listingMeta as Record<string, unknown>).slug);\n if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);\n }\n return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);\n }\n\n // Crate content: link to the canonical published location the maker\n // recorded (a YouTube/podcast/personal-site URL) — a foreign page. Crate is\n // an authoring dashboard with no public per-record viewer, so `note`\n // records — which carry no canonicalUrl — render non-clickable (null).\n if (collection === 'social.crate.content') {\n return urlHealth(stringOrNull(record.canonicalUrl));\n }\n if (collection === 'social.crate.note') {\n return { url: null, strategy: 'none' };\n }\n\n // Standard documents: siteUrl + path (or just siteUrl) — an external site.\n if (collection.startsWith('site.standard.')) {\n const siteUrl = stringOrNull(record.siteUrl);\n const path = stringOrNull(record.path);\n if (siteUrl && path) return urlHealth(`${siteUrl}${path}`);\n if (siteUrl) return urlHealth(siteUrl);\n // Fall through to generic record.url / patterns\n }\n\n // Kich recipe: always link to the Kich recipe page. record.url is the\n // *source* the recipe was imported from (a YouTube video, a blog), which the\n // card shows as secondary attribution — not the primary link. Must run before\n // the generic record.url fallback below, which would otherwise hijack it. The\n // recipe page is a first-party permalink for this record.\n if (collection === 'io.kich.recipe.recipe') {\n return patternHealth('kich', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // recipe.exchange: same shape as Kich — the record's attribution.url points\n // at the original source (a blog/video), not the app. Always link to the\n // recipe.exchange page. Must run before the generic fallback below.\n if (collection === 'exchange.recipe.recipe') {\n return patternHealth('recipe', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // atmob paste (atmob.in): the SPA viewer renders any paste by its full at-uri\n // via a `?uri=` query param (verified in the app's app.js). Both\n // paste.document and paste.bundle share that viewer, so build the URL from the\n // item's own uri — a self-permalink for this record (record-checkable).\n if (collection.startsWith('in.atmob.paste.')) {\n if (uri) return recordHealth(`https://atmob.in/?uri=${encodeURIComponent(uri)}`);\n return { url: null, strategy: 'none' };\n }\n\n // --- Generic fallbacks ---\n\n // record.url is a common ad-hoc per-item URL (hyperboards, etc.). It is an\n // arbitrary foreign URL, so probe it directly.\n const recordUrl = stringOrNull(record.url);\n if (recordUrl) return urlHealth(recordUrl);\n\n // Pattern-based fallback. Pass `collection` so app-specific guards (bluesky\n // restricting /post/{rkey} to app.bsky.feed.post) can apply. The tier\n // (per-item vs profile) decides record- vs url-strategy.\n return patternHealth(appId, { handle: authorHandle, did: authorDid, rkey }, collection);\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item — the same URL\n * the activity-card UI in sifa-web renders. Thin wrapper over\n * {@link resolveCardHealth}; returns `null` when no link is appropriate.\n */\nexport function resolveCardUrl(item: ActivityItemForUrl): string | null {\n return resolveCardHealth(item).url;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/cards/resolve-card-url.ts"],"sourcesContent":["import { APP_URL_PATTERNS, COLLECTION_TO_APP } from './app-url-patterns.js';\n\n/**\n * An activity item as consumed by the Sifa activity-card components and\n * the sifa-api external-URL health scanner. The shape mirrors the props\n * the cards already use, so the resolver returns the same URL the UI\n * renders.\n */\nexport interface ActivityItemForUrl {\n /** Collection NSID, e.g. \"sh.tangled.graph.repo\". */\n collection: string;\n /** The raw record from the PDS / API. */\n record: Record<string, unknown>;\n /** Full at-uri of the record (used by collections that derive URL from uri). */\n uri: string;\n /** Record key. */\n rkey: string;\n /** DID of the author of the record. */\n authorDid: string;\n /** Optional handle (cards prefer handle in URL paths when available). */\n authorHandle?: string;\n}\n\n/**\n * Map a collection NSID to its app id, mirroring the sifa-web prefix map.\n *\n * Falls back to the first two NSID segments (e.g. \"org.hyperboards\") when\n * the prefix is not registered, matching the existing card behaviour.\n */\nexport function getAppIdForCollection(collection: string): string {\n for (const [prefix, appId] of COLLECTION_TO_APP) {\n if (collection.startsWith(prefix)) return appId;\n }\n const parts = collection.split('.');\n if (parts.length >= 2) return `${parts[0]}.${parts[1]}`;\n return collection;\n}\n\n/**\n * How the activity-card health scanner should verify a card is still live.\n *\n * - `record`: the URL is a first-party permalink that renders THIS record\n * (e.g. a Bluesky post at bsky.app, a tangled repo, a smokesignal event).\n * The authoritative liveness check is \"does the record still exist on its\n * PDS\" — `com.atproto.repo.getRecord` on the item's own at-uri — NOT an\n * HTTP probe of the rendering app. Rendering apps (bsky.app, tangled.sh)\n * frequently answer HEAD with 404/405 while serving GET fine, which\n * false-positives every permalink as broken.\n * - `url`: the URL is a foreign or derived target — a bookmarked page, an\n * external publisher, a *different* record's page, or a profile page.\n * Its reachability is independent of this record, so probe the URL.\n * - `none`: the card has no clickable URL; nothing to check.\n */\nexport type CardHealthStrategy = 'record' | 'url' | 'none';\n\nexport interface CardHealth {\n /** The clickable URL the card renders. Null when non-clickable. */\n url: string | null;\n /** How the scanner should verify liveness. See {@link CardHealthStrategy}. */\n strategy: CardHealthStrategy;\n /**\n * True when `url` is only the author's profile or the app's homepage, used\n * because the record has no page of its own. Such a link does not point at\n * what the person did, so the timeline does not count it as one.\n */\n isProfileFallback?: boolean;\n}\n\n/** Build a `url`-strategy result (foreign/derived/profile target). */\nfunction urlHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url' } : { url: null, strategy: 'none' };\n}\n\n/** Build a profile / app-homepage fallback result (not a link to the record itself). */\nfunction profileHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'url', isProfileFallback: true } : { url: null, strategy: 'none' };\n}\n\n/** Build a `record`-strategy result (self-permalink of this record). */\nfunction recordHealth(url: string | null): CardHealth {\n return url ? { url, strategy: 'record' } : { url: null, strategy: 'none' };\n}\n\nfunction interpolate(pattern: string, vars: Record<string, string | undefined>): string | null {\n let result = pattern;\n for (const [key, value] of Object.entries(vars)) {\n if (result.includes(`{${key}}`)) {\n if (!value) return null;\n result = result.replaceAll(`{${key}}`, encodeURIComponent(value));\n }\n }\n return result;\n}\n\n/**\n * Whether the per-item `urlPattern` for `appId` is appropriate for `collection`.\n *\n * For bluesky, the per-item URL pattern only matches `app.bsky.feed.post` —\n * other `app.bsky.*` collections (`actor.status`, `graph.cancellation`, etc.)\n * use rkeys that would 404 at `bsky.app/profile/{handle}/post/{rkey}`. Fall\n * back to the profile URL for those. See sifa-web#1070 / sifa-web#1073.\n *\n * When `collection` is undefined we err on the safe side for bluesky and\n * prefer the profile URL.\n */\nfunction shouldUseItemPattern(appId: string, collection: string | undefined): boolean {\n if (appId === 'bluesky') {\n return collection === 'app.bsky.feed.post';\n }\n return true;\n}\n\n/**\n * Resolve a pattern-based URL, reporting which tier produced it:\n * - `item`: a per-item permalink (renders this specific record) -> record-checkable.\n * - `profile`: the app's profile page (not this record) -> url-checkable.\n * - `null`: no pattern applied.\n */\nfunction patternHealth(\n appId: string,\n vars: { handle?: string; did?: string; rkey?: string },\n collection?: string,\n): CardHealth {\n const patterns = APP_URL_PATTERNS[appId];\n if (!patterns) return { url: null, strategy: 'none' };\n\n if (patterns.urlPattern && shouldUseItemPattern(appId, collection)) {\n const url = interpolate(patterns.urlPattern, vars);\n if (url) return recordHealth(url);\n }\n if (patterns.profileUrlPattern) {\n const url = interpolate(patterns.profileUrlPattern, vars);\n if (url) return profileHealth(url);\n }\n return { url: null, strategy: 'none' };\n}\n\n/**\n * Valid tangled repo slug: alphanumeric, dot, dash, underscore. No whitespace,\n * slashes, or URL-encodable special characters. Multi-segment aggregate `name`\n * values produce 404 URLs, so callers fall back to the profile URL when\n * invalid. See sifa-web#1071 / sifa-web#1072.\n */\nconst TANGLED_REPO_SLUG = /^[a-zA-Z0-9._-]+$/;\n\nfunction stringOrNull(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n// at://did:plc:xxx/community.lexicon.calendar.event/rkey\n// -> { did: \"did:plc:xxx\", rkey: \"rkey\" }\nfunction parseAtUri(atUri: string): { did: string; rkey: string } | null {\n const match = atUri.match(/^at:\\/\\/(did:[^/]+)\\/[^/]+\\/(.+)$/);\n if (!match || !match[1] || !match[2]) return null;\n return { did: match[1], rkey: match[2] };\n}\n\nconst TANGLED = 'https://tangled.sh';\n\n/** A Tangled repo reference (a repo DID, or an `at://` repo record uri) as its tangled.sh base URL. */\nfunction tangledRepoBase(ref: unknown): string | null {\n const value = stringOrNull(ref);\n if (!value) return null;\n if (value.startsWith('did:')) return `${TANGLED}/${value}`;\n const parsed = parseAtUri(value);\n if (parsed && TANGLED_REPO_SLUG.test(parsed.rkey))\n return `${TANGLED}/${parsed.did}/${parsed.rkey}`;\n return null;\n}\n\n/**\n * The per-item tangled.sh URL for a Tangled record, or null when none can be\n * built. `ownRecord` is true when the URL is the record's own page (a repo).\n */\nfunction resolveTangledUrl(\n collection: string,\n record: Record<string, unknown>,\n rkey: string,\n owner: string,\n): { url: string; ownRecord: boolean } | null {\n if (collection === 'sh.tangled.repo.pull' || collection === 'sh.tangled.repo.issue') {\n const target =\n record.target && typeof record.target === 'object'\n ? (record.target as Record<string, unknown>)\n : {};\n const base =\n tangledRepoBase(target.repoDid) ??\n tangledRepoBase(target.repo) ??\n tangledRepoBase(record.repoDid) ??\n tangledRepoBase(record.repo);\n if (!base) return null;\n const list = collection === 'sh.tangled.repo.pull' ? 'pulls' : 'issues';\n return { url: `${base}/${list}`, ownRecord: false };\n }\n if (collection === 'sh.tangled.feed.star') {\n const starred = tangledRepoBase(record.subjectDid) ?? tangledRepoBase(record.subject);\n return starred ? { url: starred, ownRecord: false } : null;\n }\n const repoDid = tangledRepoBase(record.repoDid);\n if (repoDid) return { url: repoDid, ownRecord: true };\n const repoName = stringOrNull(record.name) ?? (collection === 'sh.tangled.repo' ? rkey : null);\n if (repoName && owner && TANGLED_REPO_SLUG.test(repoName)) {\n return { url: `${TANGLED}/${owner}/${repoName}`, ownRecord: true };\n }\n return null;\n}\n\n/**\n * Resolve the canonical clickable URL for an activity item **and** how its\n * liveness should be checked — the single source of truth shared by:\n * - sifa-web activity cards (which render `.url`), and\n * - the sifa-api link-health scanner (which switches on `.strategy`: probe the\n * record's PDS for `record`-strategy cards, probe the URL for `url`).\n *\n * See {@link CardHealthStrategy} for why self-permalinks (bsky posts, etc.)\n * must be verified by record existence rather than an HTTP probe of the app.\n *\n * Resolution order:\n * 1. Per-collection bespoke logic (tangled, userinput, bookhive, kipclip, margin, smokesignal\n * rsvp, standard documents). These mirror the inline logic in the\n * individual card components.\n * 2. Generic `record.url` field (used by hyperboards and similar).\n * 3. Pattern-based per-item / profile URL from the registry.\n */\nexport function resolveCardHealth(item: ActivityItemForUrl): CardHealth {\n const { collection, record, uri, rkey, authorDid, authorHandle } = item;\n const appId = getAppIdForCollection(collection);\n\n // --- Per-collection bespoke logic (mirrors the card components) ---\n\n // Tangled. tangled.sh resolves both DIDs and handles in the path, so a link\n // never depends on the author handle being known:\n // - a repo: by its repo DID when the record carries one, else owner + slug\n // (`record.name`, or the rkey for `sh.tangled.repo`, whose newer records\n // use the slug as the rkey). Only valid slugs: multi-segment aggregate\n // names would 404 (sifa-web#1071).\n // - a pull / issue: the target repo's pulls / issues list. The record carries\n // no PR or issue number, so the single item is not addressable.\n // - a star: the starred repo.\n // Anything else falls back to the owner's profile.\n if (collection.startsWith('sh.tangled.')) {\n const tangled = resolveTangledUrl(collection, record, rkey, authorHandle ?? authorDid);\n // A repo link is this record's own page; a pull / issue / star links to a\n // different record (the target repo), so probe that page itself.\n if (tangled) return tangled.ownRecord ? recordHealth(tangled.url) : urlHealth(tangled.url);\n if (!authorHandle && authorDid) return profileHealth(`${TANGLED}/${authorDid}`);\n return patternHealth('tangled', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // User Input: a discussion has its own page; a reply links to the discussion\n // it belongs to (its `subject`).\n if (collection === 'app.userinput.discussion' && authorDid) {\n return recordHealth(`https://userinput.app/d/${authorDid}/${rkey}`);\n }\n if (collection === 'app.userinput.reply') {\n const subject = record.subject;\n const subjectUri =\n subject != null && typeof subject === 'object'\n ? stringOrNull((subject as Record<string, unknown>).uri)\n : null;\n const parsed = subjectUri?.includes('/app.userinput.discussion/')\n ? parseAtUri(subjectUri)\n : null;\n if (parsed) return urlHealth(`https://userinput.app/d/${parsed.did}/${parsed.rkey}`);\n return { url: null, strategy: 'none' };\n }\n\n // BookHive: a book log links to the book's page by the record's `hiveId`.\n // The book page is a different record (shared by every reader), so its\n // reachability is probed as a URL.\n if (collection === 'buzz.bookhive.book') {\n const hiveId = stringOrNull(record.hiveId);\n if (hiveId && /^bk_[A-Za-z0-9_-]+$/.test(hiveId)) {\n return urlHealth(`https://bookhive.buzz/books/${hiveId}`);\n }\n return patternHealth('bookhive', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // Kipclip / community.lexicon.bookmarks: the bookmark subject IS the URL —\n // a foreign page whose reachability is independent of the bookmark record.\n if (\n collection.startsWith('com.kipclip.') ||\n collection.startsWith('community.lexicon.bookmarks.')\n ) {\n const subject = stringOrNull(record.subject);\n if (subject) return urlHealth(subject);\n return patternHealth('kipclip', { handle: authorHandle, did: authorDid, rkey });\n }\n\n // Margin bookmark: card returns null entirely without record.source\n if (collection === 'at.margin.bookmark') {\n return urlHealth(stringOrNull(record.source));\n }\n\n // Margin note / annotation: both are W3C web annotations whose `target.source`\n // is the annotated page URL. Link to that source; fall back to the margin app.\n if (collection === 'at.margin.annotation' || collection === 'at.margin.note') {\n const target = record.target;\n if (target != null && typeof target === 'object') {\n const source = stringOrNull((target as Record<string, unknown>).source);\n if (source) return urlHealth(source);\n }\n return profileHealth(APP_URL_PATTERNS.margin?.profileUrlPattern ?? null);\n }\n\n // Community-calendar RSVP: parse the linked event uri. The URL points at a\n // *different* record (the event), so probe the URL, not this RSVP record.\n // Rendered via atmo.rsvp, which resolves any community.lexicon.calendar event\n // by did+rkey (Smoke Signal is decommissioned; its permalinks are retired).\n if (collection === 'community.lexicon.calendar.rsvp') {\n const subject = record.subject;\n if (subject != null && typeof subject === 'object') {\n const subjectUri = stringOrNull((subject as Record<string, unknown>).uri);\n if (subjectUri) {\n const parsed = parseAtUri(subjectUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // Community-calendar event itself: use item's own uri -> self-permalink on\n // atmo.rsvp (same rationale as the RSVP block above).\n if (collection === 'community.lexicon.calendar.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp event itself: use item's own uri -> self-permalink.\n if (collection === 'quest.atmo.event') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmo.rsvp checkin: parse the linked event uri (record.event) and link to\n // that event's page (mirrors smokesignal rsvp). The URL points at a\n // *different* record (the event), so probe the URL. The checkin's own rkey is\n // not addressable, so without a valid event reference it's non-clickable.\n if (collection === 'quest.atmo.checkin') {\n const eventUri = stringOrNull(record.event);\n if (eventUri) {\n const parsed = parseAtUri(eventUri);\n if (parsed) {\n return urlHealth(`https://atmo.rsvp/p/${parsed.did}/e/${parsed.rkey}`);\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB forum (atmobb.app): the universal viewer renders any thread at\n // /t/{thread-owner-did}/{thread-rkey}. A discussion reply has no own URL — it\n // lives inside its parent thread, so parse record.thread.uri and link there.\n // That URL points at a *different* record (the thread), so probe the URL.\n if (collection === 'app.atmobb.discussion.reply') {\n const thread = record.thread;\n if (thread != null && typeof thread === 'object') {\n const threadUri = stringOrNull((thread as Record<string, unknown>).uri);\n if (threadUri) {\n const parsed = parseAtUri(threadUri);\n if (parsed) {\n return urlHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n }\n }\n return { url: null, strategy: 'none' };\n }\n\n // atmoBB thread itself: build the per-thread URL from the item's own uri — a\n // self-permalink for this record (record-checkable). Other app.atmobb.*\n // collections (actor.profile, forum.*) are excluded upstream by sifa-api and\n // never reach the resolver.\n if (collection === 'app.atmobb.discussion.thread') {\n const parsed = parseAtUri(uri);\n if (parsed) {\n return recordHealth(`https://atmobb.app/t/${parsed.did}/${parsed.rkey}`);\n }\n return { url: null, strategy: 'none' };\n }\n\n // Kut membership: a `social.kut.community.membership` record has no page of\n // its own. Link to the community it joined, parsed from record.community.uri.\n // Kut renders communities at /community/{slug}, where the slug is the\n // community-profile record's rkey (verified live). The community is a\n // *different* record, so probe the URL.\n if (collection === 'social.kut.community.membership') {\n const community = record.community;\n const communityUri =\n typeof community === 'string'\n ? community\n : // Narrow the object branch to read an optional `uri`. The membership\n // lexicon carries `community` as a bare at-uri string today; the cast\n // tolerates a future strongRef `{ uri, cid }` shape without assuming it.\n community != null && typeof community === 'object'\n ? stringOrNull((community as Record<string, unknown>).uri)\n : null;\n if (communityUri) {\n const parsed = parseAtUri(communityUri);\n if (parsed) {\n return urlHealth(`https://kut.social/community/${encodeURIComponent(parsed.rkey)}`);\n }\n }\n return profileHealth(APP_URL_PATTERNS.kut?.profileUrlPattern ?? null);\n }\n\n // atstore reviews: no per-review URL exists on atstore.fyi, and user profile\n // pages don't exist either. Deep-link to the reviewed product instead.\n // The slug comes from record.listingMeta (enriched by sifa-api from the\n // review's `subject` at-uri → fyi.atstore.listing.detail record). The product\n // page is a *different* subject, so probe the URL.\n if (collection === 'fyi.atstore.listing.review') {\n const listingMeta = record.listingMeta;\n if (listingMeta != null && typeof listingMeta === 'object') {\n const slug = stringOrNull((listingMeta as Record<string, unknown>).slug);\n if (slug) return urlHealth(`https://atstore.fyi/products/${encodeURIComponent(slug)}`);\n }\n return profileHealth(APP_URL_PATTERNS.atstore?.profileUrlPattern ?? null);\n }\n\n // Crate content: link to the canonical published location the maker\n // recorded (a YouTube/podcast/personal-site URL) — a foreign page. Crate is\n // an authoring dashboard with no public per-record viewer, so `note`\n // records — which carry no canonicalUrl — render non-clickable (null).\n if (collection === 'social.crate.content') {\n return urlHealth(stringOrNull(record.canonicalUrl));\n }\n if (collection === 'social.crate.note') {\n return { url: null, strategy: 'none' };\n }\n\n // Standard documents: siteUrl + path (or just siteUrl) — an external site.\n if (collection.startsWith('site.standard.')) {\n const siteUrl = stringOrNull(record.siteUrl);\n const path = stringOrNull(record.path);\n if (siteUrl && path) return urlHealth(`${siteUrl}${path}`);\n if (siteUrl) return urlHealth(siteUrl);\n // Fall through to generic record.url / patterns\n }\n\n // Kich recipe: always link to the Kich recipe page. record.url is the\n // *source* the recipe was imported from (a YouTube video, a blog), which the\n // card shows as secondary attribution — not the primary link. Must run before\n // the generic record.url fallback below, which would otherwise hijack it. The\n // recipe page is a first-party permalink for this record.\n if (collection === 'io.kich.recipe.recipe') {\n return patternHealth('kich', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // recipe.exchange: same shape as Kich — the record's attribution.url points\n // at the original source (a blog/video), not the app. Always link to the\n // recipe.exchange page. Must run before the generic fallback below.\n if (collection === 'exchange.recipe.recipe') {\n return patternHealth('recipe', { handle: authorHandle, did: authorDid, rkey }, collection);\n }\n\n // 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
@@ -4554,6 +4554,13 @@ function resolveCardHealth(item) {
4554
4554
  if (parsed3) return urlHealth(`https://userinput.app/d/${parsed3.did}/${parsed3.rkey}`);
4555
4555
  return { url: null, strategy: "none" };
4556
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
+ }
4557
4564
  if (collection.startsWith("com.kipclip.") || collection.startsWith("community.lexicon.bookmarks.")) {
4558
4565
  const subject = stringOrNull(record.subject);
4559
4566
  if (subject) return urlHealth(subject);
@@ -4956,7 +4963,8 @@ var streamAuthorSchema = zod.z.object({
4956
4963
  var streamThemeSchema = zod.z.object({
4957
4964
  background: rgbColorSchema,
4958
4965
  foreground: rgbColorSchema,
4959
- accent: rgbColorSchema
4966
+ accent: rgbColorSchema,
4967
+ accentForeground: rgbColorSchema.optional()
4960
4968
  });
4961
4969
  var streamMediaResolvedSchema = zod.z.object({
4962
4970
  url: zod.z.string(),
@@ -5404,9 +5412,14 @@ function resolveSourceUrl(item, record) {
5404
5412
  function readTheme(record) {
5405
5413
  const theme = asRecord(record?.publicationTheme ?? record?.theme);
5406
5414
  if (!theme) return void 0;
5407
- const { background, foreground, accent } = theme;
5415
+ const { background, foreground, accent, accentForeground } = theme;
5408
5416
  if (isValidRgbColor(background) && isValidRgbColor(foreground) && isValidRgbColor(accent)) {
5409
- return { background, foreground, accent };
5417
+ return {
5418
+ background,
5419
+ foreground,
5420
+ accent,
5421
+ ...isValidRgbColor(accentForeground) ? { accentForeground } : {}
5422
+ };
5410
5423
  }
5411
5424
  return void 0;
5412
5425
  }
@@ -6060,6 +6073,9 @@ function isRich(vm) {
6060
6073
  if (vm.media && vm.media.length > 0) return true;
6061
6074
  if (vm.externalLink) return true;
6062
6075
  if (vm.body && RICH_BODY_KINDS.has(vm.body.kind)) return true;
6076
+ if (vm.body?.kind === "standard-site") {
6077
+ return Boolean(vm.body.title?.trim()) && /^https?:\/\//i.test(vm.body.coverImageUrl ?? "");
6078
+ }
6063
6079
  return false;
6064
6080
  }
6065
6081
  function isSelfAuthoredRich(vm) {
@@ -7834,7 +7850,7 @@ function describeSifaRecord(collection, value) {
7834
7850
  }
7835
7851
 
7836
7852
  // src/index.ts
7837
- var SIFA_SDK_VERSION = "0.19.105";
7853
+ var SIFA_SDK_VERSION = "0.19.107";
7838
7854
 
7839
7855
  exports.ACTIVITY_TIERS = ACTIVITY_TIERS;
7840
7856
  exports.ACTIVITY_VERBS = ACTIVITY_VERBS;