Support `theme.toml`

This commit is contained in:
Adam Turner 2024-04-11 18:01:08 +01:00 committed by Adam Turner
parent a5d7cce7f5
commit 8976fa4498
4 changed files with 153 additions and 8 deletions

View File

@ -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 ``<link rel="stylesheet">`` 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_<name>``.
.. 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``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -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

View File

@ -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')

View File

@ -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))