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>
310 lines
9.6 KiB
TypeScript
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";
|
|
}
|