feat: resolve live Nostr references in blog posts and add embeds
Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups, render Nostr embeds in markdown, and add a consistency check for events mirrors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { buildBlogMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildBlogMarkdown();
|
||||
|
||||
@@ -3,14 +3,26 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Heart, Send } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
|
||||
import {
|
||||
hasNostrExtension,
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
publishEvent,
|
||||
shortenPubkey,
|
||||
fetchNostrProfile,
|
||||
fetchLongformFromRelays,
|
||||
fetchEventFromRelays,
|
||||
resolveEventFromRelays,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import type { Components } from "react-markdown";
|
||||
import { markdownComponents } from "./markdownComponents";
|
||||
import { remarkNostr } from "./remarkNostr";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
@@ -34,84 +46,28 @@ interface NostrReply {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className} block`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
|
||||
),
|
||||
hr: () => <hr className="border-surface-container-high my-8" />,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-left text-on-surface-variant">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
|
||||
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
|
||||
function postFromEvent(event: any, slug: string): Post {
|
||||
const tag = (name: string): string | undefined =>
|
||||
event.tags?.find((t: string[]) => t[0] === name)?.[1];
|
||||
const publishedAtSec = Number(tag("published_at")) || event.created_at;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
slug,
|
||||
title: tag("title") || "Untitled",
|
||||
content: event.content || "",
|
||||
excerpt: tag("summary"),
|
||||
authorPubkey: event.pubkey,
|
||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||
nostrEventId: event.id,
|
||||
naddr: slug.startsWith("naddr") ? slug : undefined,
|
||||
categories: (event.tags || [])
|
||||
.filter((t: string[]) => t[0] === "t" && t[1])
|
||||
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
|
||||
};
|
||||
}
|
||||
|
||||
function ArticleSkeleton() {
|
||||
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
|
||||
@@ -137,6 +93,18 @@ function ArticleSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function RelayLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">Fetching from Nostr relays…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -150,6 +118,11 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||
const [contentError, setContentError] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
setHasNostr(hasNostrExtension());
|
||||
@@ -157,39 +130,70 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLiveContent(null);
|
||||
setPost(null);
|
||||
setAuthorProfile(null);
|
||||
setContentError(false);
|
||||
setResolvingFromRelays(false);
|
||||
|
||||
// Loads the author's profile, then hydrates the article body from relays
|
||||
// when only metadata is present (indexed posts store an empty body).
|
||||
const hydrate = (data: Post) => {
|
||||
if (cancelled) return;
|
||||
setPost(data);
|
||||
setLoading(false);
|
||||
if (data?.authorPubkey) {
|
||||
fetchNostrProfile(data.authorPubkey)
|
||||
.then((profile) => !cancelled && setAuthorProfile(profile))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
|
||||
setLoadingContent(true);
|
||||
setContentError(false);
|
||||
const fetchPromise = data.naddr
|
||||
? fetchLongformFromRelays(data.naddr)
|
||||
: fetchEventFromRelays(data.nostrEventId!);
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
if (event?.content) setLiveContent(event.content);
|
||||
else setContentError(true); // not found / timed out
|
||||
})
|
||||
.catch(() => !cancelled && setContentError(true))
|
||||
.finally(() => !cancelled && setLoadingContent(false));
|
||||
}
|
||||
};
|
||||
|
||||
api
|
||||
.getPost(slug)
|
||||
.then((data) => {
|
||||
setPost(data);
|
||||
setLoading(false);
|
||||
if (data?.authorPubkey) {
|
||||
fetchNostrProfile(data.authorPubkey)
|
||||
.then((profile) => setAuthorProfile(profile))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
|
||||
setLoadingContent(true);
|
||||
const fetchPromise = data.naddr
|
||||
? fetchLongformFromRelays(data.naddr)
|
||||
: fetchEventFromRelays(data.nostrEventId!);
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (event?.content) {
|
||||
setLiveContent(event.content);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingContent(false));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
.then((data) => hydrate(data))
|
||||
.catch(() => {
|
||||
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
|
||||
// the long-form note live from relays.
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
setResolvingFromRelays(true);
|
||||
resolveEventFromRelays(slug)
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
setResolvingFromRelays(false);
|
||||
if (event) hydrate(postFromEvent(event, slug));
|
||||
else setError("Post not found");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setResolvingFromRelays(false);
|
||||
setError(err?.message || "Post not found");
|
||||
});
|
||||
});
|
||||
}, [slug]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
@@ -271,9 +275,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
{loading && <ArticleSkeleton />}
|
||||
|
||||
{resolvingFromRelays && !post && <RelayLoading />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container/20 text-error rounded-xl p-6">
|
||||
Failed to load post: {error}
|
||||
<p className="mb-4">Failed to load post: {error}</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -328,19 +340,29 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
<article className="mb-16">
|
||||
{loadingContent ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 bg-surface-container-high rounded"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
))}
|
||||
<RelayLoading />
|
||||
) : contentError && !(post.content || liveContent) ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-on-surface-variant mb-4">
|
||||
Couldn't fetch this article from the Nostr relays. It may be
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkNostr]}
|
||||
components={markdownComponents}
|
||||
// Preserve nostr: links (react-markdown strips unknown
|
||||
// schemes by default), so mentions/notes resolve correctly.
|
||||
urlTransform={(url) =>
|
||||
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
|
||||
}
|
||||
>
|
||||
{post.content || liveContent || ""}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
loadNostrProfile,
|
||||
resolveEventFromRelays,
|
||||
shortenPubkey,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
|
||||
function njump(bech32: string): string {
|
||||
return `https://njump.me/${bech32}`;
|
||||
}
|
||||
|
||||
// Inline @mention chip: avatar + display name, resolved live from the author's
|
||||
// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads.
|
||||
export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
const pubkey = useMemo(() => {
|
||||
try {
|
||||
const d = nip19.decode(bech32);
|
||||
if (d.type === "npub") return d.data as string;
|
||||
if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey;
|
||||
} catch {}
|
||||
return null;
|
||||
}, [bech32]);
|
||||
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let cancelled = false;
|
||||
loadNostrProfile(pubkey)
|
||||
.then((p) => !cancelled && setProfile(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
// Show the username; fall back to a shortened npub only when no profile.
|
||||
const npub = useMemo(() => {
|
||||
if (bech32.startsWith("npub")) return bech32;
|
||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
||||
}, [bech32, pubkey]);
|
||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 align-middle text-primary hover:underline font-medium no-underline"
|
||||
>
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded-full object-cover bg-surface-container-high inline-block"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>@{name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact author line used inside an embedded note.
|
||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadNostrProfile(pubkey)
|
||||
.then((p) => !cancelled && setProfile(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
const fallback = useMemo(() => {
|
||||
try {
|
||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
||||
} catch {
|
||||
return shortenPubkey(pubkey);
|
||||
}
|
||||
}, [pubkey]);
|
||||
const name = profile?.name || profile?.displayName || fallback;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-on-surface text-sm">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Embedded referenced note (note/nevent/naddr). Renders as a bordered card.
|
||||
// Uses span/block elements (not <div>) so it stays valid when the reference
|
||||
// sits inside a markdown paragraph.
|
||||
export function NostrNote({ bech32 }: { bech32: string }) {
|
||||
const [event, setEvent] = useState<any>(null);
|
||||
const [state, setState] = useState<"loading" | "done" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState("loading");
|
||||
resolveEventFromRelays(bech32)
|
||||
.then((ev) => {
|
||||
if (cancelled) return;
|
||||
if (ev) {
|
||||
setEvent(ev);
|
||||
setState("done");
|
||||
} else {
|
||||
setState("error");
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setState("error"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bech32]);
|
||||
|
||||
return (
|
||||
<span className="block my-4 rounded-xl border border-surface-container-high bg-surface-container-low p-4">
|
||||
{state === "loading" && (
|
||||
<span className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||
<span className="inline-block w-4 h-4 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
|
||||
Loading note…
|
||||
</span>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm break-all"
|
||||
>
|
||||
View referenced note on njump.me
|
||||
</a>
|
||||
)}
|
||||
{state === "done" && event && (
|
||||
<span className="block">
|
||||
<span className="flex items-center justify-between mb-3">
|
||||
<NoteAuthor pubkey={event.pubkey} />
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-on-surface-variant/60 hover:text-primary"
|
||||
>
|
||||
View note
|
||||
</a>
|
||||
</span>
|
||||
<span className="block whitespace-pre-wrap break-words text-on-surface-variant text-sm leading-relaxed">
|
||||
{(event.content || "").slice(0, 1000)}
|
||||
{(event.content || "").length > 1000 ? "…" : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatches a `nostr:<bech32>` reference to the right embed by entity type.
|
||||
export function NostrEntity({ bech32 }: { bech32: string }) {
|
||||
let type: string;
|
||||
try {
|
||||
type = nip19.decode(bech32).type;
|
||||
} catch {
|
||||
return <>nostr:{bech32}</>;
|
||||
}
|
||||
if (type === "npub" || type === "nprofile") return <NostrMention bech32={bech32} />;
|
||||
if (type === "note" || type === "nevent" || type === "naddr") return <NostrNote bech32={bech32} />;
|
||||
return <>nostr:{bech32}</>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Components } from "react-markdown";
|
||||
import { NostrEntity } from "./NostrEmbeds";
|
||||
|
||||
// Shared markdown renderers for blog article bodies. The `remarkNostr` plugin
|
||||
// rewrites NIP-27 `nostr:` references into links with that scheme, which the
|
||||
// `a` renderer below swaps for live profile/note embeds.
|
||||
export const markdownComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("nostr:")) {
|
||||
return <NostrEntity bech32={href.slice("nostr:".length)} />;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className} block`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
|
||||
),
|
||||
hr: () => <hr className="border-surface-container-high my-8" />,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-left text-on-surface-variant">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
@@ -30,7 +30,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
post.excerpt ||
|
||||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
|
||||
const author = post.authorName || "Belgian Bitcoin Embassy";
|
||||
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
|
||||
// Prefer the post's own header image (Nostr `image` tag); fall back to a
|
||||
// generated OG card so every post still gets a real preview image.
|
||||
const ogImageUrl = post.image || `/og?title=${encodeURIComponent(post.title)}&type=blog`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Matches NIP-27 `nostr:` references (and bare bech32 entities) embedded in
|
||||
// article text: npub/nprofile (mentions) and note/nevent/naddr (notes).
|
||||
const NOSTR_RE =
|
||||
/(?:nostr:)?((?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]+)/gi;
|
||||
|
||||
function isValidEntity(bech32: string): boolean {
|
||||
try {
|
||||
const { type } = nip19.decode(bech32);
|
||||
return ["npub", "nprofile", "note", "nevent", "naddr"].includes(type);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Splits a plain-text value into text + `link` nodes, where each link's url is
|
||||
// `nostr:<bech32>`. The markdown `a` renderer detects that scheme and swaps in
|
||||
// the live profile/note component.
|
||||
function splitText(value: string): any[] {
|
||||
const out: any[] = [];
|
||||
let last = 0;
|
||||
NOSTR_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = NOSTR_RE.exec(value)) !== null) {
|
||||
const bech32 = m[1];
|
||||
if (!isValidEntity(bech32)) continue;
|
||||
if (m.index > last) {
|
||||
out.push({ type: "text", value: value.slice(last, m.index) });
|
||||
}
|
||||
out.push({
|
||||
type: "link",
|
||||
url: `nostr:${bech32}`,
|
||||
children: [{ type: "text", value: m[0] }],
|
||||
});
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (out.length === 0) return [{ type: "text", value }];
|
||||
if (last < value.length) out.push({ type: "text", value: value.slice(last) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remark plugin: walk the mdast tree and replace `nostr:` tokens inside text
|
||||
// nodes. Skips code and existing links so identifiers there are left untouched.
|
||||
export function remarkNostr() {
|
||||
return (tree: any) => {
|
||||
const walk = (node: any) => {
|
||||
if (!node || !Array.isArray(node.children)) return;
|
||||
const next: any[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === "text") {
|
||||
next.push(...splitText(child.value));
|
||||
continue;
|
||||
}
|
||||
if (child.type !== "link" && child.type !== "inlineCode" && child.type !== "code") {
|
||||
walk(child);
|
||||
}
|
||||
next.push(child);
|
||||
}
|
||||
node.children = next;
|
||||
};
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import { apiUrl } from "@/lib/api-base";
|
||||
|
||||
const LIMIT = 9;
|
||||
|
||||
// Render at request time so the first page of posts is in the HTML for crawlers.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
|
||||
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||
if (!res.ok) return fallback;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildCommunityMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildCommunityMarkdown();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildContactMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildContactMarkdown();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildEventsMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildEventsMarkdown();
|
||||
|
||||
@@ -2,39 +2,15 @@ import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData";
|
||||
|
||||
// Re-render at most every 5 minutes so the upcoming/past split stays current.
|
||||
export const revalidate = 300;
|
||||
|
||||
async function fetchMeetups(): Promise<any[]> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/meetups"), { next: { revalidate: 300 } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Render at request time from the live backend so crawlers (no JS) always get
|
||||
// the real list, and the count stays in lock-step with /events.md and /llms.txt.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage() {
|
||||
const meetups = await fetchMeetups();
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = meetups.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start >= now;
|
||||
});
|
||||
const past = meetups
|
||||
.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start < now;
|
||||
})
|
||||
.reverse();
|
||||
const meetups = await fetchMeetupsLive();
|
||||
const { upcoming, past } = partitionMeetups(meetups);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +30,11 @@ export default async function EventsPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-8 pb-24 space-y-20"
|
||||
data-upcoming-count={upcoming.length}
|
||||
data-past-count={past.length}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-black flex items-center gap-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildFaqMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildFaqMarkdown();
|
||||
|
||||
@@ -12,11 +12,13 @@ interface FaqItem {
|
||||
showOnHomepage: boolean;
|
||||
}
|
||||
|
||||
// Render at request time from the live backend so the Q&A and FAQPage JSON-LD
|
||||
// are always present in the HTML for crawlers, never a build-time-empty shell.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function fetchFaqs(): Promise<FaqItem[]> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/faqs?all=true"), {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildHomeMarkdown } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildHomeMarkdown();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildLlmsTxt } from "@/lib/llms";
|
||||
|
||||
export const revalidate = 3600;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildLlmsTxt();
|
||||
|
||||
Reference in New Issue
Block a user