diff --git a/src/components/Auth.astro b/src/components/Auth.astro
index e0a5ff1..b3c8827 100644
--- a/src/components/Auth.astro
+++ b/src/components/Auth.astro
@@ -1,4 +1,5 @@
---
+import { localeHref } from '../i18n/paths';
// Client-side sign in / out (AT Protocol OAuth). Restores the session on load (so users
// stay signed in), exposes window.atn = { agent, session, did } + window.atnClient for the
// /login page and logged-in actions, and fires atn:signedin / atn:signedout.
@@ -8,7 +9,7 @@
// on page-to-page navigation).
import type { Lang } from '../i18n/ui';
const { lang = 'en' } = Astro.props as { lang?: Lang };
-const loginHref = lang === 'en' ? '/login' : '/nl/login';
+const loginHref = localeHref(lang, '/login');
const signIn = lang === 'nl' ? 'Inloggen' : 'Sign in';
const signOut = lang === 'nl' ? 'Uitloggen' : 'Sign out';
---
diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro
index c4abd1c..b05c5aa 100644
--- a/src/components/pages/HomePage.astro
+++ b/src/components/pages/HomePage.astro
@@ -1,4 +1,5 @@
---
+import { localeHref } from '../../i18n/paths';
import Base from '../../layouts/Base.astro';
import PostCard from '../PostCard.astro';
import LinkedEventCard from '../LinkedEventCard.astro';
@@ -18,7 +19,7 @@ import { nlStats } from '../../data/nl-stats';
import { HOMEPAGE_FEED_URI, HOMEPAGE_FEED_SUBSCRIBE } from '../../data/feed';
const { lang } = Astro.props as { lang: Lang };
-const p = lang === 'nl' ? '/nl' : '';
+const p = localeHref(lang, '/').replace(/\/$/, '');
const t = useTranslations(lang);
const { members, feed, stats } = await getCommunity();
const locations = members.map((m) => m.sifa?.location).filter((l): l is string => Boolean(l));
diff --git a/src/components/pages/MembersPage.astro b/src/components/pages/MembersPage.astro
index 7b6ed71..24d3224 100644
--- a/src/components/pages/MembersPage.astro
+++ b/src/components/pages/MembersPage.astro
@@ -1,4 +1,5 @@
---
+import { localeHref } from '../../i18n/paths';
import Base from '../../layouts/Base.astro';
import MemberCard from '../MemberCard.astro';
import { useTranslations, type Lang } from '../../i18n/ui';
@@ -7,8 +8,8 @@ import { getCommunity } from '../../lib/atproto/home';
const { lang } = Astro.props as { lang: Lang };
const t = useTranslations(lang);
const { members } = await getCommunity();
-const loginHref = lang === 'nl' ? '/nl/login' : '/login';
-const buildersHref = lang === 'nl' ? '/nl/builders' : '/builders';
+const loginHref = localeHref(lang, '/login');
+const buildersHref = localeHref(lang, '/builders');
---
diff --git a/src/components/pages/PrivacyPage.astro b/src/components/pages/PrivacyPage.astro
index 129ce7b..8f51e0b 100644
--- a/src/components/pages/PrivacyPage.astro
+++ b/src/components/pages/PrivacyPage.astro
@@ -1,11 +1,12 @@
---
+import { localeHref } from '../../i18n/paths';
import Base from '../../layouts/Base.astro';
import { useTranslations, type Lang } from '../../i18n/ui';
const { lang } = Astro.props as { lang: Lang };
const t = useTranslations(lang);
const title = 'Privacy';
-const home = lang === 'nl' ? '/nl/' : '/';
+const home = localeHref(lang, '/');
const updated = lang === 'nl' ? 'Laatst bijgewerkt: 27 augustus 2026' : 'Last updated: 27 August 2026';
---
diff --git a/src/components/pages/TermsPage.astro b/src/components/pages/TermsPage.astro
index c3bc387..a301ca6 100644
--- a/src/components/pages/TermsPage.astro
+++ b/src/components/pages/TermsPage.astro
@@ -1,11 +1,12 @@
---
+import { localeHref } from '../../i18n/paths';
import Base from '../../layouts/Base.astro';
import { useTranslations, type Lang } from '../../i18n/ui';
const { lang } = Astro.props as { lang: Lang };
const t = useTranslations(lang);
const title = lang === 'nl' ? 'Voorwaarden' : 'Terms';
-const home = lang === 'nl' ? '/nl/' : '/';
+const home = localeHref(lang, '/');
const updated = lang === 'nl' ? 'Laatst bijgewerkt: 26 augustus 2026' : 'Last updated: 26 August 2026';
---
diff --git a/src/i18n/paths.ts b/src/i18n/paths.ts
index f7b747f..8769c87 100644
--- a/src/i18n/paths.ts
+++ b/src/i18n/paths.ts
@@ -42,3 +42,47 @@ export function t(table: Record, lang: Lang, what = 'value'): T {
console.warn(`[i18n] missing ${what} for locale "${lang}"; falling back to "${DEFAULT_LOCALE}"`);
return table[DEFAULT_LOCALE];
}
+
+/** Endonyms for the language switcher. Codes are ambiguous once a third locale exists — on
+ * atproto.be "NL" is both a language and a country — so the header speaks language names and
+ * the footer speaks domains, and the two share no vocabulary. */
+export const ENDONYM: Record = {
+ en: 'English',
+ nl: 'Nederlands',
+ fr: 'Français',
+ de: 'Deutsch',
+};
+
+/** BCP-47-ish locale for og:locale. Region is the scope's country where we have one. */
+export const OG_LOCALE: Record = {
+ en: 'en_GB',
+ nl: 'nl_NL',
+ fr: 'fr_BE',
+ de: 'de_BE',
+};
+
+/** Path for `path` in `lang`. The default locale lives at the root, others under //. */
+export function localeHref(lang: Lang, path: string): string {
+ const p = path.startsWith('/') ? path : `/${path}`;
+ return lang === DEFAULT_LOCALE ? p : `/${lang}${p === '/' ? '/' : p}`;
+}
+
+/** Strip any known locale prefix, giving the path as the default locale would serve it. */
+export function stripLocale(path: string): string {
+ for (const l of LOCALES) {
+ if (l === DEFAULT_LOCALE) continue;
+ if (path === `/${l}` || path === `/${l}/`) return '/';
+ if (path.startsWith(`/${l}/`)) return path.slice(l.length + 1);
+ }
+ return path;
+}
+
+/** The same page in every locale this build serves — for the switcher and for hreflang.
+ * Only locales this scope actually builds appear, so a build can never advertise a language
+ * it does not serve. */
+export function localeAlternates(path: string): { lang: Lang; href: string; label: string }[] {
+ const base = stripLocale(path);
+ return [...LOCALES]
+ .sort((a, b) => (ENDONYM[a] ?? a).localeCompare(ENDONYM[b] ?? b))
+ .map((l) => ({ lang: l, href: localeHref(l, base), label: ENDONYM[l] ?? l.toUpperCase() }));
+}
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
index 8a77744..71649e3 100644
--- a/src/layouts/Base.astro
+++ b/src/layouts/Base.astro
@@ -1,5 +1,6 @@
---
import { getScope, siteName } from '../config/scopes';
+import { localeHref, localeAlternates, OG_LOCALE, DEFAULT_LOCALE } from '../i18n/paths';
import '@fontsource-variable/atkinson-hyperlegible-next';
import '@fontsource-variable/jetbrains-mono';
import '../styles/global.css';
@@ -23,26 +24,34 @@ interface Props {
const { title, lang = 'en', description: pageDescription } = Astro.props;
const t = useTranslations(lang);
-const home = lang === 'en' ? '/' : '/nl/';
-const membersHref = lang === 'en' ? '/members' : '/nl/members';
-const eventsHref = lang === 'en' ? '/events' : '/nl/events';
-const toolsHref = lang === 'en' ? '/tools' : '/nl/tools';
-const statsHref = lang === 'en' ? '/community-size' : '/nl/community-size';
-const buildersHref = lang === 'en' ? '/builders' : '/nl/builders';
-const aboutHref = lang === 'en' ? '/about' : '/nl/about';
-const privacyHref = lang === 'en' ? '/privacy' : '/nl/privacy';
-const termsHref = lang === 'en' ? '/terms' : '/nl/terms';
+// Every in-site link goes through localeHref: a two-way `lang === 'en' ? … : '/nl/…'` sent
+// every non-English visitor to Dutch, which made /fr/ a dead end on .be and put a Dutch
+// switcher on .eu, a build with no Dutch at all.
+const home = localeHref(lang, '/');
+const membersHref = localeHref(lang, '/members');
+const eventsHref = localeHref(lang, '/events');
+const toolsHref = localeHref(lang, '/tools');
+const statsHref = localeHref(lang, '/community-size');
+const buildersHref = localeHref(lang, '/builders');
+const aboutHref = localeHref(lang, '/about');
+const privacyHref = localeHref(lang, '/privacy');
+const termsHref = localeHref(lang, '/terms');
const followUrl = 'https://mu.social/profile/atproto.nl';
const site = Astro.site?.toString().replace(/\/$/, '') ?? 'https://atproto.nl';
// Language switch keeps you on the same page in the other language, instead of
// always going home. Guides live in English only, so their NL link falls back to /nl/events/.
const path = Astro.url.pathname;
-const toEn = (p: string) => (p === '/nl' || p === '/nl/' ? '/' : p.replace(/^\/nl\//, '/'));
-const toNl = (p: string) => (p === '/' ? '/nl/' : p.startsWith('/nl/') || p === '/nl' ? p : '/nl' + p);
-const enHref = lang === 'en' ? path : toEn(path);
-let nlHref = lang === 'nl' ? path : toNl(path);
-if (lang === 'en' && /^\/events\/[^/]+\/?$/.test(path)) nlHref = '/nl/events/';
+// Built from the scope, so a build can never advertise a language it does not serve.
+let alternates = localeAlternates(path);
+// Event guides are English-only: point other locales at the events index rather than a 404.
+if (/^\/(?:[a-z]{2}\/)?events\/[^/]+\/?$/.test(path)) {
+ alternates = alternates.map((a) =>
+ a.lang === DEFAULT_LOCALE ? a : { ...a, href: localeHref(a.lang, '/events/') },
+ );
+}
+const selfHref = alternates.find((a) => a.lang === lang)?.href ?? path;
+const defaultHref = alternates.find((a) => a.lang === DEFAULT_LOCALE)?.href ?? '/';
const canonical = `${site}${path}`;
// The site name comes from the scope, not the catalogue: it does not change per language, and
@@ -53,7 +62,7 @@ const description = pageDescription ?? t('site.tagline');
const orgLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
- name: 'atproto.nl',
+ name: SITE_NAME,
url: `${site}/`,
logo: `${site}/brand/atproto-nl-avatar-1000.png`,
description: t('site.tagline'),
@@ -86,14 +95,15 @@ const orgLd = {
{pageTitle}
-
-
-
+ {alternates.map((a) => (
+
+ ))}
+
-
+
@@ -111,7 +121,7 @@ const orgLd = {
{t('a11y.skip')}
-
+
@@ -142,11 +161,11 @@ const orgLd = {
--
2.54.0
From 16b9519e8d34618a4e09afc5340c2ca2e0690316 Mon Sep 17 00:00:00 2001
From: Guido X Jansen
Date: Wed, 2 Sep 2026 23:44:02 +0300
Subject: [PATCH 2/3] feat(eu): build the aggregate atproto.eu actually exists
for
SITE_SCOPE could select routes, locales and branding, but there was nothing to
select from: every data source in the build was NL-only, so .eu was atproto.nl
with the Dutch pages removed. This adds the European dataset and the two pages
that render it.
eu-stats-aggregate.py attributes every DID to exactly ONE country via
attribution.py, so per-country counts are people rather than rows and they sum
to the total instead of overlapping. Language is not an input at all.
The rank mix immediately earns its keep. Spain shows ~9.800 .es handles of
which 96 survive as evidence -- jam.es, hous.es and friends -- so sorting by
total would have put a near-fictional Spain at the top of the page. Sorting by
confirmed gives gb 5.606, de 4.331, nl 2.710, fr 2.335. Weak-ccTLD countries
carry the caveat next to the number instead of in a footnote, and the accounts
are kept and ranked low rather than deleted.
/countries lists every country with data; /countries/ shows how its
accounts were found, in the reader's terms rather than ours. Countries without
a community get the invitation to run one. Both routes are aggregate-only:
getStaticPaths returns [] on .nl and .be, so there is no dead route anywhere.
Communities are linked, not competed with. atproto.fr is a real French
community and we point at it. City communities (atproto.amsterdam,
atproto.barcelona) are marked as cities, never shown as speaking for a country
-- Barcelona is not Spain's community and should not be presented as one.
Note the ccTLD sweep that found these missed city domains entirely, which is
why Spain looked empty until it wasn't.
---
.../__pycache__/attribution.cpython-314.pyc | Bin 0 -> 13359 bytes
scripts/attribution.py | 218 +++++++
scripts/collect-countries.sh | 98 +++
scripts/eu-stats-aggregate.py | 178 ++++++
src/data/communities.ts | 65 ++
src/data/europe-stats.json | 559 ++++++++++++++++++
src/data/europe-stats.ts | 86 +++
src/pages/[...lang]/countries.astro | 132 +++++
src/pages/[...lang]/countries/[cc].astro | 161 +++++
9 files changed, 1497 insertions(+)
create mode 100644 scripts/__pycache__/attribution.cpython-314.pyc
create mode 100644 scripts/attribution.py
create mode 100755 scripts/collect-countries.sh
create mode 100644 scripts/eu-stats-aggregate.py
create mode 100644 src/data/communities.ts
create mode 100644 src/data/europe-stats.json
create mode 100644 src/data/europe-stats.ts
create mode 100644 src/pages/[...lang]/countries.astro
create mode 100644 src/pages/[...lang]/countries/[cc].astro
diff --git a/scripts/__pycache__/attribution.cpython-314.pyc b/scripts/__pycache__/attribution.cpython-314.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..bc9d9ca540aec5404ff02eb594dabeae783f9422
GIT binary patch
literal 13359
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
wupfH!fkwk#b!hJWfq$Y
z+{QP!g^*NaU*$G}ra*@s;+I(*E^wQZpu+G9x5^6f4RTjC9S%5N(R97Q;&-6@GK=2@
zZdZ^gHdnb77C2tvR=vQYx1jtoi{1rpRZt$_zsTpfzMrFxA;tr#@jd4^HMM}gst0=JJI^BG-6A3bK!
zA~VqP4NrgV`GsPy8Bk54NtDJ@Ekk1qlh
z-?umti_!}ci;7ckF&9@B-{MZr&&f#zYbgegLfztquUt(nE&?^7!HZyuz=f(csF(z~
z4m`yQp0R?IpWu=dR1SjI)7;{7EJ_D2nG68gRCJ3evEUX@VoFMUB2@eq7uXFTe(^1q
z#G>@#Tb!W6A6%8(;sTe!@foQ(1-DpID>6&KV|3u53&^Z1hYe(non6sn1_lPuuu8Ew
zBLl++W=2NFk1P_5EMJWn7?mG0@ZV~-un0_0yurdh
zLGT8P5J*gLg5eDo-i|`31XxyJg5n2JZce5K)(;X4tXv)X7g(e|urY{Web?tQxbvHSL`gk62aP{+c^7ioG;Nb4(=;D|l*vI`*kby-C
G9B=>^lyDCK
literal 0
HcmV?d00001
diff --git a/scripts/attribution.py b/scripts/attribution.py
new file mode 100644
index 0000000..3282728
--- /dev/null
+++ b/scripts/attribution.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""Attribute each DID to exactly ONE country, by a fixed precedence of signals.
+
+Why single-valued: the NL pipeline unions five sources and dedupes by DID, so nothing is
+double-counted within one country. Across 27 countries that breaks -- one DID can match `nl`
+(Dutch posts), `es` (a .es handle) and `de` (a Sifa location), and per-country counts would then
+sum to more than the union of people. The fix is not to drop signals but to make attribution
+single-valued for counting.
+
+Ordering principle, in four rules:
+ 1. signals that answer *this* question outrank signals that answer a neighbouring one
+ 2. declarations beat inferences
+ 3. deliberate choices beat incidental ones
+ 4. curated beats automated
+
+Rule 1 does most of the work, and it is NOT the same as ranking by how authoritative a signal
+feels. A member record is a stronger declaration than a domain choice, but it answers "where do
+you take part", not "where are you" -- someone in Luxembourg with no local events joins the
+Belgian, French and German communities and is none of those nationalities. Ranking it high would
+inflate the countries that run events and empty the ones that do not, which is worst for exactly
+the small countries whose data is already thinnest.
+
+This module is pure: it takes signals in, returns an attribution out. No I/O, no network.
+
+Self-test: python3 scripts/attribution.py --self-test
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+from collections import Counter
+from dataclasses import dataclass, field
+
+# Rank 1 is strongest. Names are stable identifiers -- they are published per country so a
+# reader can see whether a count rests on declarations or on guesses.
+RANKS = {
+ "sifaLocation": 1, # id.sifa.profile.location, isPrimary -- where you live, declared by you
+ "orgHqDeclared": 1, # (unused; see note below)
+ "cityDomain": 2, # .amsterdam / .wien / .cat -- nobody registers these by accident
+ "strongTld": 3, # .nl .de .fr .pl ... ; also a company's website ccTLD
+ "memberRecord": 4, # eu.atcommons.member with exactly one country -- where you take part
+ "starterpack": 5, # a human vouched
+ "weakTld": 6, # .is .es .at .se .it -- deliberate but word-play-prone
+ "orgCountry": 6, # a registered HQ is a legal/tax choice, not a place of operation
+ "uniqueLang": 7, # nl, pl, hu, el -- inference
+ "sharedLang": 8, # de, fr, sv, ... -- weakest inference
+}
+del RANKS["orgHqDeclared"] # kept above only to document that HQ was considered for rank 1
+
+# A signal at this rank or better is presented as "confirmed"; the rest are estimate-layer only.
+CONFIRMED_MAX_RANK = 5
+
+
+@dataclass(frozen=True)
+class Signal:
+ """One piece of evidence that a DID belongs to a country."""
+ kind: str
+ country: str
+
+ @property
+ def rank(self) -> int:
+ try:
+ return RANKS[self.kind]
+ except KeyError:
+ raise ValueError(f"unknown signal kind: {self.kind!r}") from None
+
+
+@dataclass
+class Attribution:
+ did: str
+ country: str | None # None when contested or when there is no signal at all
+ rank: int | None
+ kind: str | None
+ contested: bool = False
+ candidates: tuple[str, ...] = field(default_factory=tuple)
+
+ @property
+ def confirmed(self) -> bool:
+ return self.country is not None and self.rank is not None and self.rank <= CONFIRMED_MAX_RANK
+
+
+def attribute(did: str, signals: list[Signal]) -> Attribution:
+ """Pick the country for a DID, or mark it contested.
+
+ Ties at the winning rank that disagree on the country are NOT broken. Two city domains, or a
+ multi-country member record with no Sifa location, give a genuine coin flip -- and a coin flip
+ recorded as fact is worse than an honest "unattributed", because it is indistinguishable from
+ a real answer downstream.
+
+ Ties at the winning rank that AGREE are not contested: several sources saying "nl" is
+ corroboration, not conflict.
+ """
+ if not signals:
+ return Attribution(did, None, None, None)
+ best = min(s.rank for s in signals)
+ top = [s for s in signals if s.rank == best]
+ countries = {s.country for s in top}
+ if len(countries) > 1:
+ return Attribution(did, None, best, None, contested=True,
+ candidates=tuple(sorted(countries)))
+ winner = top[0]
+ return Attribution(did, winner.country, best, winner.kind)
+
+
+def attribute_all(signals_by_did: dict[str, list[Signal]]) -> dict[str, Attribution]:
+ return {did: attribute(did, sigs) for did, sigs in signals_by_did.items()}
+
+
+def summarise(attributions: dict[str, Attribution]) -> dict:
+ """Per-country totals plus the rank mix that produced them.
+
+ The rank mix is published, not internal: a country resting on ranks 6-8 is visibly weaker
+ evidence than one resting on 1-3, and hiding that would make a guess look like a count.
+ """
+ per: dict[str, dict] = {}
+ contested = 0
+ unattributed = 0
+ for a in attributions.values():
+ if a.contested:
+ contested += 1
+ continue
+ if a.country is None:
+ unattributed += 1
+ continue
+ c = per.setdefault(a.country, {"total": 0, "confirmed": 0, "byRank": Counter(),
+ "byKind": Counter()})
+ c["total"] += 1
+ c["confirmed"] += 1 if a.confirmed else 0
+ c["byRank"][a.rank] += 1
+ c["byKind"][a.kind] += 1
+ for c in per.values():
+ c["byRank"] = dict(sorted(c["byRank"].items()))
+ c["byKind"] = dict(sorted(c["byKind"].items(), key=lambda kv: -kv[1]))
+ return {
+ "countries": dict(sorted(per.items(), key=lambda kv: -kv[1]["total"])),
+ "contested": contested,
+ "unattributed": unattributed,
+ "attributed": sum(c["total"] for c in per.values()),
+ }
+
+
+def _self_test() -> None:
+ S = Signal
+ # Precedence: location beats a domain beats a member record.
+ a = attribute("did:x", [S("memberRecord", "be"), S("strongTld", "de"), S("sifaLocation", "lu")])
+ assert (a.country, a.rank, a.kind) == ("lu", 1, "sifaLocation"), a
+
+ # The Luxembourg case: joining BE/FR/DE communities must not make someone Belgian.
+ a = attribute("did:lu", [S("memberRecord", "be")])
+ assert a.country == "be" and a.rank == 4, a
+ a = attribute("did:lu", [S("memberRecord", "be"), S("sifaLocation", "lu")])
+ assert a.country == "lu", a
+
+ # A domain choice outranks a member record: place beats participation.
+ a = attribute("did:d", [S("memberRecord", "be"), S("cityDomain", "nl")])
+ assert a.country == "nl" and a.rank == 2, a
+
+ # orgCountry sits with weak TLDs, not at rank 1: a registered HQ is a tax choice.
+ assert RANKS["orgCountry"] == RANKS["weakTld"] == 6
+ a = attribute("did:o", [S("orgCountry", "nl"), S("strongTld", "de")])
+ assert a.country == "de", a
+
+ # Same-rank disagreement is contested, never a coin flip.
+ a = attribute("did:c", [S("cityDomain", "nl"), S("cityDomain", "be")])
+ assert a.contested and a.country is None and a.candidates == ("be", "nl"), a
+
+ # Same-rank agreement is corroboration, not conflict.
+ a = attribute("did:agree", [S("strongTld", "nl"), S("strongTld", "nl")])
+ assert not a.contested and a.country == "nl", a
+
+ # No signals at all: not attributed, not contested.
+ a = attribute("did:none", [])
+ assert a.country is None and not a.contested and a.rank is None, a
+
+ # Language never wins against anything deliberate, and is never "confirmed".
+ a = attribute("did:l", [S("uniqueLang", "nl"), S("weakTld", "es")])
+ assert a.country == "es" and a.rank == 6, a
+ assert not attribute("did:l2", [S("sharedLang", "de")]).confirmed
+ assert not attribute("did:l3", [S("uniqueLang", "nl")]).confirmed
+ assert attribute("did:s", [S("starterpack", "nl")]).confirmed
+
+ # Unknown signal kinds fail loudly rather than silently scoring last.
+ try:
+ attribute("did:bad", [S("vibes", "nl")])
+ except ValueError:
+ pass
+ else:
+ raise AssertionError("unknown signal kind should raise")
+
+ # Summary: per-country totals, rank mix, contested and unattributed kept separate.
+ summary = summarise(attribute_all({
+ "did:1": [S("sifaLocation", "nl")],
+ "did:2": [S("strongTld", "nl")],
+ "did:3": [S("sharedLang", "de")],
+ "did:4": [S("cityDomain", "nl"), S("cityDomain", "be")],
+ "did:5": [],
+ }))
+ assert summary["countries"]["nl"]["total"] == 2, summary
+ assert summary["countries"]["nl"]["confirmed"] == 2, summary
+ assert summary["countries"]["de"]["confirmed"] == 0, summary
+ assert summary["contested"] == 1 and summary["unattributed"] == 1, summary
+ assert summary["attributed"] == 3, summary
+
+ # A DID is never counted twice, which is the whole point of this module.
+ assert sum(c["total"] for c in summary["countries"].values()) == summary["attributed"]
+
+ print("attribution self-test: OK")
+
+
+if __name__ == "__main__":
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--self-test", action="store_true")
+ args = ap.parse_args()
+ if args.self_test:
+ _self_test()
+ else:
+ ap.print_help()
+ sys.exit(1)
diff --git a/scripts/collect-countries.sh b/scripts/collect-countries.sh
new file mode 100755
index 0000000..170b8fa
--- /dev/null
+++ b/scripts/collect-countries.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+# Run the per-country feeders for every configured country.
+#
+# The two Sifa feeders already take --country, so widening to Europe needs a loop, not a rewrite.
+# The two expensive feeders are deliberately NOT in here: the Jetstream collector and the PLC
+# export scan are single global passes that bucket every country at once, so running them per
+# country would multiply their cost for no gain. Run those separately, once.
+#
+# Countries come from scripts/country-tlds.json (the same map the handle scan uses) unless
+# COUNTRIES is set, so there is one list to maintain rather than two that drift.
+#
+# Usage:
+# bash scripts/collect-countries.sh # every country in the map
+# COUNTRIES="nl be de" bash scripts/collect-countries.sh
+# DIR=/volume1/homes/sshuser/nl-estimate bash scripts/collect-countries.sh
+#
+# Output per country, in $DIR:
+# sifa-.dids accounts with a Sifa location in that country
+# sifa-company-.dids company accounts for that country
+#
+# Failures never abort the run: one country's API hiccup must not cost the other 44. Failed
+# countries are retried once at the end -- with 45 countries x 2 endpoints, the usual cause is
+# Sifa's 60/min rate limit rather than anything being wrong, and the same country succeeds when
+# run on its own. PAUSE spaces the countries out to stay under that limit in the first place.
+set -uo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+DIR="${DIR:-$HERE}"
+SLEEP="${SLEEP:-0.3}"
+PAUSE="${PAUSE:-1.5}" # seconds between countries; Sifa rate-limits at 60/min
+MAP="${MAP:-$HERE/country-tlds.json}"
+
+# Country codes Sifa's API rejects. Empty since singi-labs/sifa-api#1280 landed, which added
+# fo (Faroe Islands) and gl (Greenland) -- autonomous Danish territories with their own ISO
+# 3166-1 codes that the validation list had omitted. Kept as a lever for the next such gap.
+SIFA_UNSUPPORTED="${SIFA_UNSUPPORTED:-}"
+
+if [ -z "${COUNTRIES:-}" ]; then
+ COUNTRIES="$(python3 -c "
+import json,sys
+print(' '.join(sorted(set(json.load(open(sys.argv[1]))['tlds'].values()))))
+" "$MAP")"
+fi
+
+echo "[collect] $(date -u +%FT%TZ) countries: $COUNTRIES"
+failed=""
+ok=0
+
+run_country() {
+ local cc="$1"
+ case " $SIFA_UNSUPPORTED " in
+ *" $cc "*) echo "[collect] $cc skipped (Sifa API does not accept this country code)"; return 0 ;;
+ esac
+ CC_UPPER="$(printf '%s' "$cc" | tr '[:lower:]' '[:upper:]')"
+
+ # Sifa location. --country takes the uppercase alpha-2 the API expects.
+ if python3 "$HERE/nl-sifa-location.py" --country "$CC_UPPER" \
+ --out-prefix "$DIR/sifa-$cc" --sleep "$SLEEP" >/dev/null 2>&1; then
+ n=$(wc -l < "$DIR/sifa-$cc.dids" 2>/dev/null || echo 0)
+ [ "${n:-0}" -gt 0 ] && echo "[collect] $cc sifa-location: $n"
+ else
+ failed="$failed sifa-location:$cc"
+ fi
+
+ # Sifa companies.
+ if python3 "$HERE/nl-sifa-company.py" --country "$CC_UPPER" \
+ --out-prefix "$DIR/sifa-company-$cc" --dir "$DIR" >/dev/null 2>&1; then
+ n=$(wc -l < "$DIR/sifa-company-$cc.dids" 2>/dev/null || echo 0)
+ [ "${n:-0}" -gt 0 ] && echo "[collect] $cc sifa-company: $n"
+ ok=$((ok + 1))
+ else
+ failed="$failed sifa-company:$cc"
+ return 1
+ fi
+ return 0
+}
+
+retry=""
+for cc in $COUNTRIES; do
+ run_country "$cc" || retry="$retry $cc"
+ sleep "$PAUSE"
+done
+
+# One retry pass. Rate-limit rejections are the common case and they clear on their own.
+if [ -n "$retry" ]; then
+ echo "[collect] retrying:$retry"
+ failed=""
+ for cc in $retry; do
+ sleep 5
+ run_country "$cc" || true
+ done
+fi
+
+echo "[collect] done; $ok country-runs completed"
+if [ -n "$failed" ]; then
+ # Not fatal: these feeders are backfillable, so a partial run is worth keeping.
+ echo "[collect] still failing after retry:$failed" >&2
+fi
diff --git a/scripts/eu-stats-aggregate.py b/scripts/eu-stats-aggregate.py
new file mode 100644
index 0000000..01d8608
--- /dev/null
+++ b/scripts/eu-stats-aggregate.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Aggregate the per-country feeders into europe-stats.json, the dataset atproto.eu renders.
+
+The Dutch aggregator answers "how many accounts are in the Netherlands". This one answers
+"how many are in each European country", which is a different problem: the same DID can match
+several countries at once (a .es handle, a Sifa location in Germany), so per-country counts
+would sum to more than the number of people.
+
+So every DID is attributed to exactly ONE country, by the precedence in scripts/attribution.py:
+declarations beat inferences, deliberate choices beat incidental ones, and a signal that answers
+"where are you" beats one that answers "where do you take part". Disagreements at the same rank
+are recorded as contested rather than broken by a coin flip.
+
+Language is not an input here at all. It confirms nothing about a country, which is the whole
+reason the Dutch floor was rebuilt (see nl-stats-aggregate.py).
+
+Inputs, per country cc, in --dir:
+ handles..dids ccTLD handles from the PLC export scan -> strongTld / weakTld
+ sifa-.dids declared Sifa location -> sifaLocation
+ sifa-company-.dids company website ccTLD -> strongTld (an org's market)
+ starterpack.dids curated pack members (NL only today) -> starterpack
+
+Output is COUNTS ONLY. DID lists never leave the NAS.
+
+Usage:
+ python3 scripts/eu-stats-aggregate.py --dir . --out europe-stats.json
+ python3 scripts/eu-stats-aggregate.py --self-test
+"""
+from __future__ import annotations
+
+import argparse
+import datetime
+import glob
+import json
+import os
+import re
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from attribution import Signal, attribute_all, summarise # noqa: E402
+
+# TLDs whose registrations are commonly English word-play rather than a statement about a
+# country, so a handle in them is deliberate but ambiguous. Kept, ranked lower. See the
+# attribution decision: .es is currently the LARGEST bucket in the PLC scan, ahead of .gb and
+# .de, which is not plausible as a measure of Spanish atproto adoption.
+WEAK_TLD_COUNTRIES = {"is", "es", "at", "se", "it"}
+
+DEFAULT_MAP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "country-tlds.json")
+
+
+def read_dids(path):
+ if not path or not os.path.exists(path):
+ return set()
+ with open(path) as f:
+ return {ln.strip() for ln in f if ln.strip().startswith("did:")}
+
+
+def collect_signals(directory, countries, starterpack_country="nl", abandoned=None):
+ """Build did -> [Signal] across every country's feeders."""
+ abandoned = abandoned or set()
+ signals: dict[str, list[Signal]] = {}
+
+ def add(did, kind, cc):
+ if did in abandoned:
+ return
+ signals.setdefault(did, []).append(Signal(kind, cc))
+
+ for cc in countries:
+ for did in read_dids(os.path.join(directory, f"handles.{cc}.dids")):
+ add(did, "weakTld" if cc in WEAK_TLD_COUNTRIES else "strongTld", cc)
+ for did in read_dids(os.path.join(directory, f"sifa-{cc}.dids")):
+ add(did, "sifaLocation", cc)
+ # A company's website ccTLD says which market it addresses -- an operational tie, unlike
+ # a registered HQ, which is a tax choice and sits far lower in the ladder.
+ for did in read_dids(os.path.join(directory, f"sifa-company-{cc}.dids")):
+ add(did, "strongTld", cc)
+
+ for did in read_dids(os.path.join(directory, "starterpack.dids")):
+ add(did, "starterpack", starterpack_country)
+ return signals
+
+
+def countries_in(directory, tld_map_path=DEFAULT_MAP):
+ """Countries we have any feeder file for, plus every country in the TLD map."""
+ with open(tld_map_path) as f:
+ known = sorted(set(json.load(f)["tlds"].values()))
+ seen = set(known)
+ for p in glob.glob(os.path.join(directory, "handles.*.dids")):
+ m = re.search(r"handles\.([a-z]{2})\.dids$", p)
+ if m:
+ seen.add(m.group(1))
+ return sorted(seen)
+
+
+def build(directory, tld_map_path=DEFAULT_MAP, abandoned=None):
+ countries = countries_in(directory, tld_map_path)
+ signals = collect_signals(directory, countries, abandoned=abandoned)
+ attributions = attribute_all(signals)
+ summary = summarise(attributions)
+
+ per_country = []
+ for cc, c in summary["countries"].items():
+ per_country.append({
+ "country": cc,
+ "total": c["total"],
+ "confirmed": c["confirmed"],
+ "byRank": c["byRank"],
+ "bySignal": c["byKind"],
+ })
+
+ return {
+ "generatedAt": datetime.datetime.now(datetime.timezone.utc)
+ .strftime("%Y-%m-%dT%H:%M:%SZ"),
+ # People, not rows: every DID counts towards exactly one country, so this is a real
+ # total rather than a sum of overlapping per-country numbers.
+ "people": summary["attributed"],
+ "contested": summary["contested"],
+ "countries": per_country,
+ "method": {
+ "attribution": "one DID, one country, by signal precedence",
+ "languageUsed": False,
+ "weakTldCountries": sorted(WEAK_TLD_COUNTRIES),
+ },
+ }
+
+
+def _self_test():
+ import tempfile
+
+ d = tempfile.mkdtemp()
+
+ def w(name, dids):
+ with open(os.path.join(d, name), "w") as f:
+ f.write("\n".join(dids) + "\n")
+
+ w("handles.nl.dids", ["did:plc:a", "did:plc:b"])
+ w("handles.es.dids", ["did:plc:b", "did:plc:c"]) # b: nl strong vs es weak -> nl wins
+ w("sifa-de.dids", ["did:plc:a"]) # a: sifa location beats a .nl handle
+ w("starterpack.dids", ["did:plc:d"])
+
+ out = build(d)
+ per = {c["country"]: c for c in out["countries"]}
+ assert per["de"]["total"] == 1, out # a -> de by declared location
+ assert per["nl"]["total"] == 2, out # b (strongTld beats weak), d (starterpack)
+ assert per["es"]["total"] == 1, out # c only
+ assert out["people"] == 4, out # no DID counted twice
+ assert out["contested"] == 0, out
+ assert sum(c["total"] for c in out["countries"]) == out["people"], out
+ assert out["method"]["languageUsed"] is False
+ # d came from a starterpack: rank 5, so counted but not "confirmed" at rank <= 5? It is 5.
+ assert per["nl"]["confirmed"] >= 1, per["nl"]
+ print("eu-stats self-test OK")
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--dir", default=".")
+ ap.add_argument("--out", default="europe-stats.json")
+ ap.add_argument("--tld-map", default=DEFAULT_MAP)
+ ap.add_argument("--abandoned", default=None, help="optional .dids of accounts to exclude")
+ ap.add_argument("--self-test", action="store_true")
+ args = ap.parse_args()
+
+ if args.self_test:
+ _self_test()
+ return
+
+ abandoned = read_dids(args.abandoned) if args.abandoned else set()
+ out = build(args.dir, args.tld_map, abandoned)
+ with open(args.out, "w") as f:
+ f.write(json.dumps(out, indent=2) + "\n")
+ top = ", ".join(f"{c['country']}={c['total']}" for c in out["countries"][:8])
+ print(f"Wrote {args.out}: {out['people']} people across {len(out['countries'])} countries "
+ f"({out['contested']} contested); {top}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/data/communities.ts b/src/data/communities.ts
new file mode 100644
index 0000000..d6f7d90
--- /dev/null
+++ b/src/data/communities.ts
@@ -0,0 +1,65 @@
+// National AT Protocol communities that already exist, run by other people.
+//
+// atproto.eu is a directory first and a site second. Where a country already has a community,
+// we link to it and get out of the way; building a competing one would fragment a small
+// ecosystem and duplicate volunteers who are already doing the work.
+//
+// Checked 2026-09-02 across every European atproto.: only .fr was a real national
+// community. .at is a developer tool using the domain as a pun, .es is parked and for sale,
+// .de sits on a drop-catch service, and the rest do not resolve.
+//
+// The ccTLD sweep missed city domains entirely, which is how atproto.amsterdam and
+// atproto.barcelona were nearly overlooked. Check .tld too before declaring a country
+// empty -- a country with no national site may still have an active local one.
+
+export interface Community {
+ /** ISO 3166-1 alpha-2, lowercase. */
+ country: string;
+ /** A city community covers one city, not the country. Saying atproto.barcelona is "the
+ * community for Spain" would misrepresent both it and Spain, so the directory keeps the
+ * distinction visible and never lets a city stand in for a nation. */
+ scope: 'national' | 'city';
+ /** City name, for city-scoped communities. */
+ city?: string;
+ name: string;
+ url: string;
+ /** What it actually offers, in its own terms. Kept short and factual — this is a link, not
+ * a review, and we do not speak for them. */
+ note: string;
+ /** Communities we run ourselves, so the directory can distinguish "ours" from "theirs". */
+ ours?: boolean;
+}
+
+export const existingCommunities: Community[] = [
+ {
+ country: 'nl',
+ scope: 'national',
+ name: 'atproto.nl',
+ url: 'https://atproto.nl',
+ note: 'Members, events and a directory for the AT Protocol community in the Netherlands.',
+ ours: true,
+ },
+ {
+ country: 'fr',
+ scope: 'national',
+ name: 'atproto.fr',
+ url: 'https://atproto.fr',
+ note: 'French-language articles, a protocol wiki, a Matrix channel and meetups in Paris.',
+ },
+ {
+ country: 'nl',
+ scope: 'city',
+ city: 'Amsterdam',
+ name: 'atproto.amsterdam',
+ url: 'https://atproto.amsterdam',
+ note: 'Local meetups for the AT Protocol community in Amsterdam. Open to pull requests.',
+ },
+ {
+ country: 'es',
+ scope: 'city',
+ city: 'Barcelona',
+ name: 'atproto.barcelona',
+ url: 'https://atproto.barcelona',
+ note: 'Meetups and gatherings for AT Protocol people in Barcelona, started by @didiermortier.eu.',
+ },
+];
diff --git a/src/data/europe-stats.json b/src/data/europe-stats.json
new file mode 100644
index 0000000..33bf6ba
--- /dev/null
+++ b/src/data/europe-stats.json
@@ -0,0 +1,559 @@
+{
+ "generatedAt": "2026-09-02T11:09:01Z",
+ "people": 33089,
+ "contested": 24,
+ "countries": [
+ {
+ "country": "es",
+ "total": 9889,
+ "confirmed": 96,
+ "byRank": {
+ "1": 12,
+ "3": 84,
+ "6": 9793
+ },
+ "bySignal": {
+ "weakTld": 9793,
+ "strongTld": 84,
+ "sifaLocation": 12
+ }
+ },
+ {
+ "country": "gb",
+ "total": 5606,
+ "confirmed": 5606,
+ "byRank": {
+ "1": 32,
+ "3": 5574
+ },
+ "bySignal": {
+ "strongTld": 5574,
+ "sifaLocation": 32
+ }
+ },
+ {
+ "country": "de",
+ "total": 4331,
+ "confirmed": 4331,
+ "byRank": {
+ "1": 37,
+ "3": 4294
+ },
+ "bySignal": {
+ "strongTld": 4294,
+ "sifaLocation": 37
+ }
+ },
+ {
+ "country": "nl",
+ "total": 2710,
+ "confirmed": 2710,
+ "byRank": {
+ "1": 25,
+ "3": 1484,
+ "5": 1201
+ },
+ "bySignal": {
+ "strongTld": 1484,
+ "starterpack": 1201,
+ "sifaLocation": 25
+ }
+ },
+ {
+ "country": "fr",
+ "total": 2335,
+ "confirmed": 2335,
+ "byRank": {
+ "1": 26,
+ "3": 2309
+ },
+ "bySignal": {
+ "strongTld": 2309,
+ "sifaLocation": 26
+ }
+ },
+ {
+ "country": "ch",
+ "total": 917,
+ "confirmed": 917,
+ "byRank": {
+ "1": 8,
+ "3": 909
+ },
+ "bySignal": {
+ "strongTld": 909,
+ "sifaLocation": 8
+ }
+ },
+ {
+ "country": "se",
+ "total": 712,
+ "confirmed": 36,
+ "byRank": {
+ "1": 6,
+ "3": 30,
+ "6": 676
+ },
+ "bySignal": {
+ "weakTld": 676,
+ "strongTld": 30,
+ "sifaLocation": 6
+ }
+ },
+ {
+ "country": "at",
+ "total": 694,
+ "confirmed": 38,
+ "byRank": {
+ "1": 8,
+ "3": 30,
+ "6": 656
+ },
+ "bySignal": {
+ "weakTld": 656,
+ "strongTld": 30,
+ "sifaLocation": 8
+ }
+ },
+ {
+ "country": "dk",
+ "total": 667,
+ "confirmed": 667,
+ "byRank": {
+ "1": 3,
+ "3": 664
+ },
+ "bySignal": {
+ "strongTld": 664,
+ "sifaLocation": 3
+ }
+ },
+ {
+ "country": "no",
+ "total": 535,
+ "confirmed": 535,
+ "byRank": {
+ "1": 5,
+ "3": 530
+ },
+ "bySignal": {
+ "strongTld": 530,
+ "sifaLocation": 5
+ }
+ },
+ {
+ "country": "be",
+ "total": 525,
+ "confirmed": 525,
+ "byRank": {
+ "1": 14,
+ "3": 511
+ },
+ "bySignal": {
+ "strongTld": 511,
+ "sifaLocation": 14
+ }
+ },
+ {
+ "country": "it",
+ "total": 451,
+ "confirmed": 15,
+ "byRank": {
+ "1": 5,
+ "3": 10,
+ "6": 436
+ },
+ "bySignal": {
+ "weakTld": 436,
+ "strongTld": 10,
+ "sifaLocation": 5
+ }
+ },
+ {
+ "country": "fi",
+ "total": 433,
+ "confirmed": 433,
+ "byRank": {
+ "1": 1,
+ "3": 432
+ },
+ "bySignal": {
+ "strongTld": 432,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "pl",
+ "total": 385,
+ "confirmed": 385,
+ "byRank": {
+ "1": 1,
+ "3": 384
+ },
+ "bySignal": {
+ "strongTld": 384,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "is",
+ "total": 362,
+ "confirmed": 1,
+ "byRank": {
+ "3": 1,
+ "6": 361
+ },
+ "bySignal": {
+ "weakTld": 361,
+ "strongTld": 1
+ }
+ },
+ {
+ "country": "ie",
+ "total": 310,
+ "confirmed": 310,
+ "byRank": {
+ "1": 3,
+ "3": 307
+ },
+ "bySignal": {
+ "strongTld": 307,
+ "sifaLocation": 3
+ }
+ },
+ {
+ "country": "cz",
+ "total": 301,
+ "confirmed": 301,
+ "byRank": {
+ "1": 4,
+ "3": 297
+ },
+ "bySignal": {
+ "strongTld": 297,
+ "sifaLocation": 4
+ }
+ },
+ {
+ "country": "ru",
+ "total": 204,
+ "confirmed": 204,
+ "byRank": {
+ "1": 1,
+ "3": 203
+ },
+ "bySignal": {
+ "strongTld": 203,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "li",
+ "total": 170,
+ "confirmed": 170,
+ "byRank": {
+ "3": 170
+ },
+ "bySignal": {
+ "strongTld": 170
+ }
+ },
+ {
+ "country": "lv",
+ "total": 148,
+ "confirmed": 148,
+ "byRank": {
+ "3": 148
+ },
+ "bySignal": {
+ "strongTld": 148
+ }
+ },
+ {
+ "country": "pt",
+ "total": 138,
+ "confirmed": 138,
+ "byRank": {
+ "1": 6,
+ "3": 132
+ },
+ "bySignal": {
+ "strongTld": 132,
+ "sifaLocation": 6
+ }
+ },
+ {
+ "country": "ee",
+ "total": 134,
+ "confirmed": 134,
+ "byRank": {
+ "1": 1,
+ "3": 133
+ },
+ "bySignal": {
+ "strongTld": 133,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "si",
+ "total": 134,
+ "confirmed": 134,
+ "byRank": {
+ "1": 2,
+ "3": 132
+ },
+ "bySignal": {
+ "strongTld": 132,
+ "sifaLocation": 2
+ }
+ },
+ {
+ "country": "ro",
+ "total": 121,
+ "confirmed": 121,
+ "byRank": {
+ "1": 1,
+ "3": 120
+ },
+ "bySignal": {
+ "strongTld": 120,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "rs",
+ "total": 119,
+ "confirmed": 119,
+ "byRank": {
+ "3": 119
+ },
+ "bySignal": {
+ "strongTld": 119
+ }
+ },
+ {
+ "country": "mk",
+ "total": 101,
+ "confirmed": 101,
+ "byRank": {
+ "3": 101
+ },
+ "bySignal": {
+ "strongTld": 101
+ }
+ },
+ {
+ "country": "hu",
+ "total": 97,
+ "confirmed": 97,
+ "byRank": {
+ "1": 1,
+ "3": 96
+ },
+ "bySignal": {
+ "strongTld": 96,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "ua",
+ "total": 88,
+ "confirmed": 88,
+ "byRank": {
+ "3": 88
+ },
+ "bySignal": {
+ "strongTld": 88
+ }
+ },
+ {
+ "country": "al",
+ "total": 83,
+ "confirmed": 83,
+ "byRank": {
+ "3": 83
+ },
+ "bySignal": {
+ "strongTld": 83
+ }
+ },
+ {
+ "country": "gr",
+ "total": 83,
+ "confirmed": 83,
+ "byRank": {
+ "3": 83
+ },
+ "bySignal": {
+ "strongTld": 83
+ }
+ },
+ {
+ "country": "sk",
+ "total": 59,
+ "confirmed": 59,
+ "byRank": {
+ "1": 3,
+ "3": 56
+ },
+ "bySignal": {
+ "strongTld": 56,
+ "sifaLocation": 3
+ }
+ },
+ {
+ "country": "lu",
+ "total": 54,
+ "confirmed": 54,
+ "byRank": {
+ "1": 1,
+ "3": 53
+ },
+ "bySignal": {
+ "strongTld": 53,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "lt",
+ "total": 53,
+ "confirmed": 53,
+ "byRank": {
+ "1": 1,
+ "3": 52
+ },
+ "bySignal": {
+ "strongTld": 52,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "gl",
+ "total": 34,
+ "confirmed": 34,
+ "byRank": {
+ "3": 34
+ },
+ "bySignal": {
+ "strongTld": 34
+ }
+ },
+ {
+ "country": "ad",
+ "total": 28,
+ "confirmed": 28,
+ "byRank": {
+ "1": 1,
+ "3": 27
+ },
+ "bySignal": {
+ "strongTld": 27,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "by",
+ "total": 15,
+ "confirmed": 15,
+ "byRank": {
+ "3": 15
+ },
+ "bySignal": {
+ "strongTld": 15
+ }
+ },
+ {
+ "country": "hr",
+ "total": 14,
+ "confirmed": 14,
+ "byRank": {
+ "1": 1,
+ "3": 13
+ },
+ "bySignal": {
+ "strongTld": 13,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "fo",
+ "total": 12,
+ "confirmed": 12,
+ "byRank": {
+ "3": 12
+ },
+ "bySignal": {
+ "strongTld": 12
+ }
+ },
+ {
+ "country": "bg",
+ "total": 10,
+ "confirmed": 10,
+ "byRank": {
+ "1": 1,
+ "3": 9
+ },
+ "bySignal": {
+ "strongTld": 9,
+ "sifaLocation": 1
+ }
+ },
+ {
+ "country": "cy",
+ "total": 9,
+ "confirmed": 9,
+ "byRank": {
+ "3": 9
+ },
+ "bySignal": {
+ "strongTld": 9
+ }
+ },
+ {
+ "country": "mt",
+ "total": 9,
+ "confirmed": 9,
+ "byRank": {
+ "3": 9
+ },
+ "bySignal": {
+ "strongTld": 9
+ }
+ },
+ {
+ "country": "ba",
+ "total": 8,
+ "confirmed": 8,
+ "byRank": {
+ "3": 8
+ },
+ "bySignal": {
+ "strongTld": 8
+ }
+ },
+ {
+ "country": "mc",
+ "total": 1,
+ "confirmed": 1,
+ "byRank": {
+ "3": 1
+ },
+ "bySignal": {
+ "strongTld": 1
+ }
+ }
+ ],
+ "method": {
+ "attribution": "one DID, one country, by signal precedence",
+ "languageUsed": false,
+ "weakTldCountries": [
+ "at",
+ "es",
+ "is",
+ "it",
+ "se"
+ ]
+ }
+}
diff --git a/src/data/europe-stats.ts b/src/data/europe-stats.ts
new file mode 100644
index 0000000..98155ef
--- /dev/null
+++ b/src/data/europe-stats.ts
@@ -0,0 +1,86 @@
+// Per-country AT Protocol figures for atproto.eu — the aggregate the whole domain exists for.
+//
+// Produced by scripts/eu-stats-aggregate.py on infra we control and committed here as counts
+// only; DID lists never leave the NAS. Every account is attributed to exactly ONE country by
+// the precedence in scripts/attribution.py, so the per-country numbers are people, not rows,
+// and they sum to the total instead of exceeding it.
+//
+// Language is not an input. It cannot confirm a country.
+
+import raw from './europe-stats.json';
+import { existingCommunities } from './communities';
+
+export interface CountryStat {
+ /** ISO 3166-1 alpha-2, lowercase. */
+ country: string;
+ /** Accounts attributed to this country. */
+ total: number;
+ /** Of those, attributed by a signal at rank 5 or better. */
+ confirmed: number;
+ /** How many accounts came in at each precedence rank. The honesty column: a country resting
+ * on rank 6 is a country we cannot really vouch for. */
+ byRank: Record;
+ bySignal: Record;
+}
+
+export interface EuropeStats {
+ generatedAt: string;
+ /** Distinct accounts across Europe. Not a sum of overlapping country counts. */
+ people: number;
+ /** Accounts whose strongest signals disagreed on the country, so we placed them nowhere. */
+ contested: number;
+ countries: CountryStat[];
+ method: { attribution: string; languageUsed: boolean; weakTldCountries: string[] };
+}
+
+export const europeStats: EuropeStats = raw as EuropeStats;
+
+/** Countries ordered by what we can actually stand behind, not by headline total.
+ *
+ * Sorting by `total` puts Spain first on ~9.800 word-play .es domains, which would be the most
+ * prominent wrong number on the site. `confirmed` is the honest ranking. */
+export const byConfirmed = [...europeStats.countries].sort(
+ (a, b) => b.confirmed - a.confirmed || b.total - a.total,
+);
+
+export const withAnyData = byConfirmed.filter((c) => c.total > 0);
+
+/** Accounts we can stand behind, Europe-wide. */
+export const confirmedTotal = europeStats.countries.reduce((a, c) => a + c.confirmed, 0);
+
+/** Weak-ccTLD countries: the gap between total and confirmed is word-play registrations, so
+ * these need the caveat rendered next to the number rather than in a footnote. */
+export const isWeakTldCountry = (cc: string) =>
+ europeStats.method.weakTldCountries.includes(cc);
+
+export function statFor(cc: string): CountryStat | undefined {
+ return europeStats.countries.find((c) => c.country === cc);
+}
+
+/** The national community for a country, if one exists. A city community is never returned
+ * here: it does not speak for the country and must not be shown as though it does. */
+export function communityFor(cc: string) {
+ return existingCommunities.find((c) => c.country === cc && c.scope === 'national') ?? null;
+}
+
+/** City-level communities within a country. */
+export function citiesFor(cc: string) {
+ return existingCommunities.filter((c) => c.country === cc && c.scope === 'city');
+}
+
+/** Any community at all, national or city — for "is this country empty?" checks. */
+export function hasAnyCommunity(cc: string) {
+ return existingCommunities.some((c) => c.country === cc);
+}
+
+/** Country name in the reader's language, from CLDR. Never a hand-maintained table: 27
+ * countries times four languages is work the runtime already does. Bare nominative only —
+ * inside a sentence a country needs an article and a preposition CLDR does not supply, so
+ * prose uses a hand-written phrase instead. */
+export function countryName(cc: string, lang = 'en'): string {
+ try {
+ return new Intl.DisplayNames([lang], { type: 'region' }).of(cc.toUpperCase()) ?? cc.toUpperCase();
+ } catch {
+ return cc.toUpperCase();
+ }
+}
diff --git a/src/pages/[...lang]/countries.astro b/src/pages/[...lang]/countries.astro
new file mode 100644
index 0000000..3815271
--- /dev/null
+++ b/src/pages/[...lang]/countries.astro
@@ -0,0 +1,132 @@
+---
+import Base from '../../layouts/Base.astro';
+import { localePaths, localeHref } from '../../i18n/paths';
+import { getScope } from '../../config/scopes';
+import {
+ europeStats, byConfirmed, confirmedTotal, isWeakTldCountry, countryName, communityFor, citiesFor,
+} from '../../data/europe-stats';
+import { existingCommunities } from '../../data/communities';
+
+// Aggregate-only. On .nl and .be getStaticPaths returns nothing, so the route is not built at
+// all — no dead link, no empty page, no redirect.
+export const getStaticPaths = () =>
+ getScope(import.meta.env.PUBLIC_SITE_SCOPE).aggregate ? localePaths() : [];
+
+const { lang } = Astro.props;
+const nf = (n: number) => n.toLocaleString('en-GB').replace(/,/g, '.');
+
+const withData = byConfirmed.filter((c) => c.total > 0);
+const communities = existingCommunities.length;
+---
+
+
+
AT Protocol across Europe
+
+
+ {nf(confirmedTotal)} accounts we can place in a European country, across
+ {withData.length} countries. Each account is counted once: where several
+ signals disagree we place it nowhere rather than guess, which is why these numbers add up
+ instead of overlapping.
+
+
+
+ This is a floor, not a census. AT Protocol has no country field, so we can only count people
+ who left a trace we can read — a country domain in their handle, a location they wrote in
+ their profile, a curated list they were added to. The real numbers are higher everywhere.
+ We do not use language as evidence. Most European languages cross borders,
+ so posting in German says nothing about living in Germany.
+
+
+
Communities
+
+ Where a community already exists we link to it — the point of this page is to help you find
+ your people, not to be your people. City communities cover a city, not a country, so they are
+ listed as what they are.
+
+ ⚠ marks countries whose domain is widely registered for English word-play rather than as a
+ statement about the country — .es, .it, .at,
+ .se and .is. Those matches are counted but ranked low, which is why
+ a country can show a small number here and a much larger raw match count. Spain is the clearest
+ case: about 9.800 .es handles, almost none of them Spanish.
+
+
+ Updated {europeStats.generatedAt.slice(0, 10)}. Method:
+ {' '}{europeStats.method.attribution}. {europeStats.contested} accounts were contested and
+ are counted in no country.
+
+
+
+
diff --git a/src/pages/[...lang]/countries/[cc].astro b/src/pages/[...lang]/countries/[cc].astro
new file mode 100644
index 0000000..b41f598
--- /dev/null
+++ b/src/pages/[...lang]/countries/[cc].astro
@@ -0,0 +1,161 @@
+---
+import Base from '../../../layouts/Base.astro';
+import { LOCALES, DEFAULT_LOCALE, localeHref } from '../../../i18n/paths';
+import { getScope } from '../../../config/scopes';
+import {
+ europeStats, isWeakTldCountry, countryName, communityFor, citiesFor,
+} from '../../../data/europe-stats';
+
+// One page per country that has any data, per locale. Aggregate scope only.
+export function getStaticPaths() {
+ const scope = getScope(import.meta.env.PUBLIC_SITE_SCOPE);
+ if (!scope.aggregate) return [];
+ const paths = [];
+ for (const locale of LOCALES) {
+ for (const c of europeStats.countries) {
+ if (c.total > 0) {
+ paths.push({
+ params: { lang: locale === DEFAULT_LOCALE ? undefined : locale, cc: c.country },
+ props: { lang: locale, stat: c },
+ });
+ }
+ }
+ }
+ return paths;
+}
+
+const { lang, stat } = Astro.props;
+const cc = stat.country;
+const name = countryName(cc, lang);
+const nf = (n: number) => n.toLocaleString('en-GB').replace(/,/g, '.');
+
+const community = communityFor(cc);
+const cities = citiesFor(cc);
+const weak = isWeakTldCountry(cc) && stat.total > stat.confirmed;
+
+// What each precedence rank means, in the reader's terms rather than ours.
+const RANK_LABEL: Record = {
+ '1': 'a location they wrote in their own profile',
+ '2': 'a city or region domain in their handle',
+ '3': 'a country domain in their handle',
+ '4': 'a community membership record',
+ '5': 'a curated starter pack someone added them to',
+ '6': 'a country domain that is often used for word-play, so it is weak evidence',
+};
+const ranks = Object.entries(stat.byRank).sort(([a], [b]) => Number(a) - Number(b));
+---
+
+
+
+ {nf(stat.confirmed)} accounts we can place in {name}.
+ {stat.confirmed !== stat.total && (
+ <> {nf(stat.total - stat.confirmed)} more matched on weak evidence and are not counted here.>
+ )}
+
+
+ {weak && (
+
+ Read this number carefully. The .{cc} domain is widely
+ registered for English word-play rather than as a statement about {name}, so the raw match
+ count ({nf(stat.total)}) is far higher than what we can stand behind ({nf(stat.confirmed)}).
+ We count those matches at the lowest rank rather than throwing them away, but they should
+ not be read as {name} adoption.
+
+ )}
+
+
How these accounts were found
+
+ {ranks.map(([rank, n]) => (
+
{nf(n)} — {RANK_LABEL[rank] ?? `rank ${rank}`}
+ ))}
+
+
+ Each account is counted once, under its strongest signal only. We never use language:
+ posting in a language says nothing about which country someone is in.
+
+ Run by its own people, not by us. We link to it and stay out of the way.
+
+ )}
+
+ ) : (
+
+
+ {cities.length
+ ? `No national community for ${name} yet`
+ : `Nobody runs a community for ${name} yet`}
+
+
+ Would you like to run one? There are {nf(stat.confirmed)} accounts here already, and this
+ page is the only thing standing in for a community that should be run by people who live
+ there.
+
+
+ We can hand over the data behind this page, the collectors that produce it, and the site
+ itself — it is open source. What we cannot do is be from {name}.
+
+
+ )}
+
+
+
--
2.54.0
From 118c478c83682d980bfd672f8f4dcaed84dd67f1 Mon Sep 17 00:00:00 2001
From: Guido X Jansen
Date: Fri, 4 Sep 2026 21:01:18 +0200
Subject: [PATCH 3/3] fix(stats): one attribution pass, so .nl and .eu cannot
disagree
atproto.nl said 3.353 accounts in the Netherlands while atproto.eu said 2.710,
because two aggregators were independently deciding what "in the Netherlands"
means. Publishing two figures for one country on two of our own sites is the
credibility problem, regardless of which is closer.
eu-stats-aggregate.py now writes attributed..dids for all 44 countries, and
nl-stats-aggregate.py reads its own row instead of recomputing a union. Same
number by construction rather than by luck; `attributionSource` records which
path produced it, so a silent fallback is visible in the data.
Making them agree exposed that agreement needs the same INPUTS too, not just
the same method: the floor came out at 3.580 against sources summing to 3.515,
because attribution read handles.nl.dids (the 45-country scan) while .nl still
read nl-handles.dids (the retired NL-only one). The union invariant caught it.
nlHandle now points at the shared file, with the old one as a fallback.
Both snapshots regenerated against the completed PLC pass -- 120.853 pages,
finished 2026-09-04 -- so these are the first numbers that are neither partial
nor overcounting. Europe: 49.833 people across 44 countries. NL: 3.580 on both
sites.
Also adds companyDomain as its own signal kind at the same rank as a personal
ccTLD handle, so the NL breakdown can still distinguish an organisation's
website domain from someone's own handle.
---
.../__pycache__/attribution.cpython-314.pyc | Bin 13359 -> 13386 bytes
scripts/attribution.py | 3 +-
scripts/eu-stats-aggregate.py | 23 +-
scripts/nl-stats-aggregate.py | 64 ++-
src/data/europe-stats.json | 487 ++++++++++--------
src/data/nl-stats.json | 45 +-
src/pages/[...lang]/countries/[cc].astro | 2 +-
7 files changed, 359 insertions(+), 265 deletions(-)
diff --git a/scripts/__pycache__/attribution.cpython-314.pyc b/scripts/__pycache__/attribution.cpython-314.pyc
index bc9d9ca540aec5404ff02eb594dabeae783f9422..f6c07363cb4e0814800ef455378e700947bc5308 100644
GIT binary patch
delta 2012
zcmZ3VaVkSyn~#@^fq{YHipcD&0u=^^#~=<2i$NHSpI=Q>52&gBtcW;6kSK;MZ;+TcLy)*QLykZRUyy{FI75&mn8P0=CC(5e4Q2}j$%r!q
z$%5H}u|h#|lVup4S>=Nif)ppGFs@}(+APF$iItBxIX|}`F|X1kKQ}QmZ}MaI3P$tE
zF&u9h=T7$GY-BA;P0r7oe3sLS(QYya*HcE($?v!{BxD&F7;do?CFW({;ttMC&r8hF
z195C8>v4xON>6U%PB2vmDG`m2FD^+e$xMzbl4f9FC{h3sAcKm8
zK`anqK3SSahYc(tJlU5=gVA+z8BdF@2*@brl+2VWuHup+g&GCF{Jhj6P!v>gW#*N@
z1VAwOMPPg7CZFMzW%Qr?idUXdVKNur
zVl9xFMIdu-2|4@wxp@Zpy1K;sI!43?Ir@1QslxQD70FJ%&Zo-gH~BB0Z3@`EATRl8
z@)T);EY$)L+8{y)MCgGCQxL%l@(F86X+chEkvT{}A4J%K2m=rSG6gAMj6h-_!gcaI
zet$^77)}1n?-B_1pfN}q960R7;7BO0;>pP@E{TRjNo)}~1VH+6h5*O}IR*xX;K>~V
zT8s*tHw(OGmbGSJV9;a)hl3{5EhasKTa3lUpcJa6P$WBfw}=j7!sHhs>Rw~Ker1WQtCaY>QuYl=z%@Rbc;2!BsI4fY>y^$Q6MNRKrXw*Qk3S*MfQfvbyIZ!dwz;K}GgyPZCgK6OOp~-lQIXkto$ak`@oQqpL
z$o^;$0Wzj20i+3(&eb64JP9O|%)r3V0g~WjU|?uq*pYNVaBu1X_q};L&&rk$bX;whJ$}uVu
zU|?W?a*V53L4w6aJd+n{M@spEOauiV#LIAx6s1oV(Fu^r1WAJ&Qi&5+{iFS9MigR(AMVlsoybj$(Z!NCP;&LE)mwR8$TU
zr~>6H0Z2g#$~NHq2g!+`ELWriQUrDpD7%6kc#F@mxHz?_Br`wHwWuh+s7M8*1{9)2
zptN380OEoI_ZDkGQD$C=Ci5-E;3AzOP=K&QqM#C-N8k~|61+2eAxnEl5w1=HwYhp~~QJ0+*}cLV?33H$SB`
zC)KWK3IhWJC}kJROcpRU(-NK#c!Nb~g5wPq!3l;pSa>@MZ?FhVP(%