From 0f38bb4ebe82cc6c9b70bcd9d0c8f4eb8a1f6900 Mon Sep 17 00:00:00 2001 From: Andre601 Date: Thu, 2 Nov 2023 19:30:52 +0100 Subject: [PATCH] Add flags to language selector --- docs/assets/css/styling/padding.css | 11 + mkdocs.yml | 2 + theme/.hooks/language_flags.py | 237 ++++++++++++++++++++ theme/.hooks/theme_overrides_manager.py | 279 ++++++++++++++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 theme/.hooks/language_flags.py create mode 100644 theme/.hooks/theme_overrides_manager.py diff --git a/docs/assets/css/styling/padding.css b/docs/assets/css/styling/padding.css index 875bf683..260357fa 100644 --- a/docs/assets/css/styling/padding.css +++ b/docs/assets/css/styling/padding.css @@ -33,4 +33,15 @@ color: #ff9100; border: 1px solid #ff9100; background-color: #ff91001a; +} + +.md-select button img.twemoji { + height: 1.2rem; + vertical-align: middle; +} + +.md-select__link img.twemoji { + height: 1rem; + vertical-align: text-bottom; + margin-right: 1%; } \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 0fbc9287..bea8c0aa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -148,6 +148,8 @@ plugins: hooks: - theme/.hooks/badges.py + - theme/.hooks/theme_overrides_manager.py + - theme/.hooks/language_flags.py markdown_extensions: - attr_list diff --git a/theme/.hooks/language_flags.py b/theme/.hooks/language_flags.py new file mode 100644 index 00000000..cca8c6db --- /dev/null +++ b/theme/.hooks/language_flags.py @@ -0,0 +1,237 @@ +"""MkDocs hook, which adds flag emojis to the language selector. +Works only with the Material theme, and requires the "theme_overrides_manager" hook. + +The hook dynamically on the fly modifies the "header.html" template of the Material theme before the build. +It uses the theme_overrides_manager hook to assure original file preservation. +Works both with and without the "i18n" MkDocs plugin. + +MIT Licence 2023 Kamil Krzyśków (HRY) +Parts adapted from the translations.py hook +https://github.com/squidfunk/mkdocs-material/blob/master/src/.overrides/hooks/translations.py +and from the materialx.emoji and pymdownx.emoji modules. +""" +import logging +from pathlib import Path +from typing import Dict, Optional, Union + +from jinja2 import Environment +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import PrefixedLogger +from pymdownx import twemoji_db, util +from pymdownx.emoji import TWEMOJI_SVG_CDN + +# region Core Logic Events + + +def on_env(*_, config: MkDocsConfig, **__) -> Optional[Environment]: + """Main function. Triggers just before the build begins.""" + + LOG.debug('Running "on_env"') + + if not _is_runnable(config=config): + return None + + import material + + partials: Path = Path(material.__file__).parent / "templates" / "partials" + header: Path = partials / "alternate.html" + + config.extra[HOOK_MANAGER].paths_with_processors.append((header, _add_flags)) + + LOG.info(f"Registered processors") + + return None + + +def _is_runnable(*, config: MkDocsConfig) -> bool: + """Make sure the hook should run.""" + + if HOOK_MANAGER not in config["extra"]: + LOG.info(f'"{HOOK_MANAGER}" not detected') + return False + + if config.theme["name"] != "material": + LOG.info('Only the "material" theme is supported') + return False + + if "alternate" not in config["extra"]: + LOG.info('"extra.alternate" not detected') + return False + + if len(config["extra"]["alternate"]) < 2: + LOG.info(f"Not enough languages") + return False + + return True + + +def _add_flags(*, partial: Path, config: MkDocsConfig, **__) -> None: + """Process the "header.html" partial and add flags to the language selector template.""" + + # Configure the tokens + tokens: Dict[str, str] = { + "START": '
', + "CONFIG": '{% set icon = config.theme.icon.alternate or "material/translate" %}', + "SELECTOR": '{% include ".icons/" ~ icon ~ ".svg" %}', + "LINK": "{{ alt.name }}", + "END": "
", + } + + # A negative number means the same level as the START token. + end_indent_level: int = -1 + + override_manager = config.extra[HOOK_MANAGER] + + loaded_section: str = override_manager.load_section( + partial=partial, + tokens=tokens, + end_level=end_indent_level, + ) + + # Do not continue when section is not loaded + if not loaded_section: + return + + # Load all flags relevant to the current build + flag_mapping: Dict[str, str] = { + alt["lang"]: _flag_svg(alt["lang"]) for alt in config["extra"]["alternate"] + } + + # Set the "flag_mapping" selector + # Choose different selector for the "i18n" plugin + selector: str = "i18n_page_locale" if "i18n" in config.plugins else "config.theme.language" + + modified_section: str = ( + loaded_section.replace( + tokens["CONFIG"], "{% set flag_mapping = " + str(flag_mapping) + " %}" + ) + .replace(tokens["SELECTOR"], "{{ flag_mapping[" + selector + "] }}") + .replace( + tokens["LINK"], + "{{ flag_mapping[alt.lang] }} " + + '{{ alt.name | replace(alt.lang, "") | replace("-", "") }}', + ) + ) + + # Modify the partial + override_manager.save_section( + partial=partial, original_section=loaded_section, modified_section=modified_section + ) + + LOG.debug(f'Processed "{partial.name}".') + + +def _flag_svg(alternate_lang: str) -> str: + """Returns a str with a tag containing the flag. Adapted from materialx.emoji and pymdownx.emoji""" + + emoji_code: str = f":flag_{COUNTRIES[alternate_lang]}:" + + shortname: str = INDEX["aliases"].get(emoji_code, emoji_code) + emoji: Dict[str, Optional[str]] = INDEX["emoji"].get(shortname, None) + + if not emoji: + return emoji_code + + unicode: Optional[str] = emoji.get("unicode") + unicode_alt: Optional[str] = emoji.get("unicode_alt", unicode) + + title: str = shortname + alt: str = shortname + + if unicode_alt is not None: + alt = "".join((util.get_char(int(c, 16)) for c in unicode_alt.split("-"))) + + return f'{alt}' + + +# endregion + + +# region Constants + +COUNTRIES: Dict[str, str] = { + "af": "za", + "ar": "ae", + "bg": "bg", + "bn": "bd", + "ca": "es", + "cs": "cz", + "da": "dk", + "de": "de", + "de-CH": "ch", + "el": "gr", + "en": "gb", + "eo": "eu", + "es": "es", + "et": "ee", + "fa": "ir", + "fi": "fi", + "fr": "fr", + "gl": "es", + "he": "il", + "hi": "in", + "hr": "hr", + "hu": "hu", + "hy": "am", + "id": "id", + "is": "is", + "it": "it", + "ja": "jp", + "ka": "ge", + "ko": "kr", + "lt": "lt", + "lv": "lv", + "mk": "mk", + "mn": "mn", + "ms": "my", + "my": "mm", + "nb": "no", + "nl": "nl", + "nn": "no", + "pl": "pl", + "pt-BR": "br", + "pt": "pt", + "ro": "ro", + "ru": "ru", + "sh": "rs", + "si": "lk", + "sk": "sk", + "sl": "si", + "sr": "rs", + "sv": "se", + "th": "th", + "tl": "ph", + "tr": "tr", + "uk": "ua", + "ur": "pk", + "uz": "uz", + "vi": "vn", + "zh": "cn", + "zh-Hant": "cn", + "zh-TW": "tw", +} +"""Mapping of ISO 639-1 (languages) to ISO 3166 (countries).""" +# Adapted from +# https://github.com/squidfunk/mkdocs-material/blob/master/src/.overrides/hooks/translations.py + +HOOK_NAME: str = "language_flags" +"""Name of this hook. Used in logging.""" + +HOOK_MANAGER: str = "theme_overrides_manager" +"""Name of the hook manager. Used to access it in `config.extra`.""" + +INDEX: Dict[str, Union[str, Dict]] = { + "name": "twemoji", + "emoji": twemoji_db.emoji, + "aliases": twemoji_db.aliases, +} +"""Copy of the Twemoji index.""" +# Adapted from +# materialx.emoji + +LOG: PrefixedLogger = PrefixedLogger( + HOOK_NAME, logging.getLogger(f"mkdocs.hooks.theme_overrides.{HOOK_NAME}") +) +"""Logger instance for this hook.""" + +# endregion diff --git a/theme/.hooks/theme_overrides_manager.py b/theme/.hooks/theme_overrides_manager.py new file mode 100644 index 00000000..2b3e3376 --- /dev/null +++ b/theme/.hooks/theme_overrides_manager.py @@ -0,0 +1,279 @@ +"""MkDocs hook, which adds a backup and restore cycle for files that will be overriden. + +On its own it is not that useful. It acts as the manager for other hooks. +It gathers all files from other hooks and triggers their functions. + +MIT Licence 2023 Kamil Krzyśków (HRY) +""" +import enum +import filecmp +import logging +import shutil +import tempfile +from pathlib import Path +from typing import Callable, Dict, List, Optional, Set, Tuple + +import jinja2 +from mkdocs import plugins +from mkdocs.config import Config +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import PrefixedLogger +from mkdocs.structure.files import Files + +# region Core Logic Events + + +@plugins.event_priority(100) +def on_config(config: MkDocsConfig) -> Optional[Config]: + """Triggers just after the config loaded. + + Initializes the ThemeOverridesManager class to pass data between hooks. + """ + + LOG.debug('Running "on_config"') + + ThemeOverridesManager.initialize() + config.extra[HOOK_NAME] = ThemeOverridesManager + + LOG.info("Theme overrides manager initialized") + + return None + + +@plugins.event_priority(-75) +def on_env( + env: jinja2.Environment, *, config: MkDocsConfig, files: Files +) -> Optional[jinja2.Environment]: + """Main function. Triggers just before the build begins.""" + + LOG.debug('Running "on_env"') + + if not ThemeOverridesManager.all_paths_exist(): + return None + + if not ThemeOverridesManager.paths_with_processors: + LOG.info("No file processors were registered") + return None + + ThemeOverridesManager.create_backup() + + # Invoke each function with all available parameters + for path, func in ThemeOverridesManager.paths_with_processors: + func(partial=path, env=env, config=config, files=files) + + return None + + +# endregion + +# region Closing Events + + +def on_build_error(*_, **__) -> None: + """Restores backup. Triggers when the build errors, safety measure if "on_shutdown" won't trigger.""" + + LOG.debug('Running "on_build_error"') + + ThemeOverridesManager.restore_backup() + + +def on_post_build(*_, **__) -> None: + """Restores backup. Triggers when a build finishes.""" + + LOG.debug('Running "on_post_build"') + + ThemeOverridesManager.restore_backup() + + +def on_shutdown(*_, **__) -> None: + """Restores backup. Triggers when MkDocs terminates.""" + + LOG.debug('Running "on_shutdown"') + + ThemeOverridesManager.restore_backup() + + +# endregion + + +# region Backup Management Class + + +class BackupState(enum.IntEnum): + """Possible states for the backup process""" + + NONE: int = 10 + """None backups exist""" + + CREATED: int = 20 + """All backups exist""" + + +class ThemeOverridesManager: + """Helper class, which handles the backing up of files""" + + def __init__(self): + raise NotImplementedError("This class should have no instances.") + + @classmethod + def initialize(cls): + cls.backup_state = BackupState.NONE + cls.sources = set() + cls.paths_with_processors = [] + + @classmethod + def all_paths_exist(cls) -> bool: + """Checks if the provided source paths exist""" + + result = True + + for src, _ in cls.paths_with_processors: + if not src.exists(): + LOG.error(f"File doesn't exist {src}") + result = False + + return result + + @classmethod + def create_backup(cls) -> None: + """Creates backup files with ".backup" suffix within the same directory as the source file.""" + + for src, _ in cls.paths_with_processors: + cls.sources.add(src) + + backup_len: int = len(cls.sources) + + LOG.info(f'Backing up {backup_len} file{"" if backup_len == 1 else "s"}...') + + for src in cls.sources: + backup: Path = Path(f"{src}.backup") + if backup.exists(): + LOG.info(f'Found "{backup.name}" before creation, restoring...') + shutil.copy2(backup, src) + assert filecmp.cmp(backup, src, shallow=False) + continue + + shutil.copy2(src, backup) + assert filecmp.cmp(src, backup, shallow=False) + LOG.debug(f'Created "{backup.name}"') + + if backup_len > 0: + cls.backup_state = BackupState.CREATED + + @classmethod + def restore_backup(cls) -> None: + """Restores backup files and deletes them after""" + + if cls.backup_state != BackupState.CREATED: + return + + backup_len: int = len(cls.sources) + + LOG.info(f'Restoring {backup_len} file{"" if backup_len == 1 else "s"}...') + for src in cls.sources: + backup: Path = Path(f"{src}.backup") + if not backup.exists(): + LOG.error(f'Backup "{backup.name}" doesn\'t exist') + continue + + shutil.copy2(backup, src) + assert filecmp.cmp(backup, src, shallow=False) + backup.unlink() + LOG.debug(f'Restored "{src.name}"') + + cls.backup_state = BackupState.NONE + + @classmethod + def load_section(cls, *, partial: Path, tokens: Dict[str, str], end_level: int) -> str: + """Load the section for a given partial together with some validation""" + + content: str = partial.read_text(encoding="utf8") + lines: List[str] = [] + section_started: bool = False + section_ended: bool = False + + for line in content.split("\n"): + if tokens["START"] in line: + section_started = True + if end_level < 0: + end_level = len(line) - len(line.lstrip()) + + if section_started: + lines.append(line) + if tokens["END"] in line and (len(line) - len(line.lstrip())) == end_level: + section_ended = True + break + + loaded_section: str = "\n".join(lines) + + # Validate that the loaded section started and ended + # Theme update, or another dynamic hook could have removed needed tokens + if not section_ended or not section_started: + message: str = "started" if not section_started else "ended" + LOG.error(f'Section in file "{partial.name}" never {message}') + cls.write_log(loaded_section, f"{partial.name}_read_error.html") + return "" + + # Validate that the loaded section contains all tokens + # Theme update, or another dynamic hook could have removed needed tokens + try: + for value in tokens.values(): + assert value in loaded_section + except AssertionError: + LOG.error(f'Section mismatch in "{partial.name}"') + log_content = "" + for value in tokens.values(): + log_content += f"{value in loaded_section} - {value}\n" + cls.write_log(log_content + loaded_section, f"{partial.name}_mismatch.html") + return "" + + return loaded_section + + @classmethod + def save_section(cls, *, partial: Path, original_section: str, modified_section: str) -> None: + """Save partial with modified section""" + + content: str = partial.read_text(encoding="utf8") + partial.write_text( + data=content.replace(original_section, modified_section), encoding="utf8" + ) + + @classmethod + def write_log(cls, content: str, file_name: str) -> None: + """Write `content` to TEMP_DIR/`file_name`""" + crash_log = Path(tempfile.gettempdir()) / file_name + crash_log.write_text(data=content, encoding="utf8") + LOG.info(f'File saved "{crash_log}"') + + backup_state: BackupState + """Status of the backup process""" + + sources: Set[Path] + """Set of source file paths, which will be backed up.""" + + paths_with_processors: List[Tuple[Path, Callable]] + """List of tuple pairs of file paths and their processing functions. + + Each function gets called with the following parameters in the `on_env` event: + + - partial: pathlib.Path + - config: mkdocs.config.defaults.MkDocsConfig + - env: jinja2.Environment + - files: Files + """ + + +# endregion + + +# region Constants + +HOOK_NAME: str = "theme_overrides_manager" +"""Name of this hook. Used in logging.""" + +LOG: PrefixedLogger = PrefixedLogger( + HOOK_NAME, logging.getLogger("mkdocs.hooks.theme_overrides.__main__") +) +"""Logger instance for this hook.""" + +# endregion