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:
+73
-10
@@ -35,6 +35,55 @@ async function canViewHiddenPosts(req: Request): Promise<boolean> {
|
|||||||
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
|
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves a blog slug to the Nostr event id used for reactions/replies,
|
||||||
|
// whether it's an indexed post or a live NIP-19 reference. Returns null if
|
||||||
|
// neither resolves.
|
||||||
|
async function resolveEventIdForSlug(slug: string): Promise<string | null> {
|
||||||
|
const post = await prisma.post.findUnique({ where: { slug } });
|
||||||
|
if (post) return post.nostrEventId;
|
||||||
|
const event = await nostrService.resolveEventByIdentifier(slug);
|
||||||
|
return event?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagValue(event: { tags?: string[][] }, name: string): string | undefined {
|
||||||
|
return event.tags?.find((t) => t[0] === name)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shapes a live Nostr longform event into the same JSON the frontend expects
|
||||||
|
// from an indexed Post, so naddr/nevent/note links render through the regular
|
||||||
|
// post template instead of 404ing. `identifier` is the original URL segment and
|
||||||
|
// becomes the slug so canonical URLs and reaction/reply lookups stay stable.
|
||||||
|
function buildPostShapeFromEvent(
|
||||||
|
event: { id: string; pubkey: string; content: string; created_at: number; tags?: string[][] },
|
||||||
|
identifier: string
|
||||||
|
) {
|
||||||
|
const title = tagValue(event, 'title') || 'Untitled';
|
||||||
|
const summary = tagValue(event, 'summary') || null;
|
||||||
|
const image = tagValue(event, 'image') || null;
|
||||||
|
const publishedAtSec = Number(tagValue(event, 'published_at')) || event.created_at;
|
||||||
|
const categories = (event.tags || [])
|
||||||
|
.filter((t) => t[0] === 't' && t[1])
|
||||||
|
.map((t) => ({ category: { id: t[1], name: t[1], slug: t[1] } }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
nostrEventId: event.id,
|
||||||
|
naddr: identifier.startsWith('naddr') ? identifier : null,
|
||||||
|
title,
|
||||||
|
slug: identifier,
|
||||||
|
content: event.content || '',
|
||||||
|
excerpt: summary,
|
||||||
|
image,
|
||||||
|
authorPubkey: event.pubkey,
|
||||||
|
authorName: null,
|
||||||
|
featured: false,
|
||||||
|
visible: true,
|
||||||
|
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||||
|
createdAt: new Date(event.created_at * 1000).toISOString(),
|
||||||
|
categories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
router.get('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
@@ -78,19 +127,31 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.get('/:slug', async (req: Request, res: Response) => {
|
router.get('/:slug', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
const slug = req.params.slug as string;
|
||||||
const post = await prisma.post.findUnique({
|
const post = await prisma.post.findUnique({
|
||||||
where: { slug: req.params.slug as string },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
categories: { include: { category: true } },
|
categories: { include: { category: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!post) {
|
if (post) {
|
||||||
res.status(404).json({ error: 'Post not found' });
|
// Indexed posts store an empty body (the canonical copy lives on Nostr);
|
||||||
|
// the frontend hydrates the body live from relays. Return as-is.
|
||||||
|
res.json(post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(post);
|
// Not indexed: resolve the slug as a live NIP-19 reference so the page has
|
||||||
|
// real metadata (title, summary, image) for SEO instead of "Post Not
|
||||||
|
// Found". The frontend still fetches the body from relays for display.
|
||||||
|
const event = await nostrService.resolveEventByIdentifier(slug);
|
||||||
|
if (event) {
|
||||||
|
res.json(buildPostShapeFromEvent(event, slug));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(404).json({ error: 'Post not found' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get post error:', err);
|
console.error('Get post error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
@@ -181,13 +242,14 @@ router.patch(
|
|||||||
|
|
||||||
router.get('/:slug/reactions', async (req: Request, res: Response) => {
|
router.get('/:slug/reactions', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
|
const slug = req.params.slug as string;
|
||||||
if (!post) {
|
const eventId = await resolveEventIdForSlug(slug);
|
||||||
|
if (!eventId) {
|
||||||
res.status(404).json({ error: 'Post not found' });
|
res.status(404).json({ error: 'Post not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reactions = await nostrService.fetchReactions(post.nostrEventId);
|
const reactions = await nostrService.fetchReactions(eventId);
|
||||||
res.json({ count: reactions.length, reactions });
|
res.json({ count: reactions.length, reactions });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Get reactions error:', err);
|
console.error('Get reactions error:', err);
|
||||||
@@ -197,14 +259,15 @@ router.get('/:slug/reactions', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
router.get('/:slug/replies', async (req: Request, res: Response) => {
|
router.get('/:slug/replies', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
|
const slug = req.params.slug as string;
|
||||||
if (!post) {
|
const eventId = await resolveEventIdForSlug(slug);
|
||||||
|
if (!eventId) {
|
||||||
res.status(404).json({ error: 'Post not found' });
|
res.status(404).json({ error: 'Post not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
|
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
|
||||||
nostrService.fetchReplies(post.nostrEventId),
|
nostrService.fetchReplies(eventId),
|
||||||
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
|
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
|
||||||
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
|
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -118,6 +118,32 @@ export const nostrService = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache-first: addressable events are keyed by kind+pubkey+d-tag, not by a
|
||||||
|
// stable event id, so look them up among cached events of the same author.
|
||||||
|
// Avoids re-querying relays on every repeat visit to the same naddr.
|
||||||
|
const cached = await prisma.nostrEventCache.findMany({
|
||||||
|
where: { kind: decoded.kind, pubkey: decoded.pubkey },
|
||||||
|
});
|
||||||
|
for (const c of cached) {
|
||||||
|
let tags: string[][] = [];
|
||||||
|
try {
|
||||||
|
tags = JSON.parse(c.tags);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dTag = tags.find((t) => t[0] === 'd');
|
||||||
|
if (dTag?.[1] === decoded.identifier) {
|
||||||
|
return {
|
||||||
|
id: c.eventId,
|
||||||
|
kind: c.kind,
|
||||||
|
pubkey: c.pubkey,
|
||||||
|
content: c.content,
|
||||||
|
tags,
|
||||||
|
created_at: c.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const siteRelays = await getRelayUrls();
|
const siteRelays = await getRelayUrls();
|
||||||
const naddrRelays = decoded.relays || [];
|
const naddrRelays = decoded.relays || [];
|
||||||
const filter = {
|
const filter = {
|
||||||
@@ -158,6 +184,35 @@ export const nostrService = {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
||||||
|
// event id to the underlying Nostr event, reusing the cache-aware fetchers.
|
||||||
|
// Returns null if the string can't be decoded or the event isn't found.
|
||||||
|
async resolveEventByIdentifier(identifier: string) {
|
||||||
|
const trimmed = identifier.trim();
|
||||||
|
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||||
|
return nostrService.fetchEvent(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = nip19.decode(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'naddr':
|
||||||
|
return nostrService.fetchLongformEvent(trimmed);
|
||||||
|
case 'nevent':
|
||||||
|
return nostrService.fetchEvent((decoded.data as nip19.EventPointer).id);
|
||||||
|
case 'note':
|
||||||
|
return nostrService.fetchEvent(decoded.data as string);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async fetchReactions(eventId: string) {
|
async fetchReactions(eventId: string) {
|
||||||
const relays = await getRelayUrls();
|
const relays = await getRelayUrls();
|
||||||
if (relays.length === 0) return [];
|
if (relays.length === 0) return [];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildBlogMarkdown } from "@/lib/llms";
|
import { buildBlogMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildBlogMarkdown();
|
const body = await buildBlogMarkdown();
|
||||||
|
|||||||
@@ -3,14 +3,26 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft, Heart, Send } from "lucide-react";
|
import { ArrowLeft, Heart, Send } from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/utils";
|
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 { Navbar } from "@/components/public/Navbar";
|
||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
import type { Components } from "react-markdown";
|
import { markdownComponents } from "./markdownComponents";
|
||||||
|
import { remarkNostr } from "./remarkNostr";
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,84 +46,28 @@ interface NostrReply {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdownComponents: Components = {
|
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
|
||||||
h1: ({ children }) => (
|
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
|
||||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
function postFromEvent(event: any, slug: string): Post {
|
||||||
),
|
const tag = (name: string): string | undefined =>
|
||||||
h2: ({ children }) => (
|
event.tags?.find((t: string[]) => t[0] === name)?.[1];
|
||||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
const publishedAtSec = Number(tag("published_at")) || event.created_at;
|
||||||
),
|
|
||||||
h3: ({ children }) => (
|
return {
|
||||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
id: event.id,
|
||||||
),
|
slug,
|
||||||
h4: ({ children }) => (
|
title: tag("title") || "Untitled",
|
||||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
content: event.content || "",
|
||||||
),
|
excerpt: tag("summary"),
|
||||||
p: ({ children }) => (
|
authorPubkey: event.pubkey,
|
||||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||||
),
|
nostrEventId: event.id,
|
||||||
a: ({ href, children }) => (
|
naddr: slug.startsWith("naddr") ? slug : undefined,
|
||||||
<a
|
categories: (event.tags || [])
|
||||||
href={href}
|
.filter((t: string[]) => t[0] === "t" && t[1])
|
||||||
className="text-primary hover:underline"
|
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
|
||||||
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>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
function ArticleSkeleton() {
|
function ArticleSkeleton() {
|
||||||
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
|
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 }) {
|
export default function BlogPostClient({ slug }: { slug: string }) {
|
||||||
const [post, setPost] = useState<Post | null>(null);
|
const [post, setPost] = useState<Post | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -150,6 +118,11 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||||
const [loadingContent, setLoadingContent] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setHasNostr(hasNostrExtension());
|
setHasNostr(hasNostrExtension());
|
||||||
@@ -157,39 +130,70 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLiveContent(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
|
api
|
||||||
.getPost(slug)
|
.getPost(slug)
|
||||||
.then((data) => {
|
.then((data) => hydrate(data))
|
||||||
setPost(data);
|
.catch(() => {
|
||||||
setLoading(false);
|
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
|
||||||
if (data?.authorPubkey) {
|
// the long-form note live from relays.
|
||||||
fetchNostrProfile(data.authorPubkey)
|
if (cancelled) return;
|
||||||
.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);
|
|
||||||
setLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
@@ -271,9 +275,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
{loading && <ArticleSkeleton />}
|
{loading && <ArticleSkeleton />}
|
||||||
|
|
||||||
|
{resolvingFromRelays && !post && <RelayLoading />}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-error-container/20 text-error rounded-xl p-6">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -328,19 +340,29 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
|||||||
|
|
||||||
<article className="mb-16">
|
<article className="mb-16">
|
||||||
{loadingContent ? (
|
{loadingContent ? (
|
||||||
<div className="animate-pulse space-y-4">
|
<RelayLoading />
|
||||||
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
|
) : contentError && !(post.content || liveContent) ? (
|
||||||
<div
|
<div className="text-center py-12">
|
||||||
key={i}
|
<p className="text-on-surface-variant mb-4">
|
||||||
className="h-4 bg-surface-container-high rounded"
|
Couldn't fetch this article from the Nostr relays. It may be
|
||||||
style={{ width: `${w}%` }}
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm, remarkNostr]}
|
||||||
components={markdownComponents}
|
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 || ""}
|
{post.content || liveContent || ""}
|
||||||
</ReactMarkdown>
|
</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 ||
|
post.excerpt ||
|
||||||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
|
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
|
||||||
const author = post.authorName || "Belgian Bitcoin Embassy";
|
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 {
|
return {
|
||||||
title: post.title,
|
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;
|
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> {
|
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
|
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||||
if (!res.ok) return fallback;
|
if (!res.ok) return fallback;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildCommunityMarkdown } from "@/lib/llms";
|
import { buildCommunityMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildCommunityMarkdown();
|
const body = await buildCommunityMarkdown();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildContactMarkdown } from "@/lib/llms";
|
import { buildContactMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildContactMarkdown();
|
const body = await buildContactMarkdown();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildEventsMarkdown } from "@/lib/llms";
|
import { buildEventsMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildEventsMarkdown();
|
const body = await buildEventsMarkdown();
|
||||||
|
|||||||
@@ -2,39 +2,15 @@ import { Navbar } from "@/components/public/Navbar";
|
|||||||
import { Footer } from "@/components/public/Footer";
|
import { Footer } from "@/components/public/Footer";
|
||||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData";
|
||||||
import { apiUrl } from "@/lib/api-base";
|
|
||||||
|
|
||||||
// Re-render at most every 5 minutes so the upcoming/past split stays current.
|
// Render at request time from the live backend so crawlers (no JS) always get
|
||||||
export const revalidate = 300;
|
// the real list, and the count stays in lock-step with /events.md and /llms.txt.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function EventsPage() {
|
export default async function EventsPage() {
|
||||||
const meetups = await fetchMeetups();
|
const meetups = await fetchMeetupsLive();
|
||||||
|
const { upcoming, past } = partitionMeetups(meetups);
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -54,7 +30,11 @@ export default async function EventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<h2 className="text-xl font-black flex items-center gap-3">
|
<h2 className="text-xl font-black flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildFaqMarkdown } from "@/lib/llms";
|
import { buildFaqMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildFaqMarkdown();
|
const body = await buildFaqMarkdown();
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ interface FaqItem {
|
|||||||
showOnHomepage: boolean;
|
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[]> {
|
async function fetchFaqs(): Promise<FaqItem[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl("/faqs?all=true"), {
|
const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" });
|
||||||
next: { revalidate: 300 },
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Array.isArray(data) ? data : [];
|
return Array.isArray(data) ? data : [];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildHomeMarkdown } from "@/lib/llms";
|
import { buildHomeMarkdown } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildHomeMarkdown();
|
const body = await buildHomeMarkdown();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { buildLlmsTxt } from "@/lib/llms";
|
import { buildLlmsTxt } from "@/lib/llms";
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const body = await buildLlmsTxt();
|
const body = await buildLlmsTxt();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Lightweight TTL cache backed by localStorage so resolved Nostr data (author
|
||||||
|
// profiles, long-form events) survives page reloads and navigation instead of
|
||||||
|
// being re-queried from relays every visit. No-ops on the server and degrades
|
||||||
|
// silently on quota/parse errors so it can never break rendering.
|
||||||
|
|
||||||
|
const PREFIX = "bbe:nostr:";
|
||||||
|
|
||||||
|
interface Entry<T> {
|
||||||
|
v: T;
|
||||||
|
t: number; // stored-at epoch ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCache<T>(key: string, ttlMs: number): T | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PREFIX + key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw) as Entry<T>;
|
||||||
|
if (Date.now() - entry.t > ttlMs) {
|
||||||
|
window.localStorage.removeItem(PREFIX + key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.v;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCache<T>(key: string, value: T): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const payload = JSON.stringify({ v: value, t: Date.now() } satisfies Entry<T>);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PREFIX + key, payload);
|
||||||
|
} catch {
|
||||||
|
// Quota exceeded (or similar): evict our namespace and retry once.
|
||||||
|
try {
|
||||||
|
pruneNamespace();
|
||||||
|
window.localStorage.setItem(PREFIX + key, payload);
|
||||||
|
} catch {
|
||||||
|
// Give up silently — caching is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes every entry written by this cache to recover space when localStorage
|
||||||
|
// is full. Only touches our own namespace.
|
||||||
|
function pruneNamespace(): void {
|
||||||
|
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const k = window.localStorage.key(i);
|
||||||
|
if (k && k.startsWith(PREFIX)) window.localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-36
@@ -7,14 +7,17 @@
|
|||||||
* reading these needs the actual content, not repeated chrome.
|
* reading these needs the actual content, not repeated chrome.
|
||||||
*/
|
*/
|
||||||
import { apiUrl } from "./api-base";
|
import { apiUrl } from "./api-base";
|
||||||
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "./meetupEventTime";
|
import { formatMeetupCivilDateLong } from "./meetupEventTime";
|
||||||
|
import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData";
|
||||||
|
|
||||||
export const SITE_URL =
|
export const SITE_URL =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||||
|
|
||||||
|
// Live (uncached) so the mirrors always reflect the same backend state as the
|
||||||
|
// rendered pages — no ISR drift between /events, /events.md, and /llms.txt.
|
||||||
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
|
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||||
if (!res.ok) return fallback;
|
if (!res.ok) return fallback;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -85,14 +88,10 @@ function configuredChannels(settings: Record<string, string>) {
|
|||||||
export async function buildLlmsTxt(): Promise<string> {
|
export async function buildLlmsTxt(): Promise<string> {
|
||||||
const [settings, meetups] = await Promise.all([
|
const [settings, meetups] = await Promise.all([
|
||||||
fetchJson<Record<string, string>>("/settings/public", {}),
|
fetchJson<Record<string, string>>("/settings/public", {}),
|
||||||
fetchJson<any[]>("/meetups", []),
|
fetchMeetupsLive(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const now = new Date();
|
const upcomingCount = countUpcoming(meetups);
|
||||||
const upcomingCount = (Array.isArray(meetups) ? meetups : []).filter((m) => {
|
|
||||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
||||||
return !Number.isNaN(start.getTime()) && start >= now;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const channels = configuredChannels(settings);
|
const channels = configuredChannels(settings);
|
||||||
const channelNames = channels.map((c) => c.name).join(", ");
|
const channelNames = channels.map((c) => c.name).join(", ");
|
||||||
@@ -101,12 +100,14 @@ export async function buildLlmsTxt(): Promise<string> {
|
|||||||
? `How to connect on ${channelNames}`
|
? `How to connect on ${channelNames}`
|
||||||
: "How to connect with the community";
|
: "How to connect with the community";
|
||||||
|
|
||||||
|
// Always state the count (including zero) so it stays machine-parseable and
|
||||||
|
// verifiably consistent with /events and /events.md.
|
||||||
const upcomingLine =
|
const upcomingLine =
|
||||||
upcomingCount > 0
|
upcomingCount > 0
|
||||||
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
|
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
|
||||||
upcomingCount === 1 ? "" : "s"
|
upcomingCount === 1 ? "" : "s"
|
||||||
} scheduled.`
|
} scheduled.`
|
||||||
: "Meetups run monthly; check the events page for the next date.";
|
: "There are currently 0 upcoming meetups scheduled; the community meets monthly.";
|
||||||
|
|
||||||
return `# Belgian Bitcoin Embassy
|
return `# Belgian Bitcoin Embassy
|
||||||
|
|
||||||
@@ -135,19 +136,8 @@ Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function buildHomeMarkdown(): Promise<string> {
|
export async function buildHomeMarkdown(): Promise<string> {
|
||||||
const meetups = await fetchJson<any[]>("/meetups", []);
|
const meetups = await fetchMeetupsLive();
|
||||||
const now = new Date();
|
const next = partitionMeetups(meetups).upcoming[0];
|
||||||
const next = (Array.isArray(meetups) ? meetups : [])
|
|
||||||
.filter((m) => {
|
|
||||||
if (m.status && m.status !== "PUBLISHED") return false;
|
|
||||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
||||||
return !Number.isNaN(start.getTime()) && start >= now;
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
|
|
||||||
getMeetupStartUtc(b.date, b.time || "00:00").getTime(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
let nextSection = "";
|
let nextSection = "";
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -182,20 +172,8 @@ We help people in Belgium understand and adopt Bitcoin through education, meetup
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildEventsMarkdown(): Promise<string> {
|
export async function buildEventsMarkdown(): Promise<string> {
|
||||||
const meetups = await fetchJson<any[]>("/meetups", []);
|
const meetups = await fetchMeetupsLive();
|
||||||
const list = Array.isArray(meetups) ? meetups : [];
|
const { upcoming, past } = partitionMeetups(meetups);
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const upcoming = list.filter((m) => {
|
|
||||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
||||||
return !Number.isNaN(start.getTime()) && start >= now;
|
|
||||||
});
|
|
||||||
const past = list
|
|
||||||
.filter((m) => {
|
|
||||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
||||||
return !Number.isNaN(start.getTime()) && start < now;
|
|
||||||
})
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
const renderMeetup = (m: any): string => {
|
const renderMeetup = (m: any): string => {
|
||||||
const when = formatMeetupCivilDateLong(m.date);
|
const when = formatMeetupCivilDateLong(m.date);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for meetup data used by the public /events page, the
|
||||||
|
* /events.md mirror, and the /llms.txt summary line. Centralizing the fetch +
|
||||||
|
* upcoming/past partition guarantees those three can never disagree on the count.
|
||||||
|
*
|
||||||
|
* Fetched with `no-store` so every render reflects the live backend — this is
|
||||||
|
* what keeps crawlers (which don't run JS) and llms.txt consumers from seeing a
|
||||||
|
* stale or build-time-empty list.
|
||||||
|
*/
|
||||||
|
import { apiUrl } from "./api-base";
|
||||||
|
import { getMeetupStartUtc } from "./meetupEventTime";
|
||||||
|
|
||||||
|
export interface Meetup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
time?: string;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
organizer?: { name?: string; slug?: string } | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch all publicly-visible meetups from the backend, live (uncached). */
|
||||||
|
export async function fetchMeetupsLive(): Promise<Meetup[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl("/meetups"), { cache: "no-store" });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return Array.isArray(data) ? (data as Meetup[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartitionedMeetups {
|
||||||
|
upcoming: Meetup[];
|
||||||
|
past: Meetup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split meetups into upcoming (start >= now, soonest first) and past (start <
|
||||||
|
* now, most recent first). Meetups with an unparseable date are dropped.
|
||||||
|
*/
|
||||||
|
export function partitionMeetups(
|
||||||
|
meetups: Meetup[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): PartitionedMeetups {
|
||||||
|
const upcoming: Meetup[] = [];
|
||||||
|
const past: Meetup[] = [];
|
||||||
|
|
||||||
|
for (const m of meetups) {
|
||||||
|
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||||
|
if (Number.isNaN(start.getTime())) continue;
|
||||||
|
if (start >= now) upcoming.push(m);
|
||||||
|
else past.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = (m: Meetup) =>
|
||||||
|
getMeetupStartUtc(m.date, m.time || "00:00").getTime();
|
||||||
|
upcoming.sort((a, b) => startMs(a) - startMs(b));
|
||||||
|
past.sort((a, b) => startMs(b) - startMs(a));
|
||||||
|
|
||||||
|
return { upcoming, past };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of upcoming meetups — the single value llms.txt and events.md share. */
|
||||||
|
export function countUpcoming(meetups: Meetup[], now: Date = new Date()): number {
|
||||||
|
return partitionMeetups(meetups, now).upcoming.length;
|
||||||
|
}
|
||||||
+192
-57
@@ -1,5 +1,14 @@
|
|||||||
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { readCache, writeCache } from "./browserCache";
|
||||||
|
|
||||||
|
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
|
||||||
|
// these survive reloads, where the whole point is to avoid re-querying relays.
|
||||||
|
// Profiles and immutable events change rarely; addressable (replaceable)
|
||||||
|
// long-form events use a shorter window so edits are picked up reasonably soon.
|
||||||
|
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
|
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
|
||||||
|
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
|
||||||
|
|
||||||
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
|
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
|
||||||
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
|
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
|
||||||
@@ -87,6 +96,72 @@ const FALLBACK_RELAYS = [
|
|||||||
"wss://relay.nostr.band",
|
"wss://relay.nostr.band",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
|
||||||
|
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
|
||||||
|
// content and must never be connected to. Relay hints reach us from untrusted
|
||||||
|
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
|
||||||
|
// at every connection point.
|
||||||
|
export function isSecureRelay(url: unknown): url is string {
|
||||||
|
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSecureRelays(urls: Iterable<string>): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const url of urls) {
|
||||||
|
if (isSecureRelay(url)) out.push(url.trim());
|
||||||
|
}
|
||||||
|
return [...new Set(out)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// How long a single relay query may run before we give up on it. Relays go
|
||||||
|
// dead or stall mid-subscription without ever sending EOSE, which is what makes
|
||||||
|
// blog loads occasionally hang; bounding every query keeps the page responsive.
|
||||||
|
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
|
||||||
|
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
|
||||||
|
|
||||||
|
// A single shared read pool for the whole app. Reusing one pool keeps relay
|
||||||
|
// websockets warm and—crucially—avoids opening several simultaneous
|
||||||
|
// connections to the same relays (author profile + article body + NIP-65 all at
|
||||||
|
// once on a blog open), which relays throttle/drop and which caused the
|
||||||
|
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
|
||||||
|
// manages and reuses its own connections for the SPA's lifetime.
|
||||||
|
let _readPool: any = null;
|
||||||
|
async function getReadPool(): Promise<any> {
|
||||||
|
if (!_readPool) {
|
||||||
|
const { SimplePool } = await import("nostr-tools/pool");
|
||||||
|
_readPool = new SimplePool();
|
||||||
|
}
|
||||||
|
return _readPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
|
||||||
|
// hard ceiling around relay queries so a connection that never opens (or never
|
||||||
|
// closes) can't block rendering.
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(fallback), ms);
|
||||||
|
promise.then(
|
||||||
|
(v) => { clearTimeout(timer); resolve(v); },
|
||||||
|
() => { clearTimeout(timer); resolve(fallback); },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single bounded query across `relays` (which are dialed in parallel by the
|
||||||
|
// pool). Returns null on timeout, empty relay set, or error.
|
||||||
|
async function getEventBounded(
|
||||||
|
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
|
||||||
|
relays: string[],
|
||||||
|
filter: any,
|
||||||
|
): Promise<any | null> {
|
||||||
|
if (relays.length === 0) return null;
|
||||||
|
return withTimeout(
|
||||||
|
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
|
||||||
|
RELAY_HARD_TIMEOUT,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
||||||
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@@ -98,9 +173,10 @@ export async function getSiteRelays(): Promise<string[]> {
|
|||||||
const res = await fetch("/api/relays/public");
|
const res = await fetch("/api/relays/public");
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data.relays) && data.relays.length > 0) {
|
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
|
||||||
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
|
if (secure.length > 0) {
|
||||||
return data.relays;
|
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||||
|
return secure;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return FALLBACK_RELAYS;
|
return FALLBACK_RELAYS;
|
||||||
@@ -119,12 +195,11 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
|||||||
const cached = _nip65Cache.get(pubkey);
|
const cached = _nip65Cache.get(pubkey);
|
||||||
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
||||||
|
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
|
||||||
const siteRelays = await getSiteRelays();
|
const siteRelays = await getSiteRelays();
|
||||||
const pool = new SimplePool();
|
const pool = await getReadPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await pool.get(siteRelays, {
|
const event = await getEventBounded(pool, siteRelays, {
|
||||||
kinds: [10002],
|
kinds: [10002],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
});
|
});
|
||||||
@@ -138,6 +213,7 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
|||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
if (tag[0] !== "r" || !tag[1]) continue;
|
if (tag[0] !== "r" || !tag[1]) continue;
|
||||||
const url = tag[1];
|
const url = tag[1];
|
||||||
|
if (!isSecureRelay(url)) continue;
|
||||||
const marker = tag[2];
|
const marker = tag[2];
|
||||||
result.all.push(url);
|
result.all.push(url);
|
||||||
if (marker === "write") {
|
if (marker === "write") {
|
||||||
@@ -154,8 +230,6 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
|||||||
return result;
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return { write: [], read: [], all: [] };
|
return { write: [], read: [], all: [] };
|
||||||
} finally {
|
|
||||||
pool.close(siteRelays);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +249,9 @@ export async function getUserStoredRelays(): Promise<{ url: string; read: boolea
|
|||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
|
const secure = data.filter((r) => isSecureRelay(r?.url));
|
||||||
return data;
|
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||||
|
return secure;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return [];
|
return [];
|
||||||
@@ -217,7 +292,7 @@ export async function publishEvent(signedEvent: any): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const relayUrls = [...allRelays];
|
const relayUrls = filterSecureRelays(allRelays);
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
||||||
@@ -233,30 +308,40 @@ export async function fetchNostrProfile(
|
|||||||
const hex = toHexPubkey(pubkey);
|
const hex = toHexPubkey(pubkey);
|
||||||
if (!hex) return {};
|
if (!hex) return {};
|
||||||
|
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
const cached = readProfileCache(hex, Date.now());
|
||||||
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
|
if (cached) return cached;
|
||||||
PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url));
|
|
||||||
|
|
||||||
|
const filter = { kinds: [0], authors: [hex] };
|
||||||
|
|
||||||
|
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
|
||||||
|
const nip65Promise = fetchNip65RelayList(hex).catch(
|
||||||
|
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pool = await getReadPool();
|
||||||
|
const tried = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const nip65 = await fetchNip65RelayList(hex);
|
// Phase 1: provided relays (or site relays) + profile aggregators.
|
||||||
nip65.write.forEach((url) => allRelays.add(url));
|
const phase1 = filterSecureRelays([
|
||||||
} catch {}
|
...(relayUrls || await getSiteRelays()),
|
||||||
|
...PROFILE_METADATA_RELAYS,
|
||||||
|
]);
|
||||||
|
phase1.forEach((u) => tried.add(u));
|
||||||
|
let event = await getEventBounded(pool, phase1, filter);
|
||||||
|
|
||||||
const urls = [...allRelays];
|
// Phase 2: author's NIP-65 write relays if not found yet.
|
||||||
const pool = new SimplePool();
|
if (!event?.content) {
|
||||||
|
const nip65 = await nip65Promise;
|
||||||
try {
|
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||||
const event = await pool.get(urls, {
|
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||||
kinds: [0],
|
}
|
||||||
authors: [hex],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!event?.content) return {};
|
if (!event?.content) return {};
|
||||||
return parseProfileContent(event.content);
|
const profile = parseProfileContent(event.content);
|
||||||
|
writeProfileCache(hex, profile, Date.now());
|
||||||
|
return profile;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
} finally {
|
|
||||||
pool.close(urls);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +366,25 @@ function isProfileEmpty(p: NostrProfile): boolean {
|
|||||||
|
|
||||||
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
|
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
|
||||||
const cached = _profileCache.get(pubkey);
|
const cached = _profileCache.get(pubkey);
|
||||||
if (!cached) return null;
|
if (cached) {
|
||||||
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
||||||
return now - cached.fetchedAt < ttl ? cached.profile : null;
|
if (now - cached.fetchedAt < ttl) return cached.profile;
|
||||||
|
}
|
||||||
|
// Fall back to the persistent browser cache (survives reloads/navigation).
|
||||||
|
// Only resolved profiles are persisted, so a hit here is always non-empty.
|
||||||
|
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
|
||||||
|
if (persisted) {
|
||||||
|
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
|
||||||
|
return persisted;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
|
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
|
||||||
_profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) });
|
const empty = isProfileEmpty(profile);
|
||||||
|
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
|
||||||
|
// Persist resolved profiles only; skip empties so a reload can retry the miss.
|
||||||
|
if (!empty) writeCache(`profile:${pubkey}`, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
|
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
|
||||||
@@ -321,10 +418,9 @@ export async function fetchNostrProfiles(
|
|||||||
|
|
||||||
if (hexByKey.size === 0) return result;
|
if (hexByKey.size === 0) return result;
|
||||||
|
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
|
||||||
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
|
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
|
||||||
const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])];
|
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
|
||||||
const pool = new SimplePool();
|
const pool = await getReadPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const authors = [...new Set(hexByKey.values())];
|
const authors = [...new Set(hexByKey.values())];
|
||||||
@@ -363,8 +459,6 @@ export async function fetchNostrProfiles(
|
|||||||
if (!(key in result)) result[key] = {};
|
if (!(key in result)) result[key] = {};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
|
||||||
pool.close(urls);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,22 +511,27 @@ export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
const cacheKey = `event:${eventId}`;
|
||||||
|
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const siteRelays = await getSiteRelays();
|
const siteRelays = await getSiteRelays();
|
||||||
const pool = new SimplePool();
|
const pool = await getReadPool();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await pool.get(siteRelays, { ids: [eventId] });
|
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
|
||||||
|
if (event) writeCache(cacheKey, event);
|
||||||
return event || null;
|
return event || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
pool.close(siteRelays);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
||||||
const { SimplePool } = await import("nostr-tools/pool");
|
const cacheKey = `longform:${naddrStr}`;
|
||||||
|
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const { nip19 } = await import("nostr-tools");
|
const { nip19 } = await import("nostr-tools");
|
||||||
|
|
||||||
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
||||||
@@ -445,29 +544,65 @@ export async function fetchLongformFromRelays(naddrStr: string): Promise<any | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const siteRelays = await getSiteRelays();
|
const siteRelays = await getSiteRelays();
|
||||||
const naddrRelays = decoded.relays || [];
|
const naddrRelays = filterSecureRelays(decoded.relays || []);
|
||||||
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nip65 = await fetchNip65RelayList(decoded.pubkey);
|
|
||||||
nip65.write.forEach((url) => allRelays.add(url));
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const relayUrls = [...allRelays];
|
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [decoded.kind],
|
kinds: [decoded.kind],
|
||||||
authors: [decoded.pubkey],
|
authors: [decoded.pubkey],
|
||||||
"#d": [decoded.identifier],
|
"#d": [decoded.identifier],
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = new SimplePool();
|
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
|
||||||
|
// query instead of adding a serial round-trip before it.
|
||||||
|
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
|
||||||
|
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pool = await getReadPool();
|
||||||
|
const tried = new Set<string>();
|
||||||
|
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
|
||||||
|
// hints usually point at the author's own relay, so this is the fast path.
|
||||||
|
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
|
||||||
|
phase1.forEach((u) => tried.add(u));
|
||||||
|
let event = await getEventBounded(pool, phase1, filter);
|
||||||
|
|
||||||
|
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
|
||||||
|
if (!event) {
|
||||||
|
const nip65 = await nip65Promise;
|
||||||
|
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||||
|
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event) writeCache(cacheKey, event);
|
||||||
|
return event || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
||||||
|
// event id to its underlying Nostr event by querying relays. Lets the blog page
|
||||||
|
// render a long-form note straight from a shared link even when it was never
|
||||||
|
// indexed by the backend. Returns null if it can't be decoded or found.
|
||||||
|
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
|
||||||
|
const trimmed = identifier.trim();
|
||||||
|
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||||
|
return fetchEventFromRelays(trimmed.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded;
|
||||||
try {
|
try {
|
||||||
const event = await pool.get(relayUrls, filter);
|
decoded = nip19.decode(trimmed);
|
||||||
return event || null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
}
|
||||||
pool.close(relayUrls);
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case "naddr":
|
||||||
|
return fetchLongformFromRelays(trimmed);
|
||||||
|
case "nevent":
|
||||||
|
return fetchEventFromRelays((decoded.data as { id: string }).id);
|
||||||
|
case "note":
|
||||||
|
return fetchEventFromRelays(decoded.data as string);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"check:consistency": "node scripts/check-consistency.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Consistency check: confirms the number of upcoming meetups reported by the
|
||||||
|
* live /events page, the /events.md mirror, and the /llms.txt summary all match.
|
||||||
|
*
|
||||||
|
* Catches the exact "confidently wrong" drift this check exists to prevent:
|
||||||
|
* a mirror or summary going stale relative to the rendered page.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/check-consistency.mjs [baseUrl]
|
||||||
|
* BASE_URL=https://belgianbitcoinembassy.org node scripts/check-consistency.mjs
|
||||||
|
*
|
||||||
|
* Exits 0 if all three agree, 1 on any mismatch or fetch failure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const baseUrl = (
|
||||||
|
process.argv[2] ||
|
||||||
|
process.env.BASE_URL ||
|
||||||
|
"http://localhost:3000"
|
||||||
|
).replace(/\/$/, "");
|
||||||
|
|
||||||
|
async function getText(path) {
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "bbe-consistency-check" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET ${url} -> ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /llms.txt: "There are currently N upcoming meetup(s) scheduled." */
|
||||||
|
function parseLlmsTxt(text) {
|
||||||
|
const m = text.match(/currently\s+(\d+)\s+upcoming meetup/i);
|
||||||
|
if (!m) throw new Error("llms.txt: could not find upcoming-meetup count line");
|
||||||
|
return Number(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /events.md: count "### " headings within the "## Upcoming" section. */
|
||||||
|
function parseEventsMd(text) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
let inUpcoming = false;
|
||||||
|
let count = 0;
|
||||||
|
let sawUpcoming = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (/^##\s+Upcoming\b/.test(line)) {
|
||||||
|
inUpcoming = true;
|
||||||
|
sawUpcoming = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^##\s+/.test(line) && inUpcoming) inUpcoming = false; // next section
|
||||||
|
if (inUpcoming && /^###\s+/.test(line)) count += 1;
|
||||||
|
}
|
||||||
|
if (!sawUpcoming) throw new Error("events.md: no '## Upcoming' section found");
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /events: data-upcoming-count attribute rendered into the HTML. */
|
||||||
|
function parseEventsHtml(text) {
|
||||||
|
const m = text.match(/data-upcoming-count="(\d+)"/);
|
||||||
|
if (!m) throw new Error("events: data-upcoming-count attribute not found in HTML");
|
||||||
|
return Number(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [llmsTxt, eventsMd, eventsHtml] = await Promise.all([
|
||||||
|
getText("/llms.txt"),
|
||||||
|
getText("/events.md"),
|
||||||
|
getText("/events"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
"/llms.txt": parseLlmsTxt(llmsTxt),
|
||||||
|
"/events.md": parseEventsMd(eventsMd),
|
||||||
|
"/events": parseEventsHtml(eventsHtml),
|
||||||
|
};
|
||||||
|
|
||||||
|
const values = Object.values(counts);
|
||||||
|
const allMatch = values.every((v) => v === values[0]);
|
||||||
|
|
||||||
|
console.log(`Consistency check against ${baseUrl}`);
|
||||||
|
for (const [name, value] of Object.entries(counts)) {
|
||||||
|
console.log(` ${name.padEnd(12)} upcoming = ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allMatch) {
|
||||||
|
console.error(
|
||||||
|
`\n✗ MISMATCH: upcoming-meetup counts disagree (${values.join(", ")}).`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✓ All sources agree: ${values[0]} upcoming meetup(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`✗ Consistency check failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user