diff --git a/doc/development/theming.rst b/doc/development/theming.rst index a57b8b7dd..0aaaaa248 100644 --- a/doc/development/theming.rst +++ b/doc/development/theming.rst @@ -30,11 +30,82 @@ Creating themes Themes take the form of either a directory or a zipfile (whose name is the theme name), containing the following: -* A :file:`theme.conf` file. +* Either a :file:`theme.toml` file (preferred) or a :file:`theme.conf` file. * HTML templates, if needed. * A ``static/`` directory containing any static files that will be copied to the output static directory on build. These can be images, styles, script files. +Theme configuration (``theme.toml``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :file:`theme.toml` file is a TOML_ document, +containing two tables: ``[theme]`` and ``[options]``. + +The ``[theme]`` table defines the theme's settings: + +* **inherit** (string): The name of the base theme from which to inherit + settings, options, templates, and static files. + All static files from theme 'ancestors' will be used. + The theme will use all options defined in inherited themes. + Finally, inherited themes will be used to locate missing templates + (for example, if ``"basic"`` is used as the base theme, most templates will + already be defined). + + If set to ``"none"``, the theme will not inherit from any other theme. + Inheritance is recursive, forming a chain of inherited themes + (e.g. ``default`` -> ``classic`` -> ``basic`` -> ``none``). + +* **stylesheets** (list of strings): A list of CSS filenames which will be + included in generated HTML header. + Setting the :confval:`html_style` config value will override this setting. + + Other mechanisms for including multiple stylesheets include ``@import`` in CSS + or using a custom HTML template with appropriate ```` tags. + +* **sidebars** (list of strings): A list of sidebar templates. + This can be overridden by the user via the :confval:`html_sidebars` config value. + +* **pygments_style** (table): A TOML table defining the names of Pygments styles + to use for highlighting syntax. + The table has two recognised keys: ``default`` and ``dark``. + The style defined in the ``dark`` key will be used when + the CSS media query ``(prefers-color-scheme: dark)`` evaluates to true. + + ``[theme.pygments_style.default]`` can be overridden by the user via the + :confval:`pygments_style` config value. + +The ``[options]`` table defines the options for the theme. +It is structured such that each key-value pair corresponds to a variable name +and the corresponding default value. +These options can be overridden by the user in :confval:`html_theme_options` +and are accessible from all templates as ``theme_``. + +.. versionadded:: 7.3 + ``theme.toml`` support. + +.. _TOML: https://toml.io/en/ + +Exemplar :file:`theme.toml` file: + +.. code-block:: toml + + [theme] + inherit = "basic" + stylesheets = [ + "main-CSS-stylesheet.css", + ] + sidebars = [ + "localtoc.html", + "relations.html", + "sourcelink.html", + "searchbox.html", + ] + # Style names from https://pygments.org/styles/ + pygments_style = { default = "style_name", dark = "dark_style" } + + [options] + variable = "default value" + Theme configuration (``theme.conf``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/usage/theming.rst b/doc/usage/theming.rst index 098a151db..be46cab1c 100644 --- a/doc/usage/theming.rst +++ b/doc/usage/theming.rst @@ -56,7 +56,7 @@ page's top and bottom), add the following :file:`conf.py`:: If the theme does not come with Sphinx, it can be in two static forms or as a Python package. For the static forms, either a directory (containing -:file:`theme.conf` and other needed files), or a zip file with the same +:file:`theme.toml` and other needed files), or a zip file with the same contents is supported. The directory or zipfile must be put where Sphinx can find it; for this there is the config value :confval:`html_theme_path`. This can be a list of directories, relative to the directory containing diff --git a/sphinx/theming.py b/sphinx/theming.py index 4dfb70e67..7239f6341 100644 --- a/sphinx/theming.py +++ b/sphinx/theming.py @@ -21,17 +21,42 @@ from sphinx.locale import __ from sphinx.util import logging from sphinx.util.osutil import ensuredir +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib # type: ignore[import-not-found] + if sys.version_info >= (3, 10): from importlib.metadata import entry_points else: from importlib_metadata import entry_points # type: ignore[import-not-found] if TYPE_CHECKING: + from typing import TypedDict + + from typing_extensions import Required + from sphinx.application import Sphinx + class _ThemeToml(TypedDict, total=False): + theme: Required[_ThemeTomlTheme] + options: dict[str, str] + + class _ThemeTomlTheme(TypedDict, total=False): + inherit: Required[str] + stylesheets: list[str] + sidebars: list[str] + pygments_style: _ThemeTomlThemePygments + + class _ThemeTomlThemePygments(TypedDict, total=False): + default: str + dark: str + + logger = logging.getLogger(__name__) _NO_DEFAULT = object() +_THEME_TOML = 'theme.toml' _THEME_CONF = 'theme.conf' @@ -185,7 +210,9 @@ class HTMLThemeFactory: entry, ) else: - if path.isfile(path.join(pathname, _THEME_CONF)): + toml_path = path.join(pathname, _THEME_TOML) + conf_path = path.join(pathname, _THEME_CONF) + if path.isfile(toml_path) or path.isfile(conf_path): themes[entry] = pathname return themes @@ -196,7 +223,7 @@ class HTMLThemeFactory: self._load_extra_theme(name) if name not in self._themes: - raise ThemeError(__('no theme named %r found (missing theme.conf?)') % name) + raise ThemeError(__('no theme named %r found (missing theme.toml?)') % name) themes, theme_dirs, tmp_dirs = _load_theme_with_ancestors(self._themes, name) return Theme(name, configs=themes, paths=theme_dirs, tmp_dirs=tmp_dirs) @@ -206,7 +233,8 @@ def _is_archived_theme(filename: str, /) -> bool: """Check whether the specified file is an archived theme file or not.""" try: with ZipFile(filename) as f: - return _THEME_CONF in f.namelist() + namelist = frozenset(f.namelist()) + return _THEME_TOML in namelist or _THEME_CONF in namelist except Exception: return False @@ -255,7 +283,11 @@ def _load_theme(name: str, theme_path: str, /) -> tuple[str, str, str | None, _C theme_dir = path.join(tmp_dir, name) _extract_zip(theme_path, theme_dir) - if path.isfile(conf_path := path.join(theme_dir, _THEME_CONF)): + if path.isfile(toml_path := path.join(theme_dir, _THEME_TOML)): + _cfg_table = _load_theme_toml(toml_path) + inherit = _validate_theme_toml(_cfg_table, name) + config = _convert_theme_toml(_cfg_table) + elif path.isfile(conf_path := path.join(theme_dir, _THEME_CONF)): _cfg_parser = _load_theme_conf(conf_path) inherit = _validate_theme_conf(_cfg_parser, name) config = _convert_theme_conf(_cfg_parser) @@ -279,6 +311,48 @@ def _extract_zip(filename: str, target_dir: str, /) -> None: fp.write(archive.read(name)) +def _load_theme_toml(config_file_path: str, /) -> _ThemeToml: + with open(config_file_path, encoding='utf-8') as f: + config_text = f.read() + c = tomllib.loads(config_text) + return {s: c[s] for s in ('theme', 'options') if s in c} # type: ignore[return-value] + + +def _validate_theme_toml(cfg: _ThemeToml, name: str) -> str: + if 'theme' not in cfg: + raise ThemeError(__('theme %r doesn\'t have the "theme" table') % name) + if inherit := cfg['theme'].get('inherit', ''): + return inherit + msg = __('The %r theme must define the "theme.inherit" setting') % name + raise ThemeError(msg) + + +def _convert_theme_toml(cfg: _ThemeToml, /) -> _ConfigFile: + theme = cfg['theme'] + if 'stylesheets' in theme: + stylesheets: tuple[str, ...] | None = tuple(theme['stylesheets']) + else: + stylesheets = None + if 'sidebars' in theme: + sidebar_templates: tuple[str, ...] | None = tuple(theme['sidebars']) + else: + sidebar_templates = None + pygments_table = theme.get('pygments_style', {}) + if isinstance(pygments_table, str): + hint = f'pygments_style = {{ default = "{pygments_table}" }}' + msg = __('The "theme.pygments_style" setting must be a table. Hint: "%s"') % hint + raise ThemeError(msg) + pygments_style_default: str | None = pygments_table.get('default') + pygments_style_dark: str | None = pygments_table.get('dark') + return _ConfigFile( + stylesheets=stylesheets, + sidebar_templates=sidebar_templates, + pygments_style_default=pygments_style_default, + pygments_style_dark=pygments_style_dark, + options=cfg.get('options', {}), + ) + + def _load_theme_conf(config_file_path: str, /) -> configparser.RawConfigParser: c = configparser.RawConfigParser() c.read(config_file_path, encoding='utf-8') diff --git a/tests/test_theming/test_theming.py b/tests/test_theming/test_theming.py index b2c6c3ed0..5ed2eedd8 100644 --- a/tests/test_theming/test_theming.py +++ b/tests/test_theming/test_theming.py @@ -77,8 +77,8 @@ def test_theme_api(app, status, warning): assert not any(map(os.path.exists, theme._tmp_dirs)) -def test_nonexistent_theme_conf(tmp_path): - # Check that error occurs with a non-existent theme.conf +def test_nonexistent_theme_settings(tmp_path): + # Check that error occurs with a non-existent theme.toml or theme.conf # (https://github.com/sphinx-doc/sphinx/issues/11668) with pytest.raises(ThemeError): _load_theme('', str(tmp_path))