diff --git a/.gitignore b/.gitignore
index d951f3fb9cbad..0f854f61ae0e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,3 +141,12 @@ doc/source/savefig/
# Pyodide/WASM related files #
##############################
/.pyodide-xbuildenv-*
+
+
+# Web & Translations #
+##############################
+web/preview/
+web/translations/
+web/pandas/es/
+web/pandas/pt/
+web/pandas/fr/
diff --git a/web/pandas/_templates/layout.html b/web/pandas/_templates/layout.html
index c26b093b0c4ba..500e8fce2156a 100644
--- a/web/pandas/_templates/layout.html
+++ b/web/pandas/_templates/layout.html
@@ -1,5 +1,5 @@
-
+
pandas - Python Data Analysis Library
@@ -15,6 +15,8 @@
href="{{ base_url }}{{ stylesheet }}">
{% endfor %}
+
+
@@ -28,7 +30,7 @@
- {% for item in navbar %}
+ {% for item in navbar[selected_language] %}
{% if not item.has_subitems %}
-
{{ item.name }}
@@ -50,6 +52,8 @@
{% endif %}
{% endfor %}
+
+
diff --git a/web/pandas/config.yml b/web/pandas/config.yml
index cb5447591dab6..28a2c3b8fffcc 100644
--- a/web/pandas/config.yml
+++ b/web/pandas/config.yml
@@ -25,39 +25,6 @@ static:
css:
- static/css/pandas.css
- static/css/codehilite.css
-navbar:
- - name: "About us"
- target:
- - name: "About pandas"
- target: about/
- - name: "Project roadmap"
- target: about/roadmap.html
- - name: "Governance"
- target: about/governance.html
- - name: "Team"
- target: about/team.html
- - name: "Sponsors"
- target: about/sponsors.html
- - name: "Citing and logo"
- target: about/citing.html
- - name: "Getting started"
- target: getting_started.html
- - name: "Documentation"
- target: docs/
- - name: "Community"
- target:
- - name: "Blog"
- target: community/blog/
- - name: "Ask a question (StackOverflow)"
- target: https://stackoverflow.com/questions/tagged/pandas
- - name: "Code of conduct"
- target: community/coc.html
- - name: "Ecosystem"
- target: community/ecosystem.html
- - name: "Benchmarks"
- target: community/benchmarks.html
- - name: "Contribute"
- target: contribute.html
blog:
num_posts: 50
posts_path: community/blog
@@ -204,3 +171,11 @@ sponsors:
kind: partner
roadmap:
pdeps_path: pdeps
+translations:
+ url: https://github.com/Scientific-Python-Translations/pandas-translations/archive/refs/heads/main.tar.gz
+ folder: translations
+ source_path: pandas-translations-main/web/pandas/
+ default_language: 'en'
+ default_prefix: ''
+ ignore:
+ - docs/
diff --git a/web/pandas/navbar.yml b/web/pandas/navbar.yml
new file mode 100644
index 0000000000000..bcc2d062fc3f5
--- /dev/null
+++ b/web/pandas/navbar.yml
@@ -0,0 +1,33 @@
+navbar:
+ - name: "About us"
+ target:
+ - name: "About pandas"
+ target: about/
+ - name: "Project roadmap"
+ target: about/roadmap.html
+ - name: "Governance"
+ target: about/governance.html
+ - name: "Team"
+ target: about/team.html
+ - name: "Sponsors"
+ target: about/sponsors.html
+ - name: "Citing and logo"
+ target: about/citing.html
+ - name: "Getting started"
+ target: getting_started.html
+ - name: "Documentation"
+ target: docs/
+ - name: "Community"
+ target:
+ - name: "Blog"
+ target: community/blog/
+ - name: "Ask a question (StackOverflow)"
+ target: https://stackoverflow.com/questions/tagged/pandas
+ - name: "Code of conduct"
+ target: community/coc.html
+ - name: "Ecosystem"
+ target: community/ecosystem.html
+ - name: "Benchmarks"
+ target: community/benchmarks.html
+ - name: "Contribute"
+ target: contribute.html
diff --git a/web/pandas/static/js/language_switcher.js b/web/pandas/static/js/language_switcher.js
new file mode 100644
index 0000000000000..eca361d30e20f
--- /dev/null
+++ b/web/pandas/static/js/language_switcher.js
@@ -0,0 +1,71 @@
+window.addEventListener("DOMContentLoaded", function() {
+ var absBaseUrl = document.baseURI;
+ var baseUrl = location.protocol + "//" + location.hostname
+ if (location.port) {
+ baseUrl = baseUrl + ":" + location.port
+ }
+ var currentLanguage = document.documentElement.lang;
+ var languages = JSON.parse(document.getElementById("languages").getAttribute('data-lang').replace(/'/g, '"'));
+ const languageNames = {
+ 'en': 'English',
+ 'es': 'Español',
+ 'fr': 'Français',
+ 'pt': 'Português'
+ }
+
+ // Handle preview URLs on github
+ // If preview URL changes, this regex will need to be updated
+ const re = /preview\/pandas-dev\/pandas\/(?[0-9]*)\//g;
+ var previewUrl = '';
+ for (const match of absBaseUrl.matchAll(re)) {
+ previewUrl = `/preview/pandas-dev/pandas/${match.groups.pr}`;
+ }
+ var pathName = location.pathname.replace(previewUrl, '')
+
+ // Create dropdown menu
+ function makeDropdown(options) {
+ var dropdown = document.createElement("li");
+ dropdown.classList.add("nav-item");
+ dropdown.classList.add("dropdown");
+
+ var link = document.createElement("a");
+ link.classList.add("nav-link");
+ link.classList.add("dropdown-toggle");
+ link.setAttribute("data-bs-toggle", "dropdown");
+ link.setAttribute("href", "#");
+ link.setAttribute("role", "button");
+ link.setAttribute("aria-haspopup", "true");
+ link.setAttribute("aria-expanded", "false");
+ link.textContent = languageNames[currentLanguage];
+
+ var dropdownMenu = document.createElement("div");
+ dropdownMenu.classList.add("dropdown-menu");
+
+ options.forEach(function(i) {
+ var dropdownItem = document.createElement("a");
+ dropdownItem.classList.add("dropdown-item");
+ dropdownItem.textContent = languageNames[i] || i.toUpperCase();
+ dropdownItem.setAttribute("href", "#");
+ dropdownItem.addEventListener("click", function() {
+ var urlLanguage = '';
+ if (i !== 'en') {
+ urlLanguage = '/' + i;
+ }
+ pathName = pathName.replace('/' + currentLanguage + '/', '/')
+ var newUrl = baseUrl + previewUrl + urlLanguage + pathName
+ window.location.href = newUrl;
+ });
+ dropdownMenu.appendChild(dropdownItem);
+ });
+
+ dropdown.appendChild(link);
+ dropdown.appendChild(dropdownMenu);
+ return dropdown;
+ }
+
+ var container = document.getElementById("language-switcher-container");
+ if (container) {
+ var dropdown = makeDropdown(languages);
+ container.appendChild(dropdown);
+ }
+});
diff --git a/web/pandas_translations.py b/web/pandas_translations.py
new file mode 100755
index 0000000000000..c023605cff340
--- /dev/null
+++ b/web/pandas_translations.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""
+Utilities to download and extract translations from the GitHub repository.
+"""
+
+import io
+import os
+import shutil
+from subprocess import (
+ PIPE,
+ Popen,
+)
+import sys
+import tarfile
+
+import requests
+import yaml
+
+
+def get_config(config_fname: str) -> dict:
+ """
+ Load the config yaml file and return it as a dictionary.
+ """
+ with open(config_fname, encoding="utf-8") as f:
+ context = yaml.safe_load(f)
+ return context
+
+
+def download_and_extract_translations(url: str, dir_name: str) -> None:
+ """
+ Download the translations from the GitHub repository.
+ """
+ shutil.rmtree(dir_name, ignore_errors=True)
+ response = requests.get(url)
+ if response.status_code == 200:
+ doc = io.BytesIO(response.content)
+ with tarfile.open(None, "r:gz", doc) as tar:
+ tar.extractall(dir_name)
+ else:
+ raise Exception(f"Failed to download translations: {response.status_code}")
+
+
+def get_languages(source_path: str) -> list[str]:
+ """
+ Get the list of languages available in the translations directory.
+ """
+ en_path = f"{source_path}/en/"
+ if os.path.exists(en_path):
+ shutil.rmtree(en_path)
+
+ paths = os.listdir(source_path)
+ return [path for path in paths if os.path.isdir(f"{source_path}/{path}")]
+
+
+def remove_translations(source_path: str, languages: list[str]) -> None:
+ """
+ Remove the translations from the source path.
+ """
+ for language in languages:
+ shutil.rmtree(os.path.join(source_path, language), ignore_errors=True)
+
+
+def copy_translations(source_path: str, target_path: str, languages: list[str]) -> None:
+ """
+ Copy the translations to the appropriate directory.
+ """
+ for lang in languages:
+ dest = f"{target_path}/{lang}/"
+ shutil.rmtree(dest, ignore_errors=True)
+ cmds = [
+ "rsync",
+ "-av",
+ "--delete",
+ f"{source_path}/{lang}/",
+ dest,
+ ]
+ p = Popen(cmds, stdout=PIPE, stderr=PIPE)
+ stdout, stderr = p.communicate()
+ sys.stderr.write(f"\nCopying: {lang}...\n\n")
+ sys.stderr.write(stdout.decode())
+ sys.stderr.write(stderr.decode())
+
+
+def process_translations(
+ config_fname: str, source_path: str
+) -> tuple[list[str], list[str]]:
+ """
+ Process the translations by downloading and extracting them from
+ the GitHub repository.
+ """
+ base_folder = os.path.dirname(__file__)
+ config = get_config(os.path.join(source_path, config_fname))
+ translations_path = os.path.join(base_folder, f"{config['translations']['folder']}")
+ translations_source_path = os.path.join(
+ translations_path, config["translations"]["source_path"]
+ )
+ default_language = config["translations"]["default_language"]
+
+ sys.stderr.write("\nDownloading and extracting translations...\n\n")
+ download_and_extract_translations(config["translations"]["url"], translations_path)
+
+ translated_languages = get_languages(translations_source_path)
+ remove_translations(source_path, translated_languages)
+
+ languages = [default_language] + translated_languages
+ sys.stderr.write("\nCopying translations...\n")
+ copy_translations(translations_source_path, source_path, translated_languages)
+
+ return translated_languages, languages
diff --git a/web/pandas_web.py b/web/pandas_web.py
index b3872b829c73a..58424c876f5aa 100755
--- a/web/pandas_web.py
+++ b/web/pandas_web.py
@@ -65,7 +65,7 @@ class Preprocessors:
"""
@staticmethod
- def current_year(context):
+ def current_year(context: dict) -> dict:
"""
Add the current year to the context, so it can be used for the copyright
note, or other places where it is needed.
@@ -74,23 +74,28 @@ def current_year(context):
return context
@staticmethod
- def navbar_add_info(context):
+ def navbar_add_info(context: dict, skip: bool = True) -> dict:
"""
Items in the main navigation bar can be direct links, or dropdowns with
subitems. This context preprocessor adds a boolean field
``has_subitems`` that tells which one of them every element is. It
also adds a ``slug`` field to be used as a CSS id.
"""
- for i, item in enumerate(context["navbar"]):
- context["navbar"][i] = dict(
- item,
- has_subitems=isinstance(item["target"], list),
- slug=(item["name"].replace(" ", "-").lower()),
- )
+ ignore = context["translations"]["ignore"]
+ for language in context["languages"]:
+ for i, item in enumerate(context["navbar"][language]):
+ if item["target"] in ignore:
+ item["target"] = f"../{item['target']}"
+
+ context["navbar"][language][i] = dict(
+ item,
+ has_subitems=isinstance(item["target"], list),
+ slug=(item["name"].replace(" ", "-").lower()),
+ )
return context
@staticmethod
- def blog_add_posts(context):
+ def blog_add_posts(context: dict) -> dict:
"""
Given the blog feed defined in the configuration yaml, this context
preprocessor fetches the posts in the feeds, and returns the relevant
@@ -162,7 +167,7 @@ def blog_add_posts(context):
return context
@staticmethod
- def maintainers_add_info(context):
+ def maintainers_add_info(context: dict) -> dict:
"""
Given the active maintainers defined in the yaml file, it fetches
the GitHub user information for them.
@@ -212,7 +217,7 @@ def maintainers_add_info(context):
return context
@staticmethod
- def home_add_releases(context):
+ def home_add_releases(context: dict) -> dict:
context["releases"] = []
github_repo_url = context["main"]["github_repo_url"]
@@ -272,7 +277,7 @@ def home_add_releases(context):
return context
@staticmethod
- def roadmap_pdeps(context):
+ def roadmap_pdeps(context: dict) -> dict:
"""
PDEP's (pandas enhancement proposals) are not part of the bar
navigation. They are included as lists in the "Roadmap" page
@@ -386,7 +391,9 @@ def get_callable(obj_as_str: str) -> object:
return obj
-def get_context(config_fname: str, **kwargs):
+def get_context(
+ config_fname: str, navbar_fname: str, languages: list[str], **kwargs: dict
+) -> dict:
"""
Load the config yaml as the base context, and enrich it with the
information added by the context preprocessors defined in the file.
@@ -395,8 +402,23 @@ def get_context(config_fname: str, **kwargs):
context = yaml.safe_load(f)
context["source_path"] = os.path.dirname(config_fname)
- context.update(kwargs)
+ navbar = {}
+ context["languages"] = languages
+ default_language = context["translations"]["default_language"]
+ default_prefix = context["translations"]["default_prefix"]
+ for language in languages:
+ prefix = default_prefix if language == default_language else language
+ navbar_path = os.path.join(context["source_path"], prefix, navbar_fname)
+
+ with open(navbar_path, encoding="utf-8") as f:
+ navbar_lang = yaml.safe_load(f)
+
+ navbar[language] = navbar_lang["navbar"]
+
+ context["navbar"] = navbar
+
+ context.update(kwargs)
preprocessors = (
get_callable(context_prep)
for context_prep in context["main"]["context_preprocessors"]
@@ -441,19 +463,39 @@ def main(
For ``.md`` and ``.html`` files, render them with the context
before copying them. ``.md`` files are transformed to HTML.
"""
- config_fname = os.path.join(source_path, "config.yml")
+ base_folder = os.path.dirname(__file__)
shutil.rmtree(target_path, ignore_errors=True)
os.makedirs(target_path, exist_ok=True)
+ # Handle translations
+ sys.path.append(base_folder)
+ trans = importlib.import_module("pandas_translations")
+ translated_languages, languages = trans.process_translations(
+ "config.yml", source_path
+ )
+
sys.stderr.write("Generating context...\n")
- context = get_context(config_fname, target_path=target_path)
+ context = get_context(
+ os.path.join(source_path, "config.yml"),
+ navbar_fname="navbar.yml",
+ target_path=target_path,
+ languages=languages,
+ )
sys.stderr.write("Context generated\n")
templates_path = os.path.join(source_path, context["main"]["templates_path"])
jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_path))
+ default_language = context["translations"]["default_language"]
for fname in get_source_files(source_path):
+ selected_language = context["translations"]["default_language"]
+ for language in translated_languages:
+ if fname.startswith(language + "/"):
+ selected_language = language
+ break
+
+ context["selected_language"] = selected_language
if os.path.normpath(fname) in context["main"]["ignore"]:
continue
@@ -473,7 +515,13 @@ def main(
# Python-Markdown doesn't let us config table attributes by hand
body = body.replace("", '')
content = extend_base_template(body, context["main"]["base_template"])
+
context["base_url"] = "".join(["../"] * os.path.normpath(fname).count("/"))
+ if selected_language != default_language:
+ context["base_url"] = "".join(
+ ["../"] * (os.path.normpath(fname).count("/") - 1)
+ )
+
content = jinja_env.from_string(content).render(**context)
fname_html = os.path.splitext(fname)[0] + ".html"
with open(
@@ -484,6 +532,7 @@ def main(
shutil.copy(
os.path.join(source_path, fname), os.path.join(target_path, dirname)
)
+ return 0
if __name__ == "__main__":