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>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user