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:
bbe
2026-06-29 00:59:41 +02:00
co-authored by Cursor
parent 99380ef6aa
commit 6023991f5c
27 changed files with 1082 additions and 401 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Plain-markdown mirrors of the legal pages, served at /privacy.md and /terms.md
* for llms.txt consumers.
*
* SOURCE OF TRUTH: these must be kept in sync with the rendered pages at
* app/privacy/page.tsx and app/terms/page.tsx. If you edit the legal copy there,
* update it here too (and bump "Last updated").
*/
export const PRIVACY_MARKDOWN = `# Privacy Policy
_Last updated: April 3, 2026_
## Who We Are
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education and meetups in Belgium. We aim to process the minimum data needed to run this website safely and reliably.
## What Data We Process
If you log in with Nostr, we process your public key, role, and optional username. We also process content needed to operate the site, such as posts, submissions, media metadata, and moderation records. Some Nostr-related data may be cached on our servers to improve performance.
## Why We Process Data
We process data to provide core site features, maintain account sessions, prevent abuse, moderate community interactions, and keep the service secure. Our legal bases are contract (or steps requested by you before using features) and legitimate interests (security, integrity, and service operation).
## Cookies and Local Storage
We currently do not use third-party analytics or advertising cookies. We do use browser local storage to keep your authentication session active. You can clear this data at any time by logging out or clearing browser storage.
## Recipients
When you interact through Nostr, your actions are published on the Nostr network, which is public by design. We may also use infrastructure providers to host and secure the website.
## Retention
We keep account and operational data only as long as needed for service operation, security, and moderation. Technical logs may be retained for a limited period. You can remove local browser data at any time.
## Your GDPR Rights
Depending on applicable law, you may have rights to access, rectify, erase, restrict, object to, or request portability of your personal data. You also have the right to lodge a complaint with the Belgian Data Protection Authority.
## International Transfers
If technical providers process data outside the EEA, we aim to rely on appropriate safeguards as required under GDPR.
## Children
This website is not directed at children under the age of 16.
## Policy Updates
We may update this Privacy Policy from time to time. Material changes are reflected by updating the date at the top of this page.
## Contact
For privacy-related questions, reach out to us via our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
export const TERMS_MARKDOWN = `# Terms of Use
_Last updated: April 3, 2026_
## Acceptance and Changes
By accessing or using this website, you agree to these Terms of Use. We may update these terms from time to time, and continued use after updates means you accept the revised terms.
## Nature of the Service
This website provides general Bitcoin education and community information. Nothing on this website is financial, investment, legal, or tax advice. We do not make recommendations to buy, sell, or hold Bitcoin or any other crypto-asset. Content is general in nature and not tailored to your personal circumstances.
## Crypto Risk Warning
Crypto-assets are highly volatile and you can lose all of your money. Crypto-assets are not regulated in the same way as traditional financial products. Regulatory rules may change, and availability may differ by jurisdiction. Always do your own research and consult a qualified professional before making financial decisions.
## MiCA and Regulatory Position
Belgian Bitcoin Embassy presents this website as an educational platform and not as a crypto-asset service provider. If the nature of our activities changes, we may update these terms and related legal pages.
## Content and Third Parties
Some content is curated from the Nostr network. We do not claim ownership of third-party content. Local moderation may hide or limit content on this site, but does not change content on the Nostr network itself.
## User Conduct
Users interacting via Nostr (likes, comments) are expected to behave respectfully. The moderation team reserves the right to locally hide content or block pubkeys that violate community standards.
## Paid and Commercial Features
Certain features may involve Lightning payments, such as paid public board messages. Any such feature is optional and does not change the educational nature of the site.
## Affiliate and Sponsorship Transparency
As of the last updated date above, we do not earn referral fees from links on this website. If sponsored or affiliate content is added in the future, it will be clearly disclosed.
## Disclaimer and Liability
This platform is provided on an "as is" and "as available" basis without warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin Embassy is not liable for losses or damages resulting from your use of this site or reliance on its content.
## Governing Law
These terms are governed by Belgian law, without prejudice to mandatory consumer protections that apply in your jurisdiction.
## Contact
For terms-related questions, contact us through our [community channels](https://belgianbitcoinembassy.org/community.md).
`;
+331
View File
@@ -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";
}
+21
View File
@@ -52,6 +52,27 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}
/**
* Returns the event end instant in UTC when the time string carries a range
* (e.g. "18:00 - 21:00"), otherwise null. Used for schema.org Event.endDate.
*/
export function getMeetupEndUtc(dateStr: string, timeStr: string): Date | null {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return null;
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const day = parts[2];
if (!year || !month || !day) return null;
const timeParts = (timeStr?.trim() || "").split(/\s*[-]\s*/);
if (timeParts.length < 2 || !timeParts[1]?.trim()) return null;
const { h: endH, m: endM } = parseLocalTime(timeParts[1]);
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
}
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
/**
+38
View File
@@ -0,0 +1,38 @@
import { apiUrl } from "./api-base";
/**
* Server-side fetch of the public site settings (social links, titles, …).
* Cached/revalidated so it doesn't hit the backend on every render.
*/
export async function fetchPublicSettings(): Promise<Record<string, string>> {
try {
const res = await fetch(apiUrl("/settings/public"), {
next: { revalidate: 3600 },
});
if (!res.ok) return {};
return (await res.json()) as Record<string, string>;
} catch {
return {};
}
}
/** Setting keys that hold a public social/profile URL, in display order. */
const SOCIAL_SETTING_KEYS = [
"telegram_link",
"nostr_link",
"x_link",
"youtube_link",
"discord_link",
"linkedin_link",
] as const;
/**
* Build the list of real social URLs for schema.org `sameAs` from settings.
* Only includes entries that are actually configured (non-empty, http(s)).
*/
export function socialUrlsFromSettings(
settings: Record<string, string>,
): string[] {
return SOCIAL_SETTING_KEYS.map((key) => settings[key]?.trim())
.filter((url): url is string => !!url && /^https?:\/\//i.test(url));
}