From 5f5ae44fcd321172e8787a9da6864c874e098786 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Thu, 1 Feb 2024 18:23:39 +0100 Subject: [PATCH] Enable autoescape in themes Jinja templates --- mkdocs/commands/build.py | 11 ++++++++--- mkdocs/config/config_options.py | 10 ++++++++++ mkdocs/config/defaults.py | 2 +- mkdocs/structure/__init__.py | 4 +++- mkdocs/structure/nav.py | 18 ++++++++++-------- mkdocs/structure/pages.py | 9 +++++---- mkdocs/structure/toc.py | 11 +++++++---- mkdocs/tests/structure/page_tests.py | 3 ++- mkdocs/theme.py | 2 +- mkdocs/themes/mkdocs/base.html | 2 +- mkdocs/themes/readthedocs/footer.html | 2 +- 11 files changed, 49 insertions(+), 25 deletions(-) diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py index f111bcdc..a8c2726e 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -9,6 +9,7 @@ from urllib.parse import urljoin, urlsplit import jinja2 from jinja2.exceptions import TemplateNotFound +from markupsafe import Markup import mkdocs from mkdocs import utils @@ -212,11 +213,15 @@ def _build_page( # Run `page_context` plugin events. context = config.plugins.on_page_context(context, page=page, config=config, nav=nav) + page.content = Markup(page.content or '') if excluded: page.content = ( - '
' - 'DRAFT' - '
' + (page.content or '') + Markup( + '
' + 'DRAFT' + '
' + ) + + page.content ) # Render the template. diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py index 3025045f..ea4f9148 100644 --- a/mkdocs/config/config_options.py +++ b/mkdocs/config/config_options.py @@ -32,6 +32,7 @@ from urllib.parse import urlsplit, urlunsplit import markdown import pathspec import pathspec.gitignore +from markupsafe import Markup from mkdocs import plugins, theme, utils from mkdocs.config.base import ( @@ -536,6 +537,15 @@ class URL(OptionallyRequired[str]): raise ValidationError("The URL isn't valid, it should include the http:// (scheme)") +class HTMLString(BaseConfigOption[Markup]): + """A string of HTML that should not be further escaped when pasting it into other HTML content.""" + + def run_validation(self, value: object) -> Markup: + if not isinstance(value, str): + raise ValidationError(f"Expected a string but received: {type(value)}") + return Markup(value) + + class Optional(Generic[T], BaseConfigOption[Union[T, None]]): """ Wraps a field and makes a None value possible for it when no value is set. diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index 68ebf3a8..be9cb002 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -79,7 +79,7 @@ class MkDocsConfig(base.Config): site_dir = c.SiteDir(default='site') """The directory where the site will be built to""" - copyright = c.Optional(c.Type(str)) + copyright = c.Optional(c.HTMLString()) """A copyright notice to add to the footer of documentation.""" google_analytics = c.Deprecated( diff --git a/mkdocs/structure/__init__.py b/mkdocs/structure/__init__.py index c99b6575..daf5b1d3 100644 --- a/mkdocs/structure/__init__.py +++ b/mkdocs/structure/__init__.py @@ -4,6 +4,8 @@ import abc from typing import TYPE_CHECKING, Iterable if TYPE_CHECKING: + from markupsafe import Markup + from mkdocs.structure.nav import Section @@ -21,7 +23,7 @@ class StructureItem(metaclass=abc.ABCMeta): def is_top_level(self) -> bool: return self.parent is None - title: str | None + title: Markup | None is_section: bool = False is_page: bool = False is_link: bool = False diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py index 21815fe6..5d5c4def 100644 --- a/mkdocs/structure/nav.py +++ b/mkdocs/structure/nav.py @@ -4,6 +4,8 @@ import logging from typing import TYPE_CHECKING, Iterator, TypeVar from urllib.parse import urlsplit +from markupsafe import Markup + from mkdocs.exceptions import BuildError from mkdocs.structure import StructureItem from mkdocs.structure.files import file_sort_key @@ -46,17 +48,17 @@ class Navigation: class Section(StructureItem): - def __init__(self, title: str, children: list[StructureItem]) -> None: - self.title = title + def __init__(self, title: Markup, children: list[StructureItem]) -> None: + self.title = Markup(title) self.children = children self.active = False def __repr__(self): name = self.__class__.__name__ - return f"{name}(title={self.title!r})" + return f"{name}(title={str(self.title)!r})" - title: str + title: Markup """The title of the section.""" children: list[StructureItem] @@ -95,16 +97,16 @@ class Section(StructureItem): class Link(StructureItem): - def __init__(self, title: str, url: str): - self.title = title + def __init__(self, title: Markup, url: str): + self.title = Markup(title) self.url = url def __repr__(self): name = self.__class__.__name__ - title = f"{self.title!r}" if self.title is not None else '[blank]' + title = f"{str(self.title)!r}" if self.title is not None else '[blank]' return f"{name}(title={title}, url={self.url!r})" - title: str + title: Markup """The title of the link. This would generally be used as the label of the link.""" url: str diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py index 8b0a642d..e62aa128 100644 --- a/mkdocs/structure/pages.py +++ b/mkdocs/structure/pages.py @@ -15,6 +15,7 @@ import markdown.htmlparser # type: ignore import markdown.postprocessors import markdown.treeprocessors from markdown.util import AMP_SUBSTITUTE +from markupsafe import Markup from mkdocs import utils from mkdocs.structure import StructureItem @@ -33,11 +34,11 @@ log = logging.getLogger(__name__) class Page(StructureItem): - def __init__(self, title: str | None, file: File, config: MkDocsConfig) -> None: + def __init__(self, title: Markup | None, file: File, config: MkDocsConfig) -> None: file.page = self self.file = file if title is not None: - self.title = title + self.title = Markup(title) # Navigation attributes self.children = None @@ -68,7 +69,7 @@ class Page(StructureItem): def __repr__(self): name = self.__class__.__name__ - title = f"{self.title!r}" if self.title is not None else '[blank]' + title = f"{str(self.title)!r}" if self.title is not None else '[blank]' url = self.abs_url or self.file.url return f"{name}(title={title}, url={url!r})" @@ -281,7 +282,7 @@ class Page(StructureItem): extract_title_ext = _ExtractTitleTreeprocessor() extract_title_ext._register(md) - self.content = md.convert(self.markdown) + self.content = Markup(md.convert(self.markdown)) self.toc = get_toc(getattr(md, 'toc_tokens', [])) self._title_from_render = extract_title_ext.title self.present_anchor_ids = ( diff --git a/mkdocs/structure/toc.py b/mkdocs/structure/toc.py index e1df40be..f2917343 100644 --- a/mkdocs/structure/toc.py +++ b/mkdocs/structure/toc.py @@ -5,15 +5,18 @@ For the sake of simplicity we use the Python-Markdown `toc` extension to generate a list of dicts for each toc item, and then store it as AnchorLinks to maintain compatibility with older versions of MkDocs. """ + from __future__ import annotations from typing import Iterable, Iterator, TypedDict +from markupsafe import Markup + class _TocToken(TypedDict): level: int id: str - name: str + name: Markup children: list[_TocToken] @@ -28,11 +31,11 @@ def get_toc(toc_tokens: list[_TocToken]) -> TableOfContents: class AnchorLink: """A single entry in the table of contents.""" - def __init__(self, title: str, id: str, level: int) -> None: - self.title, self.id, self.level = title, id, level + def __init__(self, title: Markup, id: str, level: int) -> None: + self.title, self.id, self.level = Markup(title), id, level self.children = [] - title: str + title: Markup """The text of the item, as HTML.""" @property diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py index 77d8542b..7cbf38d0 100644 --- a/mkdocs/tests/structure/page_tests.py +++ b/mkdocs/tests/structure/page_tests.py @@ -7,6 +7,7 @@ import unittest from unittest import mock import markdown +from markupsafe import Markup from mkdocs.config.defaults import MkDocsConfig from mkdocs.structure.files import File, Files @@ -749,7 +750,7 @@ class RelativePathExtensionTests(unittest.TestCase): ) -> str: cfg = load_config(docs_dir=DOCS_DIR, **kwargs) fs = [File(f, cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) for f in files] - pg = Page('Foo', fs[0], cfg) + pg = Page(Markup('Foo'), fs[0], cfg) with mock.patch('mkdocs.structure.files.open', mock.mock_open(read_data=content)): pg.read_source(cfg) diff --git a/mkdocs/theme.py b/mkdocs/theme.py index bf587201..c10ba01b 100644 --- a/mkdocs/theme.py +++ b/mkdocs/theme.py @@ -156,7 +156,7 @@ class Theme(MutableMapping[str, Any]): """Return a Jinja environment for the theme.""" loader = jinja2.FileSystemLoader(self.dirs) # No autoreload because editing a template in the middle of a build is not useful. - env = jinja2.Environment(loader=loader, auto_reload=False) + env = jinja2.Environment(loader=loader, auto_reload=False, autoescape=True) env.filters['url'] = templates.url_filter env.filters['script_tag'] = templates.script_tag_filter localization.install_translations(env, self.locale, self.dirs) diff --git a/mkdocs/themes/mkdocs/base.html b/mkdocs/themes/mkdocs/base.html index 2b077bd0..ed7a263b 100644 --- a/mkdocs/themes/mkdocs/base.html +++ b/mkdocs/themes/mkdocs/base.html @@ -184,7 +184,7 @@ {%- if config.copyright %}

{{ config.copyright }}

{%- endif %} -

{% trans mkdocs_link='MkDocs' %}Documentation built with {{ mkdocs_link }}.{% endtrans %}

+

{% trans mkdocs_link='MkDocs'|safe %}Documentation built with {{ mkdocs_link }}.{% endtrans %}

{%- endblock %} diff --git a/mkdocs/themes/readthedocs/footer.html b/mkdocs/themes/readthedocs/footer.html index 67bdad8f..33e4a322 100644 --- a/mkdocs/themes/readthedocs/footer.html +++ b/mkdocs/themes/readthedocs/footer.html @@ -22,5 +22,5 @@ {%- endif %} - {% trans mkdocs_link='MkDocs', sphinx_link='theme', rtd_link='Read the Docs' %}Built with %(mkdocs_link)s using a %(sphinx_link)s provided by %(rtd_link)s.{% endtrans %} + {% trans mkdocs_link='MkDocs'|safe, sphinx_link='theme'|safe, rtd_link='Read the Docs'|safe %}Built with %(mkdocs_link)s using a %(sphinx_link)s provided by %(rtd_link)s.{% endtrans %}