wikimedia-i18n-l10n-for-tools · git:20260612.7b9d5a5 · 2026-06-12 · sha256 ae217357eed1d881
wikimedia-i18n-l10n-for-tools git:20260612.7b9d5a5A
Immutable. This exact content never changes and is served at /api/v1/blob/ae217357eed1d881.
---
name: wikimedia-i18n-l10n-for-tools
description: Design multilingual Toolforge tools — message files and ICU plurals, language detection and fallback chains, RTL/bidi layout, Unicode normalization and pitfalls, cross-wiki domain mapping, batch Wikidata label fetching, and avoiding English Wikipedia assumptions
license: MIT
compatibility: opencode
skill_discovery_hints:
- keywords: ["i18n", "l10n", "internationalization", "localization", "multilingual", "translation", "toolforge"]
- keywords: ["RTL", "bidi", "right-to-left", "Arabic", "Hebrew", "Persian"]
- keywords: ["Unicode", "Unicode normalization", "NFC", "page title", "cross-wiki"]
- keywords: ["domain mapping", "language to domain", "language code", "yue", "zh-yue", "nan", "zh-min-nan", "site matrix", "sitematrix", "wiki domain"]
- keywords: ["message file", "ICU", "pluralization", "language fallback", "gettext"]
- keywords: ["Wikidata label", "multilingual label", "wbgetentities", "language detection"]
last_verified: 2026-06-11
depends_on: [wikimedia-api-access, wikidata]
---
> ⚠️ **User-Agent required:** All API examples in this skill access Wikimedia APIs. Requests without a descriptive `User-Agent` header will be blocked with HTTP 403 or 429. See the **[wikimedia-api-access](../wikimedia-api-access/SKILL.md)** skill for the correct format and rate-limiting patterns.
> 💡 **Related skills:** This skill covers *app-level* i18n for tool developers. For *on-wiki content translation* using the Translate extension, see **[mediawiki-translate-extension](../mediawiki-translate-extension/SKILL.md)**. For Wikidata SPARQL queries with multilingual labels, see **[wikidata](../wikidata/SKILL.md)**.
---
## Table of Contents
1. [Why Tool i18n Is Different from Wiki Translation](#why-tool-i18n-is-different-from-wiki-translation)
2. [Two Layers of i18n for Wikimedia Tools](#two-layers-of-i18n-for-wikimedia-tools)
3. [SOP: Message Files and UI Translation](#sop-message-files-and-ui-translation)
4. [SOP: Language Detection and Switching](#sop-language-detection-and-switching)
5. [SOP: RTL and Bidi Layout](#sop-rtl-and-bidi-layout)
6. [SOP: Language Fallback Chains](#sop-language-fallback-chains)
7. [SOP: Wikidata and Commons Multilingual Labels](#sop-wikidata-and-commons-multilingual-labels)
8. [SOP: Page Title Normalization Across Scripts](#sop-page-title-normalization-across-scripts)
9. [SOP: Cross-Wiki Domain and Language Handling](#sop-cross-wiki-domain-and-language-handling)
10. [SOP: Avoiding English Wikipedia Assumptions](#sop-avoiding-english-wikipedia-assumptions)
11. [SOP: Unicode Pitfalls](#sop-unicode-pitfalls)
12. [Guardrails](#guardrails)
13. [Cross-References](#cross-references)
---
## Why Tool i18n Is Different from Wiki Translation
The repo already has a skill for the **MediaWiki Translate extension** (`mediawiki-translate-extension`), which covers on-wiki content translation using `<translate>` tags and the `Special:Translate` workflow. That skill is for **wiki editors** translating page content.
This skill is for **tool developers** building applications (web apps, bots, scripts) that need to:
| Concern | Wiki Translation (Translate ext) | Tool i18n (this skill) |
|---------|-------------------------------|----------------------|
| **Translation format** | `<translate>` tags in wikitext | `.json`/`.po`/`.properties` files |
| **Translation workflow** | Special:Translate, translation admins | Crowdin/translatewiki.net or manual PRs |
| **Language detection** | User preference on wiki | `Accept-Language` header, cookie, URL prefix |
| **Fallback** | Built into Translate extension | Developer implements via fallback chains |
| **Plural rules** | `{{PLURAL:}}` parser function | ICU MessageFormat `{x, plural, ...}` |
| **RTL/Bidi** | Handled by MediaWiki skin CSS | Developer implements with CSS `direction`, flexbox |
| **Title normalization** | Handled by MediaWiki | Developer handles NFC, case folding, percent-encoding |
| **Wikidata labels** | Not directly relevant | Batch-fetching with language fallback |
---
## Two Layers of i18n for Wikimedia Tools
### Layer 1: App UI i18n
Translating your tool's user interface — buttons, labels, messages, error text.
```json
{
"welcome": "Welcome to my tool!",
"search": "Search",
"results": "{count, plural, one {# result} other {# results}}",
"error_loading": "Error loading data: {error}"
}
```
### Layer 2: Wikimedia-Specific i18n
Interfacing with Wikimedia APIs in a language-aware way — fetching labels in the user's language, handling cross-wiki requests, normalizing titles.
```python
# Fetch Wikidata label in user's language
label = fetch_label("Q937", user_lang="fr")
# → "Albert Einstein"
# Make an API call to the user's wiki
wiki = language_to_domain(user_lang)
resp = requests.get(f"https://{wiki}/w/api.php?action=query&...")
```
Both layers are needed for a truly multilingual tool. Most tools focus only on Layer 1 and forget Layer 2.
---
## SOP: Message Files and UI Translation
### 1. Message File Structure
Organize translations as JSON files, one per language:
```
messages/
├── en.json # Source language (English)
├── fr.json # French
├── de.json # German
├── ar.json # Arabic
└── zh-hans.json # Simplified Chinese
```
Each file is a flat key-value map:
```json
{
"app_title": "Wikimedia Category Analyzer",
"search_placeholder": "Search categories...",
"results_count": "{count, plural, one {# category} other {# categories}}",
"loading": "Loading...",
"error_network": "Could not connect. Please try again.",
"welcome_user": "Welcome back, {username}!"
}
```
### 2. ICU MessageFormat for Plurals, Gender, Select
Use ICU MessageFormat syntax for messages that vary by count, gender, or other variables.
**Plurals:**
```
{count, plural,
one {# result}
other {# results}
}
```
**Select (for gender-aware messages):**
```
{gender, select,
male {He submitted}
female {She submitted}
other {They submitted}
}
```
**Nested:**
```
{count, plural,
one {{gender, select,
male {His # result}
female {Her # result}
other {Their # result}
}}
other {{gender, select,
male {His # results}
female {Her # results}
other {Their # results}
}}
}
```
### 3. Loading Messages in Python
```python
from message_loader import I18nMessages
i18n = I18nMessages("messages/", supported_languages=["en", "fr", "de", "ar"])
# Get a simple message
msg = i18n.get("app_title", lang="fr")
# → "Analyseur de catégories Wikimedia"
# Message with variable substitution
msg = i18n.get("welcome_user", lang="de", username="Alice")
# → "Willkommen zurück, Alice!"
# Message with pluralization
msg = i18n.get_plural("results_count", count=5, lang="fr")
# → "5 catégories"
msg = i18n.get_plural("results_count", count=1, lang="fr")
# → "1 catégorie"
# Fallback when translation is missing
msg = i18n.get("app_title", lang="ja")
# → "Wikimedia Category Analyzer" (falls back to en)
```
### 4. Translation Workflow for Toolforge Tools
**Option A: Manual with PRs**
1. Keep `en.json` as the source of truth
2. Developers/volunteers submit translations via pull requests
3. Review and merge
**Option B: translatewiki.net**
[translatewiki.net](https://translatewiki.net) is the Wikimedia community's translation platform for software. It supports:
- `.json` (flat key-value)
- `.properties` (Java-style)
- `.yaml`
- `.po` (gettext)
**To use translatewiki.net:**
1. Register your tool as a project
2. Push your `en.json` as the source file
3. Translators contribute through the web interface
4. Pull translated files back into your repo
### 5. Locale-Aware Number and Date Formatting
```python
# Using the message_loader's built-in formatter
i18n = I18nMessages("messages/")
# Numbers
num = i18n.format_number(1234567.89, lang="de")
# → "1.234.567,89"
num = i18n.format_number(1234567.89, lang="en")
# → "1,234,567.89"
# For full CLDR support, use the `babel` library:
from babel.numbers import format_number, format_currency
from babel.dates import format_date, format_time
formatted = format_number(1234567.89, locale="de_DE")
# → "1.234.567,89"
date_formatted = format_date(today, locale="ar_SA")
# → "١٩ ربيع الأول ١٤٤٦ هـ"
```
---
## SOP: Language Detection and Switching
### 1. Detect Language from Browser
```python
from i18n_utils import detect_language, parse_accept_language
# From HTTP header
accept = request.headers.get("Accept-Language", "")
user_lang = detect_language(accept, supported_languages=["en", "fr", "de"])
# → e.g., "fr"
# Parse for debugging
parsed = parse_accept_language("fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7")
# → [("fr", 1.0), ("en", 0.9), ("de", 0.8)]
```
### 2. Complete Language Resolution Strategy
For a Flask web tool, implement this layered strategy:
```python
from i18n_utils import detect_language, is_rtl
from message_loader import I18nMessages
def get_user_language(request, supported_languages, default="en"):
"""
Resolve user language from multiple sources, in priority order:
1. URL prefix (example.com/fr/page)
2. User cookie (language preference)
3. Accept-Language header (browser preference)
4. Default
"""
# 1. URL prefix
lang_from_url = extract_lang_from_path(request.path)
if lang_from_url in supported_languages:
return lang_from_url
# 2. Cookie
lang_from_cookie = request.cookies.get("lang")
if lang_from_cookie in supported_languages:
return lang_from_cookie
# 3. Accept-Language header
accept = request.headers.get("Accept-Language", "")
lang_from_header = detect_language(accept, supported_languages)
if lang_from_header != default:
return lang_from_header
return default
```
### 3. Flask Blueprint Example
```python
from flask import Blueprint, request, session, redirect, url_for
i18n_bp = Blueprint("i18n", __name__)
@i18n_bp.route("/set-language/<lang>")
def set_language(lang):
"""Set language preference via URL."""
resp = redirect(request.referrer or "/")
resp.set_cookie("lang", lang, max_age=365*24*3600)
return resp
# Template context processor
def inject_i18n():
"""Inject i18n messages and RTL direction into all templates."""
from flask import g, request
from i18n_utils import detect_language, is_rtl
supported = ["en", "fr", "de", "ar", "ja", "zh-hans"]
lang = get_user_language(request, supported)
i18n = I18nMessages("messages/", supported_languages=supported)
g.lang = lang
g.i18n = i18n
g.dir = "rtl" if is_rtl(lang) else "ltr"
```
### 4. HTML Template with Language Attributes
```html
<!DOCTYPE html>
<html lang="{{ g.lang }}" dir="{{ g.dir }}">
<head>
<meta charset="utf-8">
{% if g.dir == "rtl" %}
<link rel="stylesheet" href="/static/css/rtl.css">
{% endif %}
</head>
<body>
<h1>{{ g.i18n.get("app_title", lang=g.lang) }}</h1>
<p>{{ g.i18n.get_plural("results_count", count=results|length, lang=g.lang) }}</p>
</body>
</html>
```
---
## SOP: RTL and Bidi Layout
### 1. HTML Fundamentals
Always set `dir` and `lang` attributes on the `<html>` element:
```html
<html lang="ar" dir="rtl">
```
CSS will automatically mirror most layout:
- `text-align: start` / `end` works correctly (no need for LTR/RTL variants)
- Flexbox with `flex-start` / `flex-end` adapts to direction
- Grid auto-placement adapts
### 2. CSS for RTL Support
```css
/* Use logical properties instead of physical ones */
/* ❌ Avoid: */
.element {
margin-left: 1em;
padding-right: 0.5em;
border-left: 1px solid #ccc;
text-align: left;
}
/* ✅ Use: */
.element {
margin-inline-start: 1em;
padding-inline-end: 0.5em;
border-inline-start: 1px solid #ccc;
text-align: start;
}
```
### 3. Common RTL Challenges
| Challenge | Solution |
|-----------|----------|
| **Mixed LTR/RTL text** (e.g., English name in Arabic article) | Unicode bidi algorithm handles most cases automatically. Use `<bdi>` element to isolate. |
| **Numbers in RTL text** | Numbers are LTR within RTL text by default. This is correct for most cases. |
| **Icons/arrows** | Flip icons that imply direction (← →) in RTL mode: `transform: scaleX(-1)` for CSS, or use directional icon variants. |
| **Form input alignment** | Use `text-align: start;` — inputs in RTL will have cursor on the right. |
| **Time/date in RTL** | Numbers and Latin text within dates should remain LTR. Use `<bdi>` wrappers. |
### 4. Python Direction Helper
```python
from i18n_utils import is_rtl
# Set direction in templates
dir_attr = "rtl" if is_rtl(user_lang) else "ltr"
# Bidi-isolate user-generated content
user_text = f"<bdi>{user_text}</bdi>" # Prevents RTL/LTR from leaking
```
### 5. Testing RTL Layout
- Test with real Arabic/Hebrew/Persian content, not just lorem ipsum
- Check that navigation menus, buttons, and forms are reversed
- Verify that mixed LTR/RTL content (e.g., English Wikidata labels in Arabic interface) displays correctly
- Test with `dir="auto"` on user-generated content
---
## SOP: Language Fallback Chains
### 1. What Fallback Chains Are
When a message isn't available in the user's requested language, the tool should try progressively broader languages. Wikimedia has well-defined fallback chains. For example:
```
pt-br → pt → es → en (Brazilian Portuguese → Portuguese → Spanish → English)
zh-cn → zh-hans → zh → en (Chinese China → Simplified Chinese → Chinese → English)
be-tarask → be → ru → en (Belarusian Taraškievica → Belarusian → Russian → English)
```
### 2. Using the Fallback Resolver
```python
from i18n_utils import resolve_fallback
chain = resolve_fallback("pt-br")
# → ["pt-br", "pt", "es", "en"]
chain = resolve_fallback("be-tarask")
# → ["be-tarask", "be", "ru", "en"]
chain = resolve_fallback("en")
# → ["en"]
```
### 3. Fetching Wikidata Labels with Fallback
```python
from wikidata_labels import WikidataLabelFetcher
fetcher = WikidataLabelFetcher()
# Automatically tries fallback chain
info = fetcher.get_entity_info("Q937", preferred_lang="pt-br")
# → {"label": "Albert Einstein", "description": "...", "lang": "pt"}
# Actual language used:
info["lang"] # → "pt" (Portuguese — falls back from pt-br)
```
### 4. Fallback in Message Files
The `I18nMessages` class automatically applies fallback chains:
```python
from message_loader import I18nMessages
i18n = I18nMessages("messages/", supported_languages=["en", "fr", "de"])
# If "pt-br" is requested but only "pt" and "en" exist:
msg = i18n.get("app_title", lang="pt-br")
# Falls back through: pt-br → pt → en
```
### 5. Implementing Fallback in SPARQL Queries
```python
# SPARQL with language fallback
sparql = """
SELECT ?item ?itemLabel ?itemDescription WHERE {
VALUES ?item { wd:Q937 wd:Q5 wd:P31 }
SERVICE wikibase:label {
bd:serviceParam wikibase:language "%s", "en".
}
}
""" % (user_lang,)
# The wikibase:label service handles fallback internally!
```
---
## SOP: Wikidata and Commons Multilingual Labels
### 1. Batch-Fetching Labels with Fallback
```python
from wikidata_labels import WikidataLabelFetcher
fetcher = WikidataLabelFetcher()
# Fetch for multiple entities at once (batched in 50s)
entities = ["Q937", "Q5", "P31", "P279", "Q42"]
info = fetcher.batch_get_entity_info(
entities,
preferred_lang="ar",
include_aliases=True,
)
for eid, data in info.items():
print(f"{eid}: {data['label']} [{data['lang']}]")
# → Q937: ألبرت أينشتاين [ar]
# → Q5: إنسان [ar]
if "aliases" in data:
print(f" Aliases: {', '.join(data['aliases'])}")
```
### 2. Action API with Language Parameters
```python
import requests
# Fetch Wikidata entity with specific languages
resp = requests.get(
"https://www.wikidata.org/w/api.php",
params={
"action": "wbgetentities",
"ids": "Q937|Q5|P31",
"props": "labels|descriptions",
"languages": "en|fr|de|ar",
"format": "json",
},
headers={"User-Agent": "MyTool/1.0 (contact) ContentGapResearch"},
timeout=30,
)
```
### 3. Commons Multilingual File Descriptions
Commons file pages have multilingual captions and descriptions stored as Wikibase statements:
```python
# Fetch Commons file captions
resp = requests.get(
"https://commons.wikimedia.org/w/api.php",
params={
"action": "wbgetentities",
"sites": "commonswiki",
"titles": "File:Example.jpg",
"props": "labels|descriptions",
"languages": "en|fr|de|ar",
"format": "json",
},
headers={"User-Agent": "MyTool/1.0 (contact) ContentGapResearch"},
timeout=30,
)
```
### 4. The `uselang` Parameter
Many MediaWiki API endpoints accept a `uselang` parameter that controls the language of interface messages:
```python
params = {
"action": "query",
"meta": "siteinfo",
"uselang": "fr", # Interface messages in French
"format": "json",
}
```
The `uselang` parameter also affects:
- Error messages returned by the API
- `prop=extracts` snippet language (on multilingual wikis like Commons)
- Content language in `prop=pageprops`
---
## SOP: Page Title Normalization Across Scripts
### 1. MediaWiki Title Normalization
MediaWiki applies these normalization rules to all page titles:
1. **NFC Unicode normalization** — characters like `é` are stored as single codepoints
2. **First letter capitalized** — `albert einstein` → `Albert Einstein`
3. **Underscores for spaces** — or spaces for underscores (both are equivalent)
4. **Multiple spaces collapsed** — ` spaces ` → `spaces`
5. **Invisible characters stripped** — zero-width spaces, direction markers removed
### 2. Normalizing Before API Calls
```python
from i18n_utils import normalize_title
user_input = "Spielberg\u0301" # NFD: e + combining accent
safe_title = normalize_title(user_input)
# → "Spielberg\u00e9" # NFC: é as one codepoint
# Now safe for API use
params = {"action": "query", "titles": safe_title}
```
### 3. Cross-API Title Format Differences
| API | Title Format | Example |
|-----|-------------|---------|
| Action API | Spaces (raw or encoded) | `Albert Einstein` |
| REST API | Percent-encoded spaces | `Albert%20Einstein` |
| SQL replicas | Underscores | `Albert_Einstein` |
| Wikidata (wbgetentities) via sitelinks | Underscores | `Albert_Einstein` |
| URL path | Underscores or percent-encoded | `/wiki/Albert_Einstein` |
**Converting between formats:**
```python
from urllib.parse import quote, unquote
# Spaces ← → underscores
title_with_spaces = "Albert_Einstein".replace("_", " ")
title_with_underscores = "Albert Einstein".replace(" ", "_")
# For REST API URLs
safe_path = quote("Albert Einstein", safe="")
# → "Albert%20Einstein"
# SQL format
sql_format = "Albert_Einstein"
```
### 4. Case Folding Across Scripts
Different scripts have different case rules. The MediaWiki `ucfirst` behavior is well-defined for Latin but complex for other scripts:
```python
# MediaWiki-style first-letter capitalization
def mw_ucfirst(title: str) -> str:
"""Apply MediaWiki-style first-letter capitalization."""
if not title:
return title
# For Latin scripts: simple capitalize
# For other scripts: may be more complex
return title[0].upper() + title[1:]
# But for Japanese, capitalization doesn't apply:
# "東京" stays "東京" (no uppercase concept)
```
**Best practice:** Don't manually capitalize — let the API's `normalize` property handle it:
```python
params = {
"action": "query",
"titles": "user entered title",
"redirects": 1, # Follow redirects
"format": "json",
}
# The response will contain the normalized title
```
### 5. The `normalize` Property in Action API Responses
```json
{
"query": {
"normalized": [
{
"from": "user entered title",
"to": "User entered title"
}
],
"pages": {...}
}
}
```
Always use the `normalized` and `redirects` properties in API responses to get the canonical title.
---
## SOP: Cross-Wiki Domain and Language Handling
### 1. Language-to-Domain Mapping
> **⚠️ Important (2025):** Wikimedia unified mobile and desktop domains in October 2025.
> The old `en.m.wikipedia.org` domains are now dead — they redirect to `en.wikipedia.org`.
> **Do not construct `https://{lang}.m.wikipedia.org/` URLs.** All content is on the standard domain.
> See [Unifying mobile and desktop domains](https://diff.wikimedia.org/2025/11/21/unifying-mobile-and-desktop-domains/).
>
> Additionally, the Action API's `prop=langlinks` returns **CLDR/BCP 47 language codes**
> which sometimes differ from Wikipedia domain names. For example, `yue` (Cantonese)
> resolves to `zh-yue.wikipedia.org`, not `yue.wikipedia.org`. A static mapping works
> for common cases, but the **Site Matrix API** (`action=sitematrix`) is the canonical
> source for the correct domain of every Wikipedia language edition.
#### Quick approach — static mapping (covers 95% of cases)
```python
from i18n_utils import language_to_domain
# Standard mapping
domain = language_to_domain("fr")
# → "fr.wikipedia.org"
domain = language_to_domain("de", project="wiktionary")
# → "de.wiktionary.org"
# Special codes
domain = language_to_domain("simple")
# → "simple.wikipedia.org"
domain = language_to_domain("be-tarask")
# → "be-tarask.wikipedia.org"
# Known code/domain mismatches (not covered by static mapping):
# yue → zh-yue.wikipedia.org (Cantonese)
# nan → zh-min-nan.wikipedia.org (Min Nan Chinese)
# nb → no.wikipedia.org (Norwegian Bokmål → Norwegian)
# Project wikis
domain = language_to_domain("en", project="commons")
# → "commons.wikimedia.org"
domain = language_to_domain("en", project="wikidata")
# → "www.wikidata.org"
```
#### Authoritative approach — Site Matrix API (covers 100%, self-updating)
For tools that fetch interlanguage links and need the correct domain every time,
calling the Site Matrix API once at startup builds an authoritative mapping:
```python
import requests
def build_domain_map() -> dict:
"""Build {language_code: domain} from the Wikimedia Site Matrix."""
resp = requests.get("https://en.wikipedia.org/w/api.php", params={
"action": "sitematrix",
"format": "json",
"smtype": "language",
}, headers={"User-Agent": "MyBot/1.0 (contact)"}, timeout=15)
data = resp.json()
domain_map = {}
for smkey, smval in data.get("sitematrix", {}).items():
if smkey == "count" or not isinstance(smval, dict):
continue
lang_code = smval.get("code")
for site in smval.get("site", []):
if site.get("code") == "wiki" and "wikipedia.org" in site.get("url", ""):
# Extract domain from URL: "https://fr.wikipedia.org/w/" → "fr.wikipedia.org"
domain = site["url"].replace("https://", "").replace("/w/", "")
domain_map[lang_code] = domain
# Static fallbacks for edge cases not covered by the matrix
domain_map.update({
"yue": "zh-yue.wikipedia.org",
"nan": "zh-min-nan.wikipedia.org",
"nb": "no.wikipedia.org",
})
return domain_map
# Usage
domain_map = build_domain_map()
domain = domain_map.get("yue", f"{lang}.wikipedia.org")
# → "zh-yue.wikipedia.org"
```
This handles all ~362 language editions automatically, including edge cases
like `yue`→`zh-yue`, `nan`→`zh-min-nan`, and `nb`→`no`. The API call takes
~1 second and the result can be cached in memory for the lifetime of the tool.
### 2. Domain-to-Language Extraction
```python
from i18n_utils import domain_to_language
lang = domain_to_language("fr.wikipedia.org")
# → "fr"
lang = domain_to_language("commons.wikimedia.org")
# → None (no language)
lang = domain_to_language("be-tarask.wikipedia.org")
# → "be-tarask"
```
### 3. Cross-Wiki API Patterns
```python
import requests
def fetch_page_from_wiki(title: str, lang: str) -> dict:
"""Fetch a page from a specific language Wikipedia."""
from i18n_utils import language_to_domain
domain = language_to_domain(lang)
url = f"https://{domain}/w/api.php"
resp = requests.get(
url,
params={
"action": "query",
"titles": title,
"prop": "extracts",
"exintro": True,
"explaintext": True,
"format": "json",
},
headers={"User-Agent": "MyTool/1.0 (contact) ContentGapResearch"},
timeout=30,
)
return resp.json()
# Fetch the same topic across multiple languages
for lang in ["en", "fr", "de", "ar"]:
data = fetch_page_from_wiki("Albert Einstein", lang)
# Process each language version
```
### 4. Interlanguage Links via the API
```python
# Fetch page links to other language editions
resp = requests.get(
"https://en.wikipedia.org/w/api.php",
params={
"action": "query",
"titles": "Albert Einstein",
"prop": "langlinks",
"lllimit": 500,
"format": "json",
},
headers={"User-Agent": "MyTool/1.0 (contact) ContentGapResearch"},
timeout=30,
)
data = resp.json()
# Extract language → title mappings
pages = data.get("query", {}).get("pages", {})
for page_id, page in pages.items():
langlinks = page.get("langlinks", [])
for link in langlinks:
lang = link["lang"]
title = link["title"]
print(f"{lang}: {title}")
```
---
## SOP: Avoiding English Wikipedia Assumptions
### 1. Bad Patterns vs. Good Patterns
```python
# ❌ BAD: Hardcoded English Wikipedia
resp = requests.get("https://en.wikipedia.org/w/api.php?...")
# ✅ GOOD: Parameterized wiki domain
def fetch_wiki(title: str, lang: str = "en"):
from i18n_utils import language_to_domain
domain = language_to_domain(lang)
url = f"https://{domain}/w/api.php"
...
# ❌ BAD: Assuming English labels exist on Wikidata
# (they almost always do, but when they don't, your tool breaks)
# ✅ GOOD: Use fallback chains to find any available language
from wikidata_labels import WikidataLabelFetcher
fetcher = WikidataLabelFetcher()
info = fetcher.get_entity_info("Q937", preferred_lang=user_lang)
# Falls back: user_lang → ... → en
# ❌ BAD: Assuming LTR layout
html = f"<html><body><p>{message}</p></body></html>"
# ✅ GOOD: Set dir and lang attributes
dir_attr = "rtl" if is_rtl(user_lang) else "ltr"
html = f'<html lang="{user_lang}" dir="{dir_attr}">...</html>'
# ❌ BAD: Assuming Latin script
title_length = len(page_title) # Counts codepoints, not graphemes
# ✅ GOOD: Handle all scripts gracefully
# (Devanagari, CJK, Arabic all have different character widths)
# ❌ BAD: Hardcoded "en" in SPARQL
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
# ✅ GOOD: Use user's language with en fallback
SERVICE wikibase:label {
bd:serviceParam wikibase:language "{user_lang}", "en".
}
```
### 2. Checklist for Multilingual Tool Design
- [ ] All API calls parameterize the wiki domain (not hardcoded `en.wikipedia.org`)
- [ ] Wikidata queries use the user's language with English fallback
- [ ] HTML templates set `lang` and `dir` attributes
- [ ] CSS uses logical properties (`margin-inline-start`, `text-align: start`)
- [ ] Message files exist for at least one non-English language
- [ ] `Accept-Language` header is parsed for language detection
- [ ] User-facing numbers use locale-aware formatting
- [ ] Page titles are NFC-normalized before comparison
- [ ] Invisible Unicode characters are stripped from user input
- [ ] Language fallback chains are implemented (not just "en" fallback)
- [ ] SPARQL `SERVICE wikibase:label` uses the user's language
- [ ] RTL layout is tested with real Arabic/Hebrew/Persian content
- [ ] Plural rules are correct for the target languages
- [ ] `uselang` parameter is passed to API calls where appropriate
### 3. Testing Multilingual Tools
```python
"""Test your tool with multiple languages in CI."""
TEST_LANGUAGES = ["en", "fr", "de", "ar", "ja", "zh-hans", "ru"]
def test_message_coverage():
"""Verify all supported languages have all required keys."""
from message_loader import I18nMessages
i18n = I18nMessages("messages/", supported_languages=TEST_LANGUAGES)
required_keys = ["app_title", "search", "results", "error_network"]
for lang in TEST_LANGUAGES:
for key in required_keys:
msg = i18n.get(key, lang=lang)
assert msg != key, f"Missing translation: {lang}.{key}"
assert msg != "", f"Empty translation: {lang}.{key}"
def test_rtl_languages_handled():
"""Verify RTL languages return correct direction."""
from i18n_utils import is_rtl
assert is_rtl("ar")
assert is_rtl("he")
assert is_rtl("fa")
assert not is_rtl("en")
assert not is_rtl("de")
```
---
## SOP: Unicode Pitfalls
See **[references/unicode-pitfalls.md](references/unicode-pitfalls.md)** for a complete reference.
Key takeaways:
| Pitfall | Symptom | Fix |
|---------|---------|-----|
| NFC vs NFD | Title comparison fails | `unicodedata.normalize("NFC", title)` |
| Zero-width characters | API returns "page not found" | Strip `\u200B-\u200F`, `\uFEFF` |
| RTL override characters | Text displays in reverse order | Strip `\u202A-\u202F` |
| Combining characters | `len()` gives wrong count | Use grapheme cluster counting |
| Percent-encoding mismatch | REST API returns 404 | Use `urllib.parse.quote` for REST URLs |
| Punycode domains | Interwiki link fails | Use ASCII domain names where possible |
---
## Guardrails
### 1. Never Hardcode `en.wikipedia.org`
Every API call should parameterize the wiki domain. Use `language_to_domain()` from `i18n_utils.py`.
### 2. Always Normalize Page Titles
Before comparing, storing, or using page titles from user input, always:
1. Strip invisible characters
2. NFC normalize
3. Strip whitespace
### 3. Set `dir` and `lang` on Every HTML Page
Even if you only support English. This ensures browser tools, screen readers, and CSS frameworks work correctly.
### 4. Don't Assume Latin Script for Truncation
`text[:100]` may cut in the middle of a CJK character, a Devanagari conjunct, or an emoji. Use grapheme-aware truncation or set CSS `text-overflow: ellipsis` instead.
### 5. Always Pass `uselang` to the API
When calling Wikimedia APIs, pass `uselang` to get error messages and interface text in the user's language:
```python
params = {
"action": "query",
"uselang": user_lang,
"format": "json",
}
```
### 6. Wikidata Labels May Not Exist in English
While rare, some Wikidata items lack English labels. Always use the fallback chain rather than hardcoding `"en"`.
### 7. Test RTL with Real Content
Testing RTL layout with Latin lorem ipsum is useless. Use real Arabic, Hebrew, or Persian text:
```
مرحبًا بكم في أداة تحليل الويكي
ברוכים הבאים לכלי ניתוח ויקי
به ابزار تحلیل ویکی خوش آمدید
```
### 8. Plural Rules Vary Significantly
Don't apply English plural rules (one/other) to all languages:
- Arabic has 6 plural forms (zero, one, two, few, many, other)
- Russian has 4 forms (one, few, many, other)
- Japanese has 2 forms but they work differently
- Some languages have no plural forms at all
Use ICU MessageFormat or the `babel` library instead of simple `if count == 1` checks.
### 9. Language Code Normalization: Aliases, Renames, and Non-Standard Codes
Wikimedia has a complex history of language code usage. The same language can
be referred to by multiple codes depending on the source:
| Context | Example Codes |
|---------|--------------|
| Wikipedia subdomain | `yue.wikipedia.org`, `nb.wikipedia.org` |
| ISO 639-1/3 standard | `yue`, `nb` (canonical) |
| Deprecated alias | `zh-yue` (old MediaWiki code → `yue`) |
| Deprecated alias | `no` (ISO macrolanguage → `nb`) |
| BCP 47 tag | `en-simple` (Simple English as English variant) |
**The critical mappings** (from MediaWiki `$wgDummyLanguageCodes`):
| Alias | Canonical | Why |
|-------|-----------|-----|
| `zh-yue` → | `yue` | Cantonese Wikipedia subdomain was renamed |
| `no` → | `nb` | ISO macrolanguage code redirects to Bokmål |
| `zh-classical` → | `lzh` | Classical Chinese uses ISO 639-3 |
| `zh-min-nan` → | `nan` | Min Nan uses ISO 639-3 |
| `bat-smg` → | `sgs` | Samogitian (deprecated code) |
| `roa-rup` → | `rup` | Aromanian (deprecated code) |
| `als` → | `gsw` | Alemannic (was wrongly using Tosk Albanian's ISO code) |
| `fiu-vro` → | `vro` | Võro (deprecated code) |
| `be-x-old` → | `be-tarask` | Belarusian old orthography (renamed) |
| `simple` → | `en` | Simple English uses English message files |
**How to handle this in your code:**
```python
from i18n_utils import normalize_language_code, language_code_to_bcp47
# User passes an old/alias code — normalize it
lang = normalize_language_code("zh-yue") # → "yue"
lang = normalize_language_code("no") # → "nb"
lang = normalize_language_code("zh-yue") # → "yue"
# For HTML output, convert to BCP 47
tag = language_code_to_bcp47("simple") # → "en-simple"
tag = language_code_to_bcp47("zh-cn") # → "zh-Hans-CN"
# Always normalize before building API URLs or making cross-system comparisons
```
**Full reference:** See the [language-codes.md](references/language-codes.md) reference doc
for the complete mapping tables including BCP 47 conversion, non-standard Wikipedia
subdomains, and all `$wgExtraLanguageCodes` mappings.
### 10. Cross-Wiki API Calls Have Different Rate Limits
Different language Wikipedias are separate API endpoints with separate rate limits. You cannot assume the same limits apply to `fr.wikipedia.org` and `de.wikipedia.org`.
---
## Cross-References
| Related Skill | Why |
|--------------|-----|
| **[wikimedia-api-access](../wikimedia-api-access/SKILL.md)** | User-Agent headers, rate limiting, base API patterns |
| **[wikidata](../wikidata/SKILL.md)** | SPARQL with `SERVICE wikibase:label`, entity queries |
| **[mediawiki-translate-extension](../mediawiki-translate-extension/SKILL.md)** | On-wiki content translation (different from tool i18n) |
| **[wikimedia-commons](../wikimedia-commons/SKILL.md)** | Commons multilingual file descriptions and captions |
| **[wikimedia-wikitext](../wikimedia-wikitext/SKILL.md)** | Unicode handling in wikitext parsing |
| **[wikipedia-error-handling](../wikipedia-error-handling/SKILL.md)** | 429 handling per wiki domain |
| **[pywikibot](../pywikibot/SKILL.md)** | Pywikibot's `mylang` config, cross-wiki `-interwiki:` generator |
| **[wikimedia-database](../wikimedia-database/SKILL.md)** | `langlinks` table for interlanguage lookups via SQL |