Files
BelgianBitcoinEmbassy/frontend/lib/llms.ts
T
bbeandCursor 6023991f5c feat: add SEO metadata, llms.txt, and markdown page mirrors
Centralize site metadata and social URLs for JSON-LD, expose llmstxt.org
content and per-page .md routes for LLM crawlers, and refactor blog/FAQ/events
pages with shared components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:59:41 +02:00

332 lines
10 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, getMeetupStartUtc } from "./meetupEventTime";
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { next: { revalidate: 300 } });
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", {}),
fetchJson<any[]>("/meetups", []),
]);
const now = new Date();
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 channelNames = channels.map((c) => c.name).join(", ");
const communityDescription = channelNames
? `How to connect on ${channelNames}`
: "How to connect with the community";
const upcomingLine =
upcomingCount > 0
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
upcomingCount === 1 ? "" : "s"
} scheduled.`
: "Meetups run monthly; check the events page for the next date.";
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 fetchJson<any[]>("/meetups", []);
const now = new Date();
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 = "";
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 fetchJson<any[]>("/meetups", []);
const list = Array.isArray(meetups) ? 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 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";
}