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,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).
|
||||
`;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user