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 @@ 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__":