diff --git a/src/components/nostr/kinds/NsiteDetailRenderer.tsx b/src/components/nostr/kinds/NsiteDetailRenderer.tsx
new file mode 100644
index 0000000..bb5d9a4
--- /dev/null
+++ b/src/components/nostr/kinds/NsiteDetailRenderer.tsx
@@ -0,0 +1,243 @@
+import { useState } from "react";
+import {
+ Globe,
+ FileText,
+ HardDrive,
+ ExternalLink,
+ Code,
+ Radio,
+} from "lucide-react";
+import {
+ getNsitePaths,
+ getNsiteServers,
+ getNsiteRelays,
+ getNsiteIdentifier,
+ getNsiteSource,
+ getNsiteGatewayUrl,
+} from "@/lib/nip5a-helpers";
+import { useNsiteMetadata } from "@/hooks/useNsiteMetadata";
+import { useAddWindow } from "@/core/state";
+import { RelayLink } from "../RelayLink";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import type { NostrEvent } from "@/types/nostr";
+
+function NsiteIcon({
+ faviconUrl,
+ className,
+}: {
+ faviconUrl?: string;
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+
+ if (!faviconUrl || failed) {
+ return ;
+ }
+
+ return (
+
setFailed(true)}
+ />
+ );
+}
+
+function NsiteFileRow({
+ path,
+ hash,
+ serverUrl,
+}: {
+ path: string;
+ hash: string;
+ serverUrl?: string;
+}) {
+ const addWindow = useAddWindow();
+
+ const handleClick = () => {
+ addWindow(
+ "blossom",
+ { subcommand: "blob", sha256: hash, serverUrl },
+ `blossom blob ${hash.slice(0, 8)}`,
+ undefined,
+ );
+ };
+
+ return (
+
+ {path}
+
+ {hash.slice(0, 8)}…{hash.slice(-4)}
+
+
+ );
+}
+
+/**
+ * Shared detail view for all nsite kinds
+ */
+function NsiteDetailView({
+ event,
+ legacy = false,
+}: {
+ event: NostrEvent;
+ legacy?: boolean;
+}) {
+ const paths = getNsitePaths(event);
+ const servers = getNsiteServers(event);
+ const relays = getNsiteRelays(event);
+ const identifier = getNsiteIdentifier(event);
+ const source = getNsiteSource(event);
+ const gatewayUrl = getNsiteGatewayUrl(event);
+ const { title, description, faviconUrl } = useNsiteMetadata(event);
+ const addWindow = useAddWindow();
+
+ const displayTitle = title || (identifier ? `/${identifier}` : "Nsite");
+
+ const handleServerClick = (serverUrl: string) => {
+ addWindow(
+ "blossom",
+ { subcommand: "server", serverUrl },
+ `blossom server ${serverUrl}`,
+ undefined,
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
{displayTitle}
+ {identifier && }
+
+ {description && (
+
{description}
+ )}
+ {legacy && (
+
+ This is a legacy nsite event (kind 34128). New sites should use kind
+ 15128 or 35128.
+
+ )}
+
+
+ {/* Actions */}
+
+
+ {/* Files */}
+ {paths.length > 0 && (
+
+
+
+ Files ({paths.length})
+
+
+ {[...paths]
+ .sort((a, b) => a.path.localeCompare(b.path))
+ .map(({ path, hash }) => (
+
+ ))}
+
+
+ )}
+
+ {/* Servers */}
+ {servers.length > 0 && (
+
+
+
+ Blossom Servers ({servers.length})
+
+
+ {servers.map((url) => (
+
handleServerClick(url)}
+ >
+
+
+ {url}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Relays */}
+ {relays.length > 0 && (
+
+
+
+ Relays ({relays.length})
+
+
+ {relays.map((url) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+/**
+ * Kind 15128 Detail Renderer - Root Nsite Manifest
+ */
+export function NsiteRootDetailRenderer({ event }: { event: NostrEvent }) {
+ return ;
+}
+
+/**
+ * Kind 35128 Detail Renderer - Named Nsite Manifest
+ */
+export function NsiteNamedDetailRenderer({ event }: { event: NostrEvent }) {
+ return ;
+}
+
+/**
+ * Kind 34128 Detail Renderer - Legacy Nsite (deprecated)
+ */
+export function NsiteLegacyDetailRenderer({ event }: { event: NostrEvent }) {
+ return ;
+}
diff --git a/src/components/nostr/kinds/NsiteRenderer.tsx b/src/components/nostr/kinds/NsiteRenderer.tsx
new file mode 100644
index 0000000..69e9148
--- /dev/null
+++ b/src/components/nostr/kinds/NsiteRenderer.tsx
@@ -0,0 +1,125 @@
+import { useState } from "react";
+import { Globe, FileText, HardDrive, ExternalLink } from "lucide-react";
+import {
+ getNsitePaths,
+ getNsiteServers,
+ getNsiteIdentifier,
+ getNsiteGatewayUrl,
+} from "@/lib/nip5a-helpers";
+import { useNsiteMetadata } from "@/hooks/useNsiteMetadata";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+
+/**
+ * Shows favicon with Globe fallback on error or when unavailable
+ */
+function NsiteIcon({
+ faviconUrl,
+ className,
+}: {
+ faviconUrl?: string;
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+
+ if (!faviconUrl || failed) {
+ return ;
+ }
+
+ return (
+
setFailed(true)}
+ />
+ );
+}
+
+/**
+ * Shared nsite feed renderer
+ */
+function NsiteRendererInner({
+ event,
+ legacy = false,
+}: BaseEventProps & { legacy?: boolean }) {
+ const paths = getNsitePaths(event);
+ const servers = getNsiteServers(event);
+ const identifier = getNsiteIdentifier(event);
+ const { title, faviconUrl } = useNsiteMetadata(event);
+ const gatewayUrl = getNsiteGatewayUrl(event);
+
+ const displayTitle = title || (identifier ? `/${identifier}` : "Nsite");
+
+ return (
+
+
+
+
+ {displayTitle}
+ {legacy && (
+
+ (legacy)
+
+ )}
+
+
+
+ {paths.length > 0 && (
+
+
+
+ {paths.length} file{paths.length !== 1 ? "s" : ""}
+
+
+ )}
+ {servers.length > 0 && (
+
+
+
+ {servers.length} server{servers.length !== 1 ? "s" : ""}
+
+
+ )}
+
e.stopPropagation()}
+ >
+
+ Visit
+
+
+
+
+ );
+}
+
+/**
+ * Kind 15128 Renderer - Root Nsite Manifest (Feed View)
+ */
+export function NsiteRootRenderer({ event }: BaseEventProps) {
+ return ;
+}
+
+/**
+ * Kind 35128 Renderer - Named Nsite Manifest (Feed View)
+ */
+export function NsiteNamedRenderer({ event }: BaseEventProps) {
+ return ;
+}
+
+/**
+ * Kind 34128 Renderer - Legacy Nsite (Feed View, deprecated)
+ */
+export function NsiteLegacyRenderer({ event }: BaseEventProps) {
+ return ;
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index b60bb85..ca56cc2 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -176,6 +176,16 @@ import {
import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer";
import { EducationalResourceRenderer } from "./EducationalResourceRenderer";
import { EducationalResourceDetailRenderer } from "./EducationalResourceDetailRenderer";
+import {
+ NsiteRootRenderer,
+ NsiteNamedRenderer,
+ NsiteLegacyRenderer,
+} from "./NsiteRenderer";
+import {
+ NsiteRootDetailRenderer,
+ NsiteNamedDetailRenderer,
+ NsiteLegacyDetailRenderer,
+} from "./NsiteDetailRenderer";
/**
* Registry of kind-specific renderers
@@ -240,6 +250,7 @@ const kindRenderers: Record> = {
10317: Kind10317Renderer, // User Grasp List (NIP-34)
10777: FavoriteSpellsRenderer, // Favorite Spells (Grimoire)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
+ 15128: NsiteRootRenderer, // Root Nsite Manifest (NIP-5A)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
30003: BookmarkSetRenderer, // Bookmark Sets (NIP-51)
@@ -265,6 +276,8 @@ const kindRenderers: Record> = {
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
36787: MusicTrackRenderer, // Music Track
+ 34128: NsiteLegacyRenderer, // Legacy Nsite (NIP-5A, deprecated)
+ 35128: NsiteNamedRenderer, // Named Nsite Manifest (NIP-5A)
30617: RepositoryRenderer, // Repository (NIP-34)
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
@@ -356,6 +369,7 @@ const detailRenderers: Record<
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
10777: FavoriteSpellsDetailRenderer, // Favorite Spells Detail (Grimoire)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
+ 15128: NsiteRootDetailRenderer, // Root Nsite Manifest Detail (NIP-5A)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
30003: BookmarkSetDetailRenderer, // Bookmark Sets Detail (NIP-51)
30004: ArticleCurationSetDetailRenderer, // Article Curation Sets Detail (NIP-51)
@@ -376,6 +390,8 @@ const detailRenderers: Record<
30383: TrustedAssertionDetailRenderer, // Event Assertion Detail (NIP-85)
30384: TrustedAssertionDetailRenderer, // Address Assertion Detail (NIP-85)
30385: TrustedAssertionDetailRenderer, // External Assertion Detail (NIP-85)
+ 34128: NsiteLegacyDetailRenderer, // Legacy Nsite Detail (NIP-5A, deprecated)
+ 35128: NsiteNamedDetailRenderer, // Named Nsite Detail (NIP-5A)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
diff --git a/src/hooks/useNsiteMetadata.ts b/src/hooks/useNsiteMetadata.ts
new file mode 100644
index 0000000..21ca1dc
--- /dev/null
+++ b/src/hooks/useNsiteMetadata.ts
@@ -0,0 +1,226 @@
+import { useState, useEffect } from "react";
+import type { NostrEvent } from "@/types/nostr";
+import {
+ getNsiteTitle,
+ getNsiteDescription,
+ getNsiteIndexHash,
+ getNsiteServers,
+ getNsiteGatewayUrl,
+} from "@/lib/nip5a-helpers";
+import { getBlobUrl } from "@/services/blossom";
+import { blossomServerCache } from "@/services/blossom-server-cache";
+import db from "@/services/db";
+
+export interface NsiteMetadata {
+ title?: string;
+ description?: string;
+ faviconUrl?: string;
+}
+
+// In-memory fast layer (populated from Dexie on first access)
+const memoryCache = new Map();
+
+// Track in-flight fetches to avoid duplicate requests
+const pendingFetches = new Map>();
+
+async function getCachedMetadata(hash: string): Promise {
+ // Memory first
+ const mem = memoryCache.get(hash);
+ if (mem) return mem;
+
+ // Then Dexie
+ try {
+ const cached = await db.nsiteMetadata.get(hash);
+ if (cached) {
+ const metadata: NsiteMetadata = {
+ title: cached.title,
+ description: cached.description,
+ faviconUrl: cached.faviconUrl,
+ };
+ memoryCache.set(hash, metadata);
+ return metadata;
+ }
+ } catch {
+ // Dexie error, continue to fetch
+ }
+
+ return null;
+}
+
+async function persistMetadata(
+ hash: string,
+ metadata: NsiteMetadata,
+): Promise {
+ memoryCache.set(hash, metadata);
+ try {
+ await db.nsiteMetadata.put({ hash, ...metadata });
+ } catch {
+ // Non-critical, memory cache is enough
+ }
+}
+
+async function fetchAndParseIndexHtml(
+ hash: string,
+ servers: string[],
+ baseUrl: string,
+ signal: AbortSignal,
+): Promise {
+ for (const server of servers) {
+ try {
+ const url = getBlobUrl(server, hash);
+ const response = await fetch(url, { signal });
+ if (!response.ok) continue;
+
+ const html = await response.text();
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, "text/html");
+
+ // Resolve favicon: try , then
+ let faviconUrl: string | undefined;
+ const iconLink =
+ doc.querySelector('link[rel="icon"]') ??
+ doc.querySelector('link[rel="shortcut icon"]');
+ const iconHref = iconLink?.getAttribute("href");
+ if (iconHref) {
+ if (iconHref.startsWith("data:")) {
+ faviconUrl = iconHref;
+ } else {
+ try {
+ faviconUrl = new URL(iconHref, baseUrl).href;
+ } catch {
+ // Invalid URL, skip
+ }
+ }
+ }
+
+ const metadata: NsiteMetadata = {
+ title: doc.querySelector("title")?.textContent?.trim() || undefined,
+ description:
+ doc
+ .querySelector('meta[name="description"]')
+ ?.getAttribute("content")
+ ?.trim() || undefined,
+ faviconUrl,
+ };
+
+ return metadata;
+ } catch {
+ if (signal.aborted) return null;
+ // Try next server
+ }
+ }
+ return null;
+}
+
+/**
+ * Hook that returns site metadata for an nsite manifest event.
+ *
+ * Strategy (progressive):
+ * 1. Use title/description tags from the event if present
+ * 2. Check in-memory cache, then Dexie (persistent across sessions)
+ * 3. Fetch index.html from Blossom, parse for title/description/favicon
+ * 4. Persist to both caches (keyed by sha256 hash — immutable, no TTL)
+ */
+export function useNsiteMetadata(event: NostrEvent): {
+ title?: string;
+ description?: string;
+ faviconUrl?: string;
+ loading: boolean;
+} {
+ const tagTitle = getNsiteTitle(event);
+ const tagDescription = getNsiteDescription(event);
+ const gatewayUrl = getNsiteGatewayUrl(event);
+
+ const [fetched, setFetched] = useState(() => {
+ // Sync init from memory cache
+ const indexHash = getNsiteIndexHash(event);
+ return indexHash ? (memoryCache.get(indexHash) ?? null) : null;
+ });
+ const [loading, setLoading] = useState(false);
+
+ const indexHash = getNsiteIndexHash(event);
+ const needsFetch = !!indexHash;
+
+ useEffect(() => {
+ if (!needsFetch || !indexHash) return;
+
+ // Already have it in memory
+ const mem = memoryCache.get(indexHash);
+ if (mem) {
+ setFetched(mem);
+ return;
+ }
+
+ const controller = new AbortController();
+ let cancelled = false;
+ setLoading(true);
+
+ (async () => {
+ // Check Dexie
+ const cached = await getCachedMetadata(indexHash);
+ if (cached) {
+ if (!cancelled) {
+ setFetched(cached);
+ setLoading(false);
+ }
+ return;
+ }
+
+ // Deduplicate in-flight fetches
+ let fetchPromise = pendingFetches.get(indexHash);
+ if (!fetchPromise) {
+ fetchPromise = (async () => {
+ const eventServers = getNsiteServers(event);
+ const authorServers =
+ (await blossomServerCache.getServers(event.pubkey)) ?? [];
+ const allServers = [...new Set([...eventServers, ...authorServers])];
+
+ if (allServers.length === 0) return null;
+
+ return fetchAndParseIndexHtml(
+ indexHash,
+ allServers,
+ gatewayUrl,
+ controller.signal,
+ );
+ })();
+ pendingFetches.set(indexHash, fetchPromise);
+ }
+
+ try {
+ const result = await fetchPromise;
+ pendingFetches.delete(indexHash);
+ if (cancelled) return;
+ if (result) {
+ await persistMetadata(indexHash, result);
+ setFetched(result);
+ }
+ } catch {
+ pendingFetches.delete(indexHash);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ controller.abort();
+ };
+ }, [needsFetch, indexHash, event, gatewayUrl]);
+
+ if (tagTitle) {
+ return {
+ title: tagTitle,
+ description: tagDescription,
+ faviconUrl: fetched?.faviconUrl,
+ loading: false,
+ };
+ }
+
+ return {
+ title: fetched?.title,
+ description: fetched?.description ?? tagDescription,
+ faviconUrl: fetched?.faviconUrl,
+ loading,
+ };
+}
diff --git a/src/lib/nip5a-helpers.ts b/src/lib/nip5a-helpers.ts
new file mode 100644
index 0000000..9ff0da5
--- /dev/null
+++ b/src/lib/nip5a-helpers.ts
@@ -0,0 +1,133 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
+import { nip19 } from "nostr-tools";
+
+/**
+ * NIP-5A Helper Functions
+ * Utility functions for parsing NIP-5A pubkey static website events
+ *
+ * All helper functions use applesauce's getOrComputeCachedValue to cache
+ * computed values on the event object itself. This means you don't need
+ * useMemo when calling these functions.
+ */
+
+export const DEFAULT_NSITE_GATEWAY = "nsite.lol";
+
+// Cache symbols
+const NsitePathsSymbol = Symbol("nsitePaths");
+const NsiteServersSymbol = Symbol("nsiteServers");
+const NsiteRelaysSymbol = Symbol("nsiteRelays");
+const NsiteGatewayUrlSymbol = Symbol("nsiteGatewayUrl");
+
+export interface NsitePath {
+ path: string;
+ hash: string;
+}
+
+/**
+ * Get the site title from a site manifest event
+ */
+export function getNsiteTitle(event: NostrEvent): string | undefined {
+ return getTagValue(event, "title");
+}
+
+/**
+ * Get the site description from a site manifest event
+ */
+export function getNsiteDescription(event: NostrEvent): string | undefined {
+ return getTagValue(event, "description");
+}
+
+/**
+ * Get the source code URL from a site manifest event
+ */
+export function getNsiteSource(event: NostrEvent): string | undefined {
+ return getTagValue(event, "source");
+}
+
+/**
+ * Get all path-to-hash mappings from a site manifest event
+ */
+export function getNsitePaths(event: NostrEvent): NsitePath[] {
+ return getOrComputeCachedValue(event, NsitePathsSymbol, () =>
+ event.tags
+ .filter((t) => t[0] === "path" && t[1] && t[2])
+ .map((t) => ({ path: t[1], hash: t[2] })),
+ );
+}
+
+/**
+ * Get all blossom server hints from a site manifest event
+ */
+export function getNsiteServers(event: NostrEvent): string[] {
+ return getOrComputeCachedValue(event, NsiteServersSymbol, () => [
+ ...new Set(
+ event.tags.filter((t) => t[0] === "server" && t[1]).map((t) => t[1]),
+ ),
+ ]);
+}
+
+/**
+ * Get relay hints from a site manifest event
+ */
+export function getNsiteRelays(event: NostrEvent): string[] {
+ return getOrComputeCachedValue(event, NsiteRelaysSymbol, () => [
+ ...new Set(
+ event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]),
+ ),
+ ]);
+}
+
+/**
+ * Get the sha256 hash for /index.html from the site manifest
+ */
+export function getNsiteIndexHash(event: NostrEvent): string | undefined {
+ const paths = getNsitePaths(event);
+ return paths.find((p) => p.path === "/index.html")?.hash;
+}
+
+/**
+ * Get the sha256 hash for /favicon.ico from the site manifest
+ */
+export function getNsiteFaviconHash(event: NostrEvent): string | undefined {
+ const paths = getNsitePaths(event);
+ return paths.find((p) => p.path === "/favicon.ico")?.hash;
+}
+
+/**
+ * Get the site identifier (d tag) for named sites (kind 35128)
+ */
+export function getNsiteIdentifier(event: NostrEvent): string | undefined {
+ return getTagValue(event, "d");
+}
+
+/**
+ * Convert a hex pubkey to base36 (50 chars, zero-padded)
+ * Used for named site subdomain construction per NIP-5A spec
+ */
+function hexToBase36(hex: string): string {
+ const num = BigInt("0x" + hex);
+ return num.toString(36).padStart(50, "0");
+}
+
+/**
+ * Get the gateway URL to view this site
+ * Root sites (15128): https://.nsite.lol
+ * Named sites (35128): https://.nsite.lol
+ */
+export function getNsiteGatewayUrl(
+ event: NostrEvent,
+ gateway: string = DEFAULT_NSITE_GATEWAY,
+): string {
+ return getOrComputeCachedValue(event, NsiteGatewayUrlSymbol, () => {
+ if (event.kind === 35128) {
+ const dTag = getNsiteIdentifier(event);
+ if (dTag) {
+ const pubkeyB36 = hexToBase36(event.pubkey);
+ return `https://${pubkeyB36}${dTag}.${gateway}`;
+ }
+ }
+ const npub = nip19.npubEncode(event.pubkey);
+ return `https://${npub}.${gateway}`;
+ });
+}
diff --git a/src/services/db.ts b/src/services/db.ts
index b84eeec..ab9d330 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -100,6 +100,13 @@ export interface LnurlCache {
fetchedAt: number; // Timestamp for cache invalidation
}
+export interface CachedNsiteMetadata {
+ hash: string; // Primary key - sha256 of index.html (content-addressable, never expires)
+ title?: string;
+ description?: string;
+ faviconUrl?: string;
+}
+
export interface GrimoireZap {
eventId: string; // Primary key - zap receipt event ID
senderPubkey: string; // Who sent the zap
@@ -120,6 +127,7 @@ class GrimoireDb extends Dexie {
spells!: Table;
spellbooks!: Table;
lnurlCache!: Table;
+ nsiteMetadata!: Table;
grimoireZaps!: Table;
constructor(name: string) {
@@ -366,6 +374,24 @@ class GrimoireDb extends Dexie {
grimoireZaps:
"&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]",
});
+
+ // Version 18: Add nsite metadata caching (NIP-5A)
+ this.version(18).stores({
+ profiles: "&pubkey",
+ nip05: "&nip05",
+ nips: "&id",
+ relayInfo: "&url",
+ relayAuthPreferences: "&url",
+ relayLists: "&pubkey, updatedAt",
+ relayLiveness: "&url",
+ blossomServers: "&pubkey, updatedAt",
+ spells: "&id, alias, createdAt, isPublished, deletedAt",
+ spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
+ lnurlCache: "&address, fetchedAt",
+ grimoireZaps:
+ "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]",
+ nsiteMetadata: "&hash",
+ });
}
}