From ecb5af374b35df496c0b3d3f3e7d63e8bcd32d5d Mon Sep 17 00:00:00 2001 From: Guido Jansen Date: Sun, 13 Sep 2026 14:48:24 +0200 Subject: [PATCH] feat(stats): language-weighted per-country estimate on the hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add eu-estimate.py + eu-lang-speakers.json: estimate(cc)=K*Σ_lang authors(lang)* speakerWeight(cc|lang), K calibrated so NL matches its own model. Merges an `estimate` per country + `peopleEstimate` into europe-stats.json. HomePage hero now leads with the estimate for be/eu/no (like nl) and falls back to the attributed count otherwise. English- first gb/ie are excluded (English untracked); es/pt under-covered. Wired into the NAS job after eu-stats-aggregate; language is estimate-layer only, never confirms a country. --- scripts/eu-estimate.py | 188 ++++++++++++++++++++++++++++ scripts/eu-lang-speakers.json | 37 ++++++ src/components/pages/HomePage.astro | 28 +++-- src/data/europe-stats.json | 137 +++++++++++++------- src/data/europe-stats.ts | 13 ++ src/i18n/ui.ts | 4 + 6 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 scripts/eu-estimate.py create mode 100644 scripts/eu-lang-speakers.json diff --git a/scripts/eu-estimate.py b/scripts/eu-estimate.py new file mode 100644 index 0000000..77e0d18 --- /dev/null +++ b/scripts/eu-estimate.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Per-country Bluesky-population estimate, language-weighted, merged into europe-stats.json. + +The attributed counts in europe-stats.json are a hard floor (accounts we can place in a +country by a deliberate signal). This adds an ESTIMATE of the real active population per +country, the same kind of modelled number atproto.nl already shows, so the .be/.eu/.no +heroes can lead with an estimate instead of the raw floor. + +Model: + estimate(cc) = K * Σ_lang authors(lang) * weight(cc | lang) + + - authors(lang): distinct accounts posting `lang` in the collector window + (estimate-nl-users.py `byLang[...].distinct_authors`). null (untracked) langs are skipped. + - weight(cc | lang): share of `lang`'s European speakers in country cc (eu-lang-speakers.json). + - K: one calibration constant, chosen so estimate(nl) equals the NL model's own figure + (nl-stats.json estimate.modelledNl). Every other country is expressed in the same units as + the number atproto.nl already publishes, so the four sites agree on method. + +LANGUAGE IS NOT COUNTRY. This is the estimate layer only; nothing here is "confirmed" and no +DID is placed by language. Each estimate is floored at the country's attributed `total` (an +estimate below the count we can actually stand behind would be nonsense). Castilian Spain and +Portugal are under-covered (es/pt are not author-tracked); non-EU Balkan/Ukrainian languages are +omitted. Both are stated on the page. See eu-lang-speakers.json for the weight provenance. + +Usage: + python3 scripts/eu-estimate.py --stats europe-stats.json --collector nl-live.json \ + --weights scripts/eu-lang-speakers.json --nl-stats nl-stats.json + python3 scripts/eu-estimate.py --self-test +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + + +def load_authors(collector_path): + """{lang: distinct_authors} for langs the collector tracked (non-null).""" + d = json.load(open(collector_path)) + out = {} + for lang, v in (d.get("byLang") or {}).items(): + a = v.get("distinct_authors") + if isinstance(a, int) and a > 0: + out[lang] = a + return out + + +def raw_by_country(authors, weights): + """raw(cc) = Σ_lang authors(lang) * weight(cc|lang), before calibration.""" + raw: dict[str, float] = {} + for lang, n in authors.items(): + for cc, w in (weights.get(lang) or {}).items(): + raw[cc] = raw.get(cc, 0.0) + n * w + return raw + + +def estimate(authors, weights, nl_anchor, anchor_cc="nl"): + """Calibrated per-country estimate. K makes estimate(anchor_cc) == nl_anchor.""" + raw = raw_by_country(authors, weights) + base = raw.get(anchor_cc, 0.0) + if base <= 0: + raise ValueError(f"no language signal for the anchor country {anchor_cc!r}; cannot calibrate") + k = nl_anchor / base + return {cc: r * k for cc, r in raw.items()}, k + + +# English-first countries: English is not author-tracked (spoken everywhere), so a +# language-weighted estimate can only see a tiny non-English minority (Welsh, Irish) and would +# wildly undercount them. We give them NO estimate and leave them out of the aggregate headline +# rather than publish a number we know is meaningless. They still show their attributed count. +ESTIMATE_EXCLUDE = {"gb", "ie"} + + +def merge_into_stats(stats, est, exclude=ESTIMATE_EXCLUDE): + """Add `estimate` to each covered country (floored at its attributed total) + an aggregate. + + Excluded (English-first) countries get no estimate and are left out of peopleEstimate. A + country with an attributed floor but no language estimate keeps its floor as the estimate, + never less. + """ + per = {c["country"]: c for c in stats.get("countries", [])} + total_est = 0 + seen = set() + for cc, c in per.items(): + seen.add(cc) + if cc in exclude: + c.pop("estimate", None) + continue + e = max(int(round(est.get(cc, 0))), int(c.get("total", 0))) # never below the floor + c["estimate"] = e + total_est += e + # A language-only country (estimate but no attributed row) still counts, unless excluded. + for cc, val in est.items(): + if cc not in seen and cc not in exclude: + total_est += int(round(val)) + stats["peopleEstimate"] = total_est + stats["estimateMethod"] = { + "model": "K * Σ_lang authors(lang) * speakerWeight(cc|lang)", + "calibratedTo": "atproto.nl modelledNl", + "languageUsed": True, + "excludes": sorted(exclude), + "note": "estimate layer only; language never confirms a country. English-first " + "countries (gb, ie) are excluded (English untracked); es/pt under-covered.", + } + return stats + + +def _self_test(): + weights = { + "nl": {"nl": 0.73, "be": 0.27}, + "fr": {"fr": 0.89, "be": 0.07}, + "de": {"de": 1.0}, + } + authors = {"nl": 1000, "fr": 2000, "de": 5000, "en": 9999} # en absent from weights -> ignored + est, k = estimate(authors, weights, nl_anchor=250000, anchor_cc="nl") + # raw(nl) = 1000*0.73 = 730 -> K = 250000/730 + assert abs(k - 250000 / 730) < 1e-6, k + assert abs(est["nl"] - 250000) < 1, est # anchor reproduced exactly + # be = (1000*0.27 + 2000*0.07) * K = (270+140)*K + assert abs(est["be"] - 410 * k) < 1e-6, est + # de present, en (no weights) contributed nothing + assert "de" in est and est["de"] == 5000 * k + + stats = {"countries": [ + {"country": "nl", "total": 3000}, + {"country": "be", "total": 900}, + {"country": "de", "total": 7081}, + {"country": "es", "total": 11666}, # no language estimate -> floored at total + {"country": "gb", "total": 8738}, # English-first -> excluded, no estimate + ]} + merged = merge_into_stats(stats, est) + per = {c["country"]: c for c in merged["countries"]} + assert per["nl"]["estimate"] == 250000, per["nl"] + assert per["es"]["estimate"] == 11666, per["es"] # floor kept, no lang signal + assert per["de"]["estimate"] == max(int(round(5000 * k)), 7081), per["de"] + assert "estimate" not in per["gb"], per["gb"] # excluded English-first + # peopleEstimate = every covered country's estimate + language-only countries (fr here), + # and must NOT include the excluded gb. + covered = sum(per[c]["estimate"] for c in ("nl", "be", "de", "es")) + int(round(est["fr"])) + assert merged["peopleEstimate"] == covered, (merged["peopleEstimate"], covered) + assert merged["estimateMethod"]["languageUsed"] is True + assert merged["estimateMethod"]["excludes"] == ["gb", "ie"] + print("eu-estimate self-test OK") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--stats", default="europe-stats.json", help="europe-stats.json to merge into") + ap.add_argument("--collector", default="nl-live.json", help="collector full-result JSON (byLang)") + ap.add_argument("--weights", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), + "eu-lang-speakers.json")) + ap.add_argument("--nl-stats", default="nl-stats.json", help="reads estimate.modelledNl") + ap.add_argument("--nl-anchor", type=int, default=None, help="override the NL anchor directly") + ap.add_argument("--out", default=None, help="default: overwrite --stats") + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + + if args.self_test: + _self_test() + return + + anchor = args.nl_anchor + if anchor is None: + anchor = int(json.load(open(args.nl_stats)).get("estimate", {}).get("modelledNl", 0)) + if not anchor: + print("no NL anchor (estimate.modelledNl missing and --nl-anchor unset)", file=sys.stderr) + sys.exit(1) + + authors = load_authors(args.collector) + weights = json.load(open(args.weights)).get("weights", {}) + est, k = estimate(authors, weights, anchor) + stats = json.load(open(args.stats)) + merge_into_stats(stats, est) + + out = args.out or args.stats + tmp = out + ".tmp" + with open(tmp, "w") as f: + f.write(json.dumps(stats, indent=2) + "\n") + os.replace(tmp, out) + top = sorted(((c["country"], c.get("estimate", 0)) for c in stats["countries"]), + key=lambda kv: -kv[1])[:8] + pretty = ", ".join(f"{cc}={n:,}" for cc, n in top) + print(f"Wrote {out}: K={k:.1f}, peopleEstimate={stats['peopleEstimate']:,}; {pretty}") + + +if __name__ == "__main__": + main() diff --git a/scripts/eu-lang-speakers.json b/scripts/eu-lang-speakers.json new file mode 100644 index 0000000..a06a0e3 --- /dev/null +++ b/scripts/eu-lang-speakers.json @@ -0,0 +1,37 @@ +{ + "_note": "Language -> country weight table for the per-country estimate (eu-estimate.py). weight[lang][cc] = the share of language `lang`'s European speakers who live in country `cc`; each language's weights sum to ~1 over the countries we cover. Derived from native-speaker populations (Eurobarometer 386 'Europeans and their Languages' mother-tongue shares x Eurostat population), rounded. This is the ESTIMATE layer only: language never confirms a country (see decisions/2026-09-02-country-attribution-precedence.md). English is excluded (spoken everywhere). es/pt are not author-tracked by the collector (non-EU-dominated), so Castilian Spain and Portugal are under-covered here and lean on ca/eu/gl and pt-less data respectively -- a known limitation, stated on the page. Non-EU Balkan/Ukrainian languages (sq, sr, bs, mk, uk) have no clean single home in our coverage and are omitted. Refine against fuller EB386 tables over time.", + "_provenance": "native-speaker populations, EB386 (2012) + Eurostat; v1 2026-09-13", + "weights": { + "nl": { "nl": 0.73, "be": 0.27 }, + "fr": { "fr": 0.89, "be": 0.07, "ch": 0.03, "lu": 0.01 }, + "de": { "de": 0.85, "at": 0.09, "ch": 0.06 }, + "it": { "it": 0.98, "ch": 0.02 }, + "sv": { "se": 0.97, "fi": 0.03 }, + "fi": { "fi": 1.0 }, + "pl": { "pl": 1.0 }, + "cs": { "cz": 1.0 }, + "sk": { "sk": 1.0 }, + "hu": { "hu": 0.9, "ro": 0.06, "sk": 0.04 }, + "ro": { "ro": 1.0 }, + "el": { "gr": 0.92, "cy": 0.08 }, + "da": { "dk": 1.0 }, + "nb": { "no": 1.0 }, + "nn": { "no": 1.0 }, + "no": { "no": 1.0 }, + "is": { "is": 1.0 }, + "ga": { "ie": 1.0 }, + "cy": { "gb": 1.0 }, + "mt": { "mt": 1.0 }, + "lb": { "lu": 1.0 }, + "fo": { "dk": 1.0 }, + "sl": { "si": 1.0 }, + "hr": { "hr": 1.0 }, + "et": { "ee": 1.0 }, + "lv": { "lv": 1.0 }, + "lt": { "lt": 1.0 }, + "bg": { "bg": 1.0 }, + "ca": { "es": 1.0 }, + "eu": { "es": 1.0 }, + "gl": { "es": 1.0 } + } +} diff --git a/src/components/pages/HomePage.astro b/src/components/pages/HomePage.astro index 778b8ab..24b81fd 100644 --- a/src/components/pages/HomePage.astro +++ b/src/components/pages/HomePage.astro @@ -33,9 +33,10 @@ const locations = members.map((m) => m.sifa?.location).filter((l): l is string = const scopeId = resolveScopeId(import.meta.env.PUBLIC_SITE_SCOPE); const scope = getScope(scopeId); -// Headline community number by scope: .nl leads with the modelled estimate; .be/.eu use the -// country-attributed counts from europe-stats (deduped; language is never an input). Link target: -// .nl -> national methodology page, .eu -> aggregate directory, .be -> none (no stats page yet). +// Headline community number by scope. Every scope now leads with a modelled ESTIMATE where we +// have one (the same language-weighted model, calibrated to the NL figure), and falls back to the +// deduplicated attributed count where we don't. Language feeds the estimate layer only; it never +// confirms a country. Link target: .nl -> its methodology page, .eu -> the aggregate directory. let headlineNum: string; let headlineLabel: string; let headlineHref: string | null; @@ -44,13 +45,26 @@ if (scopeId === 'nl') { headlineLabel = t('stats.estimate'); headlineHref = `${p}/community-size`; } else if (scope.aggregate) { - headlineNum = europeStats.people.toLocaleString('nl-NL'); - headlineLabel = t('stats.count'); + // .eu: the aggregate estimate covers European-language communities (English-first UK/IE are + // excluded because English is untracked). Fall back to the attributed count until it's computed. + if (europeStats.peopleEstimate) { + headlineNum = '~' + europeStats.peopleEstimate.toLocaleString('nl-NL'); + headlineLabel = t('stats.estimate.eu'); + } else { + headlineNum = europeStats.people.toLocaleString('nl-NL'); + headlineLabel = t('stats.count'); + } headlineHref = `${p}/countries`; } else { const cc = scope.countries === 'all' ? '' : scope.countries[0]; - headlineNum = (statFor(cc)?.total ?? 0).toLocaleString('nl-NL'); - headlineLabel = t('stats.count'); + const s = statFor(cc); + if (s?.estimate && s.estimate > s.total) { + headlineNum = '~' + s.estimate.toLocaleString('nl-NL'); + headlineLabel = t('stats.estimate'); + } else { + headlineNum = (s?.total ?? 0).toLocaleString('nl-NL'); + headlineLabel = t('stats.count'); + } headlineHref = null; } diff --git a/src/data/europe-stats.json b/src/data/europe-stats.json index b63671c..c551f56 100644 --- a/src/data/europe-stats.json +++ b/src/data/europe-stats.json @@ -18,7 +18,8 @@ "verifier": 103, "companyDomain": 84, "sifaLocation": 12 - } + }, + "estimate": 83965 }, { "country": "gb", @@ -46,7 +47,8 @@ "strongTld": 6987, "companyDomain": 57, "sifaLocation": 37 - } + }, + "estimate": 1022996 }, { "country": "fr", @@ -62,7 +64,8 @@ "verifier": 1623, "companyDomain": 45, "sifaLocation": 26 - } + }, + "estimate": 669655 }, { "country": "nl", @@ -78,7 +81,8 @@ "starterpack": 1178, "companyDomain": 58, "sifaLocation": 25 - } + }, + "estimate": 250000 }, { "country": "at", @@ -93,7 +97,8 @@ "weakTld": 3176, "companyDomain": 30, "sifaLocation": 8 - } + }, + "estimate": 108317 }, { "country": "be", @@ -109,7 +114,8 @@ "strongTld": 783, "sifaLocation": 14, "companyDomain": 13 - } + }, + "estimate": 145135 }, { "country": "ch", @@ -123,7 +129,8 @@ "strongTld": 1413, "companyDomain": 27, "sifaLocation": 8 - } + }, + "estimate": 98249 }, { "country": "se", @@ -138,7 +145,8 @@ "weakTld": 1016, "companyDomain": 30, "sifaLocation": 6 - } + }, + "estimate": 104606 }, { "country": "dk", @@ -152,7 +160,8 @@ "strongTld": 908, "companyDomain": 6, "sifaLocation": 3 - } + }, + "estimate": 42898 }, { "country": "fi", @@ -166,7 +175,8 @@ "strongTld": 764, "companyDomain": 9, "sifaLocation": 1 - } + }, + "estimate": 84653 }, { "country": "no", @@ -180,7 +190,8 @@ "strongTld": 751, "companyDomain": 8, "sifaLocation": 5 - } + }, + "estimate": 66137 }, { "country": "it", @@ -195,7 +206,8 @@ "weakTld": 745, "companyDomain": 10, "sifaLocation": 5 - } + }, + "estimate": 169797 }, { "country": "is", @@ -208,7 +220,8 @@ "bySignal": { "weakTld": 550, "companyDomain": 1 - } + }, + "estimate": 4536 }, { "country": "pl", @@ -222,7 +235,8 @@ "strongTld": 515, "companyDomain": 9, "sifaLocation": 1 - } + }, + "estimate": 76563 }, { "country": "cz", @@ -236,7 +250,8 @@ "strongTld": 483, "companyDomain": 14, "sifaLocation": 4 - } + }, + "estimate": 49504 }, { "country": "ie", @@ -263,7 +278,8 @@ "bySignal": { "strongTld": 388, "sifaLocation": 1 - } + }, + "estimate": 389 }, { "country": "ro", @@ -276,7 +292,8 @@ "bySignal": { "strongTld": 324, "sifaLocation": 1 - } + }, + "estimate": 5813 }, { "country": "li", @@ -287,7 +304,8 @@ }, "bySignal": { "strongTld": 252 - } + }, + "estimate": 252 }, { "country": "si", @@ -301,7 +319,8 @@ "strongTld": 248, "sifaLocation": 2, "companyDomain": 1 - } + }, + "estimate": 4775 }, { "country": "pt", @@ -315,7 +334,8 @@ "strongTld": 225, "sifaLocation": 6, "companyDomain": 4 - } + }, + "estimate": 235 }, { "country": "ee", @@ -329,7 +349,8 @@ "strongTld": 231, "sifaLocation": 1, "companyDomain": 1 - } + }, + "estimate": 3024 }, { "country": "rs", @@ -340,7 +361,8 @@ }, "bySignal": { "strongTld": 196 - } + }, + "estimate": 196 }, { "country": "hu", @@ -353,7 +375,8 @@ "bySignal": { "strongTld": 175, "sifaLocation": 1 - } + }, + "estimate": 20343 }, { "country": "lv", @@ -364,7 +387,8 @@ }, "bySignal": { "strongTld": 176 - } + }, + "estimate": 4139 }, { "country": "gr", @@ -375,7 +399,8 @@ }, "bySignal": { "strongTld": 134 - } + }, + "estimate": 29728 }, { "country": "ua", @@ -387,7 +412,8 @@ "bySignal": { "strongTld": 122, "companyDomain": 1 - } + }, + "estimate": 123 }, { "country": "al", @@ -398,7 +424,8 @@ }, "bySignal": { "strongTld": 118 - } + }, + "estimate": 118 }, { "country": "mk", @@ -409,7 +436,8 @@ }, "bySignal": { "strongTld": 108 - } + }, + "estimate": 108 }, { "country": "sk", @@ -422,7 +450,8 @@ "bySignal": { "strongTld": 99, "sifaLocation": 3 - } + }, + "estimate": 9340 }, { "country": "lu", @@ -435,7 +464,8 @@ "bySignal": { "strongTld": 99, "sifaLocation": 1 - } + }, + "estimate": 7604 }, { "country": "lt", @@ -448,7 +478,8 @@ "bySignal": { "strongTld": 80, "sifaLocation": 1 - } + }, + "estimate": 716 }, { "country": "ad", @@ -461,7 +492,8 @@ "bySignal": { "strongTld": 55, "sifaLocation": 1 - } + }, + "estimate": 56 }, { "country": "gl", @@ -472,7 +504,8 @@ }, "bySignal": { "strongTld": 44 - } + }, + "estimate": 44 }, { "country": "by", @@ -483,7 +516,8 @@ }, "bySignal": { "strongTld": 20 - } + }, + "estimate": 20 }, { "country": "hr", @@ -497,7 +531,8 @@ "strongTld": 16, "sifaLocation": 1, "companyDomain": 1 - } + }, + "estimate": 5014 }, { "country": "mt", @@ -508,7 +543,8 @@ }, "bySignal": { "strongTld": 18 - } + }, + "estimate": 80 }, { "country": "fo", @@ -519,7 +555,8 @@ }, "bySignal": { "strongTld": 16 - } + }, + "estimate": 16 }, { "country": "ba", @@ -530,7 +567,8 @@ }, "bySignal": { "strongTld": 15 - } + }, + "estimate": 15 }, { "country": "bg", @@ -544,7 +582,8 @@ "strongTld": 13, "sifaLocation": 1, "companyDomain": 1 - } + }, + "estimate": 7083 }, { "country": "cy", @@ -555,7 +594,8 @@ }, "bySignal": { "strongTld": 12 - } + }, + "estimate": 2585 }, { "country": "sm", @@ -566,7 +606,8 @@ }, "bySignal": { "strongTld": 4 - } + }, + "estimate": 4 }, { "country": "mc", @@ -577,7 +618,8 @@ }, "bySignal": { "strongTld": 1 - } + }, + "estimate": 1 } ], "method": { @@ -590,5 +632,16 @@ "it", "se" ] + }, + "peopleEstimate": 3078832, + "estimateMethod": { + "model": "K * \u03a3_lang authors(lang) * speakerWeight(cc|lang)", + "calibratedTo": "atproto.nl modelledNl", + "languageUsed": true, + "excludes": [ + "gb", + "ie" + ], + "note": "estimate layer only; language never confirms a country. English-first countries (gb, ie) are excluded (English untracked); es/pt under-covered." } } diff --git a/src/data/europe-stats.ts b/src/data/europe-stats.ts index 98155ef..1218ec6 100644 --- a/src/data/europe-stats.ts +++ b/src/data/europe-stats.ts @@ -21,6 +21,9 @@ export interface CountryStat { * on rank 6 is a country we cannot really vouch for. */ byRank: Record; bySignal: Record; + /** Modelled active-population estimate (language-weighted, calibrated to the NL model). Absent + * for English-first countries (gb, ie) where language tracking cannot see the population. */ + estimate?: number; } export interface EuropeStats { @@ -31,6 +34,16 @@ export interface EuropeStats { contested: number; countries: CountryStat[]; method: { attribution: string; languageUsed: boolean; weakTldCountries: string[] }; + /** Aggregate modelled estimate across covered countries (excludes English-first gb/ie). Absent + * until eu-estimate.py has run. See estimateMethod for the caveats. */ + peopleEstimate?: number; + estimateMethod?: { + model: string; + calibratedTo: string; + languageUsed: boolean; + excludes: string[]; + note: string; + }; } export const europeStats: EuropeStats = raw as EuropeStats; diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts index f85b08e..b39fd85 100644 --- a/src/i18n/ui.ts +++ b/src/i18n/ui.ts @@ -46,6 +46,7 @@ export const ui = { 'stats.cities': 'Cities', 'stats.users': 'Active users', 'stats.estimate': '{inCountry} (est.)', + 'stats.estimate.eu': 'European-language communities (est.)', 'stats.count': '{inCountry}', 'stats.heading': 'Users', 'members.viewAll': 'View all members', @@ -190,6 +191,7 @@ export const ui = { 'stats.cities': 'Steden', 'stats.users': 'Actieve gebruikers', 'stats.estimate': '{inCountry} (schatting)', + 'stats.estimate.eu': 'Europese taalgemeenschappen (schatting)', 'stats.count': '{inCountry}', 'stats.heading': 'Gebruikers', 'members.viewAll': 'Alle leden bekijken', @@ -333,6 +335,7 @@ export const ui = { 'stats.cities': 'Villes', 'stats.users': 'Utilisateurs actifs', 'stats.estimate': '{inCountry} (est.)', + 'stats.estimate.eu': 'communautés européennes par langue (est.)', 'stats.count': '{inCountry}', 'stats.heading': 'Utilisateurs', 'members.viewAll': 'Voir tous les membres', @@ -475,6 +478,7 @@ export const ui = { 'stats.cities': 'Byer', 'stats.users': 'Aktive brukere', 'stats.estimate': '{inCountry} (anslag)', + 'stats.estimate.eu': 'europeiske språksamfunn (anslag)', 'stats.count': '{inCountry}', 'stats.heading': 'Brukere', 'members.viewAll': 'Se alle medlemmer', -- knotmirror 0.0.1