Files
bbeandCursor 2ef68222bf 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>
2026-06-29 03:57:32 +02:00

310 lines
9.6 KiB
TypeScript

/**
* Server-side builders for the llms.txt file (llmstxt.org) and the plain-markdown
* page mirrors (`<path>.md`). Everything here is generated at request time from
* live data (settings, meetups, FAQs, posts) so it never goes stale.
*
* Mirrors deliberately omit nav, footer, and the legal disclaimer block — an LLM
* reading these needs the actual content, not repeated chrome.
*/
import { apiUrl } from "./api-base";
import { formatMeetupCivilDateLong } from "./meetupEventTime";
import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData";
export const SITE_URL =
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> {
try {
const res = await fetch(apiUrl(path), { cache: "no-store" });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
function formatPostDate(value?: string): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
/** Social channels with their human description, in display order. */
const SOCIAL_CHANNELS: { key: string; name: string; description: string }[] = [
{
key: "telegram_link",
name: "Telegram",
description:
"Main Belgian chat group for daily discussion and local coordination.",
},
{
key: "nostr_link",
name: "Nostr",
description:
"Follow the BBE on the censorship-resistant social protocol.",
},
{
key: "x_link",
name: "X",
description: "Latest local announcements and event drops.",
},
{
key: "youtube_link",
name: "YouTube",
description: "Past talks, educational content, and meetup recordings.",
},
{
key: "discord_link",
name: "Discord",
description:
"Technical discussions, node running, and project collaboration.",
},
{
key: "linkedin_link",
name: "LinkedIn",
description: "The Belgian Bitcoin professional network.",
},
];
function configuredChannels(settings: Record<string, string>) {
return SOCIAL_CHANNELS.map((c) => ({ ...c, url: settings[c.key]?.trim() }))
.filter((c): c is typeof c & { url: string } =>
!!c.url && /^https?:\/\//i.test(c.url),
);
}
// ---------------------------------------------------------------------------
// llms.txt
// ---------------------------------------------------------------------------
export async function buildLlmsTxt(): Promise<string> {
const [settings, meetups] = await Promise.all([
fetchJson<Record<string, string>>("/settings/public", {}),
fetchMeetupsLive(),
]);
const upcomingCount = countUpcoming(meetups);
const channels = configuredChannels(settings);
const channelNames = channels.map((c) => c.name).join(", ");
const communityDescription = channelNames
? `How to connect on ${channelNames}`
: "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 =
upcomingCount > 0
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
upcomingCount === 1 ? "" : "s"
} scheduled.`
: "There are currently 0 upcoming meetups scheduled; the community meets monthly.";
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here covers meetups, FAQs about the community, and curated Bitcoin/Nostr commentary. ${upcomingLine}
## Pages
- [About](${SITE_URL}/index.html.md): Who we are and what we do
- [Events](${SITE_URL}/events.md): Upcoming and past Bitcoin meetups in Belgium
- [FAQ](${SITE_URL}/faq.md): Common questions about the community and how to get involved
- [Blog](${SITE_URL}/blog.md): Curated Bitcoin and Nostr content
- [Community](${SITE_URL}/community.md): ${communityDescription}
## Optional
- [Privacy](${SITE_URL}/privacy.md)
- [Terms](${SITE_URL}/terms.md)
- [Contact](${SITE_URL}/contact.md)
`;
}
// ---------------------------------------------------------------------------
// Page mirrors
// ---------------------------------------------------------------------------
export async function buildHomeMarkdown(): Promise<string> {
const meetups = await fetchMeetupsLive();
const next = partitionMeetups(meetups).upcoming[0];
let nextSection = "";
if (next) {
const when = formatMeetupCivilDateLong(next.date);
const bits = [when, next.time, next.location].filter(Boolean).join(" · ");
nextSection = `
## Next Meetup
**${next.title}** — ${bits}
See all events: ${SITE_URL}/events.md`;
}
return `# Belgian Bitcoin Embassy
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
## The Mission
"Fix the money, fix the world."
We help people in Belgium understand and adopt Bitcoin through education, meetups, and community. We are not a company, but a sovereign network of individuals building a sounder future.${nextSection}
## More
- Events: ${SITE_URL}/events.md
- FAQ: ${SITE_URL}/faq.md
- Blog: ${SITE_URL}/blog.md
- Community: ${SITE_URL}/community.md
`;
}
export async function buildEventsMarkdown(): Promise<string> {
const meetups = await fetchMeetupsLive();
const { upcoming, past } = partitionMeetups(meetups);
const renderMeetup = (m: any): string => {
const when = formatMeetupCivilDateLong(m.date);
const meta = [when, m.time, m.location].filter(Boolean).join(" · ");
const organizer = m.organizer?.name || "Belgian Bitcoin Embassy";
const lines = [`### ${m.title}`, "", `${meta}`, "", `Organized by ${organizer}.`];
if (m.description) lines.push("", m.description.trim());
lines.push("", `Details: ${SITE_URL}/events/${m.id}`);
return lines.join("\n");
};
const sections: string[] = [
"# Events",
"",
"Past and upcoming Bitcoin meetups in Belgium, organized by the Belgian Bitcoin Embassy.",
];
sections.push("", "## Upcoming");
sections.push(
"",
upcoming.length
? upcoming.map(renderMeetup).join("\n\n")
: "No upcoming events are currently scheduled. Check back soon.",
);
if (past.length) {
sections.push("", "## Past Events", "", past.map(renderMeetup).join("\n\n"));
}
return sections.join("\n") + "\n";
}
export async function buildFaqMarkdown(): Promise<string> {
const faqs = await fetchJson<any[]>("/faqs?all=true", []);
const list = Array.isArray(faqs) ? faqs : [];
const header = `# Frequently Asked Questions
Everything you need to know about the Belgian Bitcoin Embassy.`;
if (!list.length) {
return `${header}\n\nNo FAQs are available yet.\n`;
}
const body = list
.map((f) => `## ${f.question}\n\n${(f.answer || "").trim()}`)
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildBlogMarkdown(): Promise<string> {
const data = await fetchJson<{ posts: any[]; total: number }>(
"/posts?limit=100",
{ posts: [], total: 0 },
);
const posts = Array.isArray(data?.posts) ? data.posts : [];
const header = `# Blog
Curated Bitcoin and Nostr content from the Belgian Bitcoin Embassy.`;
if (!posts.length) {
return `${header}\n\nNo posts have been published yet.\n`;
}
const body = posts
.map((p) => {
const date = formatPostDate(p.publishedAt || p.createdAt);
const meta = [p.author, date].filter(Boolean).join(" · ");
const lines = [`## ${p.title}`];
if (meta) lines.push("", `_${meta}_`);
if (p.excerpt) lines.push("", p.excerpt.trim());
lines.push("", `Read: ${SITE_URL}/blog/${p.slug}`);
return lines.join("\n");
})
.join("\n\n");
return `${header}\n\n${body}\n`;
}
export async function buildCommunityMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Community
Connect with local Belgian Bitcoiners, builders, and educators across every platform.`;
if (!channels.length) {
return `${header}\n\nCommunity channel links are being set up. Check back soon.\n`;
}
const body = channels
.map((c) => `- [${c.name}](${c.url}): ${c.description}`)
.join("\n");
return `${header}\n\n## Channels\n\n${body}\n`;
}
export async function buildContactMarkdown(): Promise<string> {
const settings = await fetchJson<Record<string, string>>(
"/settings/public",
{},
);
const channels = configuredChannels(settings);
const header = `# Contact
The best way to reach us is through our community channels. We are a decentralized community — there is no central office or email inbox.`;
const lines: string[] = [header, "", "## Channels"];
if (channels.length) {
lines.push(
"",
...channels.map((c) => `- [${c.name}](${c.url}): ${c.description}`),
);
} else {
lines.push("", "Community channel links are being set up. Check back soon.");
}
lines.push(
"",
"## Meetups",
"",
`The best way to connect is in person. Come to our monthly meetup — see upcoming events at ${SITE_URL}/events.md`,
);
return lines.join("\n") + "\n";
}