1"""Several HTML builders."""
2
3from __future__ import annotations
4
5import contextlib
6import hashlib
7import html
8import os
9import posixpath
10import re
11import sys
12import time
13import types
14import warnings
15from os import path
16from typing import IO, TYPE_CHECKING, Any
17from urllib.parse import quote
18
19import docutils.readers.doctree
20from docutils import nodes
21from docutils.core import Publisher
22from docutils.frontend import OptionParser
23from docutils.io import DocTreeInput, StringOutput
24from docutils.utils import relative_path
25
26from sphinx import __display_version__, package_dir
27from sphinx import version_info as sphinx_version
28from sphinx.builders import Builder
29from sphinx.builders.html._assets import _CascadingStyleSheet, _file_checksum, _JavaScript
30from sphinx.config import ENUM, Config
31from sphinx.deprecation import _deprecation_warning
32from sphinx.domains import Domain, Index, IndexEntry
33from sphinx.environment.adapters.asset import ImageAdapter
34from sphinx.environment.adapters.indexentries import IndexEntries
35from sphinx.environment.adapters.toctree import document_toc, global_toctree_for_doc
36from sphinx.errors import ConfigError, ThemeError
37from sphinx.highlighting import PygmentsBridge
38from sphinx.locale import _, __
39from sphinx.search import js_index
40from sphinx.theming import HTMLThemeFactory
41from sphinx.util import isurl, logging
42from sphinx.util.display import progress_message, status_iterator
43from sphinx.util.docutils import new_document
44from sphinx.util.fileutil import copy_asset
45from sphinx.util.i18n import format_date
46from sphinx.util.inventory import InventoryFile
47from sphinx.util.matching import DOTFILES, Matcher, patmatch
48from sphinx.util.osutil import SEP, copyfile, ensuredir, os_path, relative_uri
49from sphinx.writers.html import HTMLWriter
50from sphinx.writers.html5 import HTML5Translator
51
52if TYPE_CHECKING:
53 from collections.abc import Iterable, Iterator, Set
54
55 from docutils.nodes import Node
56 from docutils.readers import Reader
57
58 from sphinx.application import Sphinx
59 from sphinx.config import _ConfigRebuild
60 from sphinx.environment import BuildEnvironment
61 from sphinx.util.tags import Tags
62 from sphinx.util.typing import ExtensionMetadata
63
64#: the filename for the inventory of objects
65INVENTORY_FILENAME = 'objects.inv'
66
67logger = logging.getLogger(__name__)
68return_codes_re = re.compile('[\r\n]+')
69
70DOMAIN_INDEX_TYPE = tuple[
71 # Index name (e.g. py-modindex)
72 str,
73 # Index class
74 type[Index],
75 # list of (heading string, list of index entries) pairs.
76 list[tuple[str, list[IndexEntry]]],
77 # whether sub-entries should start collapsed
78 bool,
79]
80
81
82def _stable_hash(obj: Any) -> str:
83 """Return a stable hash for a Python data structure.
84
85 We can't just use the md5 of str(obj) as the order of collections
86 may be random.
87 """
88 if isinstance(obj, dict):
89 obj = sorted(map(_stable_hash, obj.items()))
90 if isinstance(obj, (list, tuple, set, frozenset)):
91 obj = sorted(map(_stable_hash, obj))
92 elif isinstance(obj, (type, types.FunctionType)):
93 # The default repr() of functions includes the ID, which is not ideal.
94 # We use the fully qualified name instead.
95 obj = f'{obj.__module__}.{obj.__qualname__}'
96 return hashlib.md5(str(obj).encode(), usedforsecurity=False).hexdigest()
97
98
99def convert_locale_to_language_tag(locale: str | None) -> str | None:
100 """Convert a locale string to a language tag (ex. en_US -> en-US).
101
102 refs: BCP 47 (:rfc:`5646`)
103 """
104 if locale:
105 return locale.replace('_', '-')
106 else:
107 return None
108
109
110class BuildInfo:
111 """buildinfo file manipulator.
112
113 HTMLBuilder and its family are storing their own envdata to ``.buildinfo``.
114 This class is a manipulator for the file.
115 """
116
117 @classmethod
118 def load(cls: type[BuildInfo], f: IO[str]) -> BuildInfo:
119 try:
120 lines = f.readlines()
121 assert lines[0].rstrip() == '# Sphinx build info version 1'
122 assert lines[2].startswith('config: ')
123 assert lines[3].startswith('tags: ')
124
125 build_info = BuildInfo()
126 build_info.config_hash = lines[2].split()[1].strip()
127 build_info.tags_hash = lines[3].split()[1].strip()
128 return build_info
129 except Exception as exc:
130 raise ValueError(__('build info file is broken: %r') % exc) from exc
131
132 def __init__(
133 self,
134 config: Config | None = None,
135 tags: Tags | None = None,
136 config_categories: Set[_ConfigRebuild] = frozenset(),
137 ) -> None:
138 self.config_hash = ''
139 self.tags_hash = ''
140
141 if config:
142 values = {c.name: c.value for c in config.filter(config_categories)}
143 self.config_hash = _stable_hash(values)
144
145 if tags:
146 self.tags_hash = _stable_hash(sorted(tags))
147
148 def __eq__(self, other: BuildInfo) -> bool: # type: ignore[override]
149 return (self.config_hash == other.config_hash and
150 self.tags_hash == other.tags_hash)
151
152 def dump(self, f: IO[str]) -> None:
153 f.write('# Sphinx build info version 1\n'
154 '# This file hashes the configuration used when building these files.'
155 ' When it is not found, a full rebuild will be done.\n'
156 'config: %s\n'
157 'tags: %s\n' %
158 (self.config_hash, self.tags_hash))
159
160
161class StandaloneHTMLBuilder(Builder):
162 """
163 Builds standalone HTML docs.
164 """
165
166 name = 'html'
167 format = 'html'
168 epilog = __('The HTML pages are in %(outdir)s.')
169
170 default_translator_class = HTML5Translator
171 copysource = True
172 allow_parallel = True
173 out_suffix = '.html'
174 link_suffix = '.html' # defaults to matching out_suffix
175 indexer_format: Any = js_index
176 indexer_dumps_unicode = True
177 # create links to original images from images [True/False]
178 html_scaled_image_link = True
179 supported_image_types = ['image/svg+xml', 'image/png',
180 'image/gif', 'image/jpeg']
181 supported_remote_images = True
182 supported_data_uri_images = True
183 searchindex_filename = 'searchindex.js'
184 add_permalinks = True
185 allow_sharp_as_current_path = True
186 embedded = False # for things like HTML help or Qt help: suppresses sidebar
187 search = True # for things like HTML help and Apple help: suppress search
188 use_index = False
189 download_support = True # enable download role
190
191 imgpath: str = ''
192 domain_indices: list[DOMAIN_INDEX_TYPE] = []
193
194 def __init__(self, app: Sphinx, env: BuildEnvironment) -> None:
195 super().__init__(app, env)
196
197 # CSS files
198 self._css_files: list[_CascadingStyleSheet] = []
199
200 # JS files
201 self._js_files: list[_JavaScript] = []
202
203 # Cached Publisher for writing doctrees to HTML
204 reader: Reader[DocTreeInput] = docutils.readers.doctree.Reader(
205 parser_name='restructuredtext'
206 )
207 pub = Publisher(
208 reader=reader,
209 parser=reader.parser,
210 writer=HTMLWriter(self),
211 source_class=DocTreeInput,
212 destination=StringOutput(encoding='unicode'),
213 )
214 pub.get_settings(output_encoding='unicode', traceback=True)
215 self._publisher = pub
216
217 def init(self) -> None:
218 self.build_info = self.create_build_info()
219 # basename of images directory
220 self.imagedir = '_images'
221 # section numbers for headings in the currently visited document
222 self.secnumbers: dict[str, tuple[int, ...]] = {}
223 # currently written docname
224 self.current_docname: str = ''
225
226 self.init_templates()
227 self.init_highlighter()
228 self.init_css_files()
229 self.init_js_files()
230
231 html_file_suffix = self.get_builder_config('file_suffix', 'html')
232 if html_file_suffix is not None:
233 self.out_suffix = html_file_suffix
234
235 html_link_suffix = self.get_builder_config('link_suffix', 'html')
236 if html_link_suffix is not None:
237 self.link_suffix = html_link_suffix
238 else:
239 self.link_suffix = self.out_suffix
240
241 self.use_index = self.get_builder_config('use_index', 'html')
242
243 def create_build_info(self) -> BuildInfo:
244 return BuildInfo(self.config, self.tags, frozenset({'html'}))
245
246 def _get_translations_js(self) -> str:
247 candidates = [path.join(dir, self.config.language,
248 'LC_MESSAGES', 'sphinx.js')
249 for dir in self.config.locale_dirs] + \
250 [path.join(package_dir, 'locale', self.config.language,
251 'LC_MESSAGES', 'sphinx.js'),
252 path.join(sys.prefix, 'share/sphinx/locale',
253 self.config.language, 'sphinx.js')]
254
255 for jsfile in candidates:
256 if path.isfile(jsfile):
257 return jsfile
258 return ''
259
260 def _get_style_filenames(self) -> Iterator[str]:
261 if isinstance(self.config.html_style, str):
262 yield self.config.html_style
263 elif self.config.html_style is not None:
264 yield from self.config.html_style
265 elif self.theme:
266 yield from self.theme.stylesheets
267 else:
268 yield 'default.css'
269
270 def get_theme_config(self) -> tuple[str, dict[str, str | int | bool]]:
271 return self.config.html_theme, self.config.html_theme_options
272
273 def init_templates(self) -> None:
274 theme_factory = HTMLThemeFactory(self.app)
275 theme_name, theme_options = self.get_theme_config()
276 self.theme = theme_factory.create(theme_name)
277 self.theme_options = theme_options
278 self.create_template_bridge()
279 self.templates.init(self, self.theme)
280
281 def init_highlighter(self) -> None:
282 # determine Pygments style and create the highlighter
283 if self.config.pygments_style is not None:
284 style = self.config.pygments_style
285 elif self.theme:
286 # From the ``pygments_style`` theme setting
287 style = self.theme.pygments_style_default or 'none'
288 else:
289 style = 'sphinx'
290 self.highlighter = PygmentsBridge('html', style)
291
292 if self.theme:
293 # From the ``pygments_dark_style`` theme setting
294 dark_style = self.theme.pygments_style_dark
295 else:
296 dark_style = None
297
298 self.dark_highlighter: PygmentsBridge | None
299 if dark_style is not None:
300 self.dark_highlighter = PygmentsBridge('html', dark_style)
301 self.app.add_css_file('pygments_dark.css',
302 media='(prefers-color-scheme: dark)',
303 id='pygments_dark_css')
304 else:
305 self.dark_highlighter = None
306
307 @property
308 def css_files(self) -> list[_CascadingStyleSheet]:
309 _deprecation_warning(__name__, f'{self.__class__.__name__}.css_files', remove=(9, 0))
310 return self._css_files
311
312 def init_css_files(self) -> None:
313 self._css_files = []
314 self.add_css_file('pygments.css', priority=200)
315
316 for filename in self._get_style_filenames():
317 self.add_css_file(filename, priority=200)
318
319 for filename, attrs in self.app.registry.css_files:
320 self.add_css_file(filename, **attrs)
321
322 for filename, attrs in self.get_builder_config('css_files', 'html'):
323 attrs.setdefault('priority', 800) # User's CSSs are loaded after extensions'
324 self.add_css_file(filename, **attrs)
325
326 def add_css_file(self, filename: str, **kwargs: Any) -> None:
327 if '://' not in filename:
328 filename = posixpath.join('_static', filename)
329
330 if (asset := _CascadingStyleSheet(filename, **kwargs)) not in self._css_files:
331 self._css_files.append(asset)
332
333 @property
334 def script_files(self) -> list[_JavaScript]:
335 canonical_name = f'{self.__class__.__name__}.script_files'
336 _deprecation_warning(__name__, canonical_name, remove=(9, 0))
337 return self._js_files
338
339 def init_js_files(self) -> None:
340 self._js_files = []
341 self.add_js_file('documentation_options.js', priority=200)
342 self.add_js_file('doctools.js', priority=200)
343 self.add_js_file('sphinx_highlight.js', priority=200)
344
345 for filename, attrs in self.app.registry.js_files:
346 self.add_js_file(filename or '', **attrs)
347
348 for filename, attrs in self.get_builder_config('js_files', 'html'):
349 attrs.setdefault('priority', 800) # User's JSs are loaded after extensions'
350 self.add_js_file(filename or '', **attrs)
351
352 if self._get_translations_js():
353 self.add_js_file('translations.js')
354
355 def add_js_file(self, filename: str, **kwargs: Any) -> None:
356 if filename and '://' not in filename:
357 filename = posixpath.join('_static', filename)
358
359 if (asset := _JavaScript(filename, **kwargs)) not in self._js_files:
360 self._js_files.append(asset)
361
362 @property
363 def math_renderer_name(self) -> str | None:
364 name = self.get_builder_config('math_renderer', 'html')
365 if name is not None:
366 # use given name
367 return name
368 else:
369 # not given: choose a math_renderer from registered ones as possible
370 renderers = list(self.app.registry.html_inline_math_renderers)
371 if len(renderers) == 1:
372 # only default math_renderer (mathjax) is registered
373 return renderers[0]
374 elif len(renderers) == 2:
375 # default and another math_renderer are registered; prior the another
376 renderers.remove('mathjax')
377 return renderers[0]
378 else:
379 # many math_renderers are registered. can't choose automatically!
380 return None
381
382 def get_outdated_docs(self) -> Iterator[str]:
383 try:
384 with open(path.join(self.outdir, '.buildinfo'), encoding="utf-8") as fp:
385 buildinfo = BuildInfo.load(fp)
386
387 if self.build_info != buildinfo:
388 logger.debug('[build target] did not match: build_info ')
389 yield from self.env.found_docs
390 return
391 except ValueError as exc:
392 logger.warning(__('Failed to read build info file: %r'), exc)
393 except OSError:
394 # ignore errors on reading
395 pass
396
397 if self.templates:
398 template_mtime = self.templates.newest_template_mtime()
399 else:
400 template_mtime = 0
401 for docname in self.env.found_docs:
402 if docname not in self.env.all_docs:
403 logger.debug('[build target] did not in env: %r', docname)
404 yield docname
405 continue
406 targetname = self.get_outfilename(docname)
407 try:
408 targetmtime = path.getmtime(targetname)
409 except Exception:
410 targetmtime = 0
411 try:
412 srcmtime = max(path.getmtime(self.env.doc2path(docname)), template_mtime)
413 if srcmtime > targetmtime:
414 logger.debug(
415 '[build target] targetname %r(%s), template(%s), docname %r(%s)',
416 targetname,
417 _format_modified_time(targetmtime),
418 _format_modified_time(template_mtime),
419 docname,
420 _format_modified_time(path.getmtime(self.env.doc2path(docname))),
421 )
422 yield docname
423 except OSError:
424 # source doesn't exist anymore
425 pass
426
427 def get_asset_paths(self) -> list[str]:
428 return self.config.html_extra_path + self.config.html_static_path
429
430 def render_partial(self, node: Node | None) -> dict[str, str]:
431 """Utility: Render a lone doctree node."""
432 if node is None:
433 return {'fragment': ''}
434
435 doc = new_document('<partial node>')
436 doc.append(node)
437 self._publisher.set_source(doc)
438 self._publisher.publish()
439 return self._publisher.writer.parts
440
441 def prepare_writing(self, docnames: set[str]) -> None:
442 # create the search indexer
443 self.indexer = None
444 if self.search:
445 from sphinx.search import IndexBuilder
446 lang = self.config.html_search_language or self.config.language
447 self.indexer = IndexBuilder(self.env, lang,
448 self.config.html_search_options,
449 self.config.html_search_scorer)
450 self.load_indexer(docnames)
451
452 self.docwriter = HTMLWriter(self)
453 with warnings.catch_warnings():
454 warnings.filterwarnings('ignore', category=DeprecationWarning)
455 # DeprecationWarning: The frontend.OptionParser class will be replaced
456 # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later.
457 self.docsettings: Any = OptionParser(
458 defaults=self.env.settings,
459 components=(self.docwriter,),
460 read_config_files=True).get_default_values()
461 self.docsettings.compact_lists = bool(self.config.html_compact_lists)
462
463 # determine the additional indices to include
464 self.domain_indices = []
465 # html_domain_indices can be False/True or a list of index names
466 if indices_config := self.config.html_domain_indices:
467 if not isinstance(indices_config, bool):
468 check_names = True
469 indices_config = frozenset(indices_config)
470 else:
471 check_names = False
472 for domain_name in sorted(self.env.domains):
473 domain: Domain = self.env.domains[domain_name]
474 for index_cls in domain.indices:
475 index_name = f'{domain.name}-{index_cls.name}'
476 if check_names and index_name not in indices_config:
477 continue
478 content, collapse = index_cls(domain).generate()
479 if content:
480 self.domain_indices.append(
481 (index_name, index_cls, content, collapse))
482
483 # format the "last updated on" string, only once is enough since it
484 # typically doesn't include the time of day
485 last_updated: str | None
486 if (lu_fmt := self.config.html_last_updated_fmt) is not None:
487 lu_fmt = lu_fmt or _('%b %d, %Y')
488 last_updated = format_date(lu_fmt, language=self.config.language)
489 else:
490 last_updated = None
491
492 # If the logo or favicon are urls, keep them as-is, otherwise
493 # strip the relative path as the files will be copied into _static.
494 logo = self.config.html_logo or ''
495 favicon = self.config.html_favicon or ''
496
497 if not isurl(logo):
498 logo = path.basename(logo)
499 if not isurl(favicon):
500 favicon = path.basename(favicon)
501
502 self.relations = self.env.collect_relations()
503
504 rellinks: list[tuple[str, str, str, str]] = []
505 if self.use_index:
506 rellinks.append(('genindex', _('General Index'), 'I', _('index')))
507 for indexname, indexcls, _content, _collapse in self.domain_indices:
508 # if it has a short name
509 if indexcls.shortname:
510 rellinks.append((indexname, indexcls.localname,
511 '', indexcls.shortname))
512
513 # add assets registered after ``Builder.init()``.
514 for css_filename, attrs in self.app.registry.css_files:
515 self.add_css_file(css_filename, **attrs)
516 for js_filename, attrs in self.app.registry.js_files:
517 self.add_js_file(js_filename or '', **attrs)
518
519 # back up _css_files and _js_files to allow adding CSS/JS files to a specific page.
520 self._orig_css_files = list(dict.fromkeys(self._css_files))
521 self._orig_js_files = list(dict.fromkeys(self._js_files))
522 styles = list(self._get_style_filenames())
523
524 self.globalcontext = {
525 'embedded': self.embedded,
526 'project': self.config.project,
527 'release': return_codes_re.sub('', self.config.release),
528 'version': self.config.version,
529 'last_updated': last_updated,
530 'copyright': self.config.copyright,
531 'master_doc': self.config.root_doc,
532 'root_doc': self.config.root_doc,
533 'use_opensearch': self.config.html_use_opensearch,
534 'docstitle': self.config.html_title,
535 'shorttitle': self.config.html_short_title,
536 'show_copyright': self.config.html_show_copyright,
537 'show_search_summary': self.config.html_show_search_summary,
538 'show_sphinx': self.config.html_show_sphinx,
539 'has_source': self.config.html_copy_source,
540 'show_source': self.config.html_show_sourcelink,
541 'sourcelink_suffix': self.config.html_sourcelink_suffix,
542 'file_suffix': self.out_suffix,
543 'link_suffix': self.link_suffix,
544 'script_files': self._js_files,
545 'language': convert_locale_to_language_tag(self.config.language),
546 'css_files': self._css_files,
547 'sphinx_version': __display_version__,
548 'sphinx_version_tuple': sphinx_version,
549 'docutils_version_info': docutils.__version_info__[:5],
550 'styles': styles,
551 'rellinks': rellinks,
552 'builder': self.name,
553 'parents': [],
554 'logo_url': logo,
555 'logo_alt': _('Logo of %s') % self.config.project,
556 'favicon_url': favicon,
557 'html5_doctype': True,
558 }
559 if self.theme:
560 self.globalcontext |= {
561 f'theme_{key}': val for key, val in
562 self.theme.get_options(self.theme_options).items()
563 }
564 self.globalcontext |= self.config.html_context
565
566 def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, Any]:
567 """Collect items for the template context of a page."""
568 # find out relations
569 prev = next = None
570 parents = []
571 rellinks = self.globalcontext['rellinks'][:]
572 related = self.relations.get(docname)
573 titles = self.env.titles
574 if related and related[2]:
575 try:
576 next = {
577 'link': self.get_relative_uri(docname, related[2]),
578 'title': self.render_partial(titles[related[2]])['title'],
579 }
580 rellinks.append((related[2], next['title'], 'N', _('next')))
581 except KeyError:
582 next = None
583 if related and related[1]:
584 try:
585 prev = {
586 'link': self.get_relative_uri(docname, related[1]),
587 'title': self.render_partial(titles[related[1]])['title'],
588 }
589 rellinks.append((related[1], prev['title'], 'P', _('previous')))
590 except KeyError:
591 # the relation is (somehow) not in the TOC tree, handle
592 # that gracefully
593 prev = None
594 while related and related[0]:
595 with contextlib.suppress(KeyError):
596 parents.append(
597 {'link': self.get_relative_uri(docname, related[0]),
598 'title': self.render_partial(titles[related[0]])['title']})
599
600 related = self.relations.get(related[0])
601 if parents:
602 # remove link to the master file; we have a generic
603 # "back to index" link already
604 parents.pop()
605 parents.reverse()
606
607 # title rendered as HTML
608 title_node = self.env.longtitles.get(docname)
609 title = self.render_partial(title_node)['title'] if title_node else ''
610
611 # Suffix for the document
612 source_suffix = self.env.doc2path(docname, False)[len(docname):]
613
614 # the name for the copied source
615 if self.config.html_copy_source:
616 sourcename = docname + source_suffix
617 if source_suffix != self.config.html_sourcelink_suffix:
618 sourcename += self.config.html_sourcelink_suffix
619 else:
620 sourcename = ''
621
622 # metadata for the document
623 meta = self.env.metadata.get(docname)
624
625 # local TOC and global TOC tree
626 self_toc = document_toc(self.env, docname, self.tags)
627 toc = self.render_partial(self_toc)['fragment']
628
629 return {
630 'parents': parents,
631 'prev': prev,
632 'next': next,
633 'title': title,
634 'meta': meta,
635 'body': body,
636 'metatags': metatags,
637 'rellinks': rellinks,
638 'sourcename': sourcename,
639 'toc': toc,
640 # only display a TOC if there's more than one item to show
641 'display_toc': (self.env.toc_num_entries[docname] > 1),
642 'page_source_suffix': source_suffix,
643 }
644
645 def copy_assets(self) -> None:
646 self.finish_tasks.add_task(self.copy_download_files)
647 self.finish_tasks.add_task(self.copy_static_files)
648 self.finish_tasks.add_task(self.copy_extra_files)
649 self.finish_tasks.join()
650
651 def write_doc(self, docname: str, doctree: nodes.document) -> None:
652 destination = StringOutput(encoding='utf-8')
653 doctree.settings = self.docsettings
654
655 self.secnumbers = self.env.toc_secnumbers.get(docname, {})
656 self.fignumbers = self.env.toc_fignumbers.get(docname, {})
657 self.imgpath = relative_uri(self.get_target_uri(docname), '_images')
658 self.dlpath = relative_uri(self.get_target_uri(docname), '_downloads')
659 self.current_docname = docname
660 self.docwriter.write(doctree, destination)
661 self.docwriter.assemble_parts()
662 body = self.docwriter.parts['fragment']
663 metatags = self.docwriter.clean_meta
664
665 ctx = self.get_doc_context(docname, body, metatags)
666 self.handle_page(docname, ctx, event_arg=doctree)
667
668 def write_doc_serialized(self, docname: str, doctree: nodes.document) -> None:
669 self.imgpath = relative_uri(self.get_target_uri(docname), self.imagedir)
670 self.post_process_images(doctree)
671 title_node = self.env.longtitles.get(docname)
672 title = self.render_partial(title_node)['title'] if title_node else ''
673 self.index_page(docname, doctree, title)
674
675 def finish(self) -> None:
676 self.finish_tasks.add_task(self.gen_indices)
677 self.finish_tasks.add_task(self.gen_pages_from_extensions)
678 self.finish_tasks.add_task(self.gen_additional_pages)
679 self.finish_tasks.add_task(self.copy_image_files)
680 self.finish_tasks.add_task(self.write_buildinfo)
681
682 # dump the search index
683 self.handle_finish()
684
685 @progress_message(__('generating indices'))
686 def gen_indices(self) -> None:
687 # the global general index
688 if self.use_index:
689 self.write_genindex()
690
691 # the global domain-specific indices
692 self.write_domain_indices()
693
694 def gen_pages_from_extensions(self) -> None:
695 # pages from extensions
696 for pagelist in self.events.emit('html-collect-pages'):
697 for pagename, context, template in pagelist:
698 self.handle_page(pagename, context, template)
699
700 @progress_message(__('writing additional pages'))
701 def gen_additional_pages(self) -> None:
702 # additional pages from conf.py
703 for pagename, template in self.config.html_additional_pages.items():
704 logger.info(pagename + ' ', nonl=True)
705 self.handle_page(pagename, {}, template)
706
707 # the search page
708 if self.search:
709 logger.info('search ', nonl=True)
710 self.handle_page('search', {}, 'search.html')
711
712 # the opensearch xml file
713 if self.config.html_use_opensearch and self.search:
714 logger.info('opensearch ', nonl=True)
715 fn = path.join(self.outdir, '_static', 'opensearch.xml')
716 self.handle_page('opensearch', {}, 'opensearch.xml', outfilename=fn)
717
718 def write_genindex(self) -> None:
719 # the total count of lines for each index letter, used to distribute
720 # the entries into two columns
721 genindex = IndexEntries(self.env).create_index(self)
722 indexcounts = [sum(1 + len(subitems) for _, (_, subitems, _) in entries)
723 for _k, entries in genindex]
724
725 genindexcontext = {
726 'genindexentries': genindex,
727 'genindexcounts': indexcounts,
728 'split_index': self.config.html_split_index,
729 }
730 logger.info('genindex ', nonl=True)
731
732 if self.config.html_split_index:
733 self.handle_page('genindex', genindexcontext,
734 'genindex-split.html')
735 self.handle_page('genindex-all', genindexcontext,
736 'genindex.html')
737 for (key, entries), count in zip(genindex, indexcounts):
738 ctx = {'key': key, 'entries': entries, 'count': count,
739 'genindexentries': genindex}
740 self.handle_page('genindex-' + key, ctx,
741 'genindex-single.html')
742 else:
743 self.handle_page('genindex', genindexcontext, 'genindex.html')
744
745 def write_domain_indices(self) -> None:
746 for indexname, indexcls, content, collapse in self.domain_indices:
747 indexcontext = {
748 'indextitle': indexcls.localname,
749 'content': content,
750 'collapse_index': collapse,
751 }
752 logger.info(indexname + ' ', nonl=True)
753 self.handle_page(indexname, indexcontext, 'domainindex.html')
754
755 def copy_image_files(self) -> None:
756 if self.images:
757 stringify_func = ImageAdapter(self.app.env).get_original_image_uri
758 ensuredir(path.join(self.outdir, self.imagedir))
759 for src in status_iterator(self.images, __('copying images... '), "brown",
760 len(self.images), self.app.verbosity,
761 stringify_func=stringify_func):
762 dest = self.images[src]
763 try:
764 copyfile(path.join(self.srcdir, src),
765 path.join(self.outdir, self.imagedir, dest))
766 except Exception as err:
767 logger.warning(__('cannot copy image file %r: %s'),
768 path.join(self.srcdir, src), err)
769
770 def copy_download_files(self) -> None:
771 def to_relpath(f: str) -> str:
772 return relative_path(self.srcdir, f)
773
774 # copy downloadable files
775 if self.env.dlfiles:
776 ensuredir(path.join(self.outdir, '_downloads'))
777 for src in status_iterator(self.env.dlfiles, __('copying downloadable files... '),
778 "brown", len(self.env.dlfiles), self.app.verbosity,
779 stringify_func=to_relpath):
780 try:
781 dest = path.join(self.outdir, '_downloads', self.env.dlfiles[src][1])
782 ensuredir(path.dirname(dest))
783 copyfile(path.join(self.srcdir, src), dest)
784 except OSError as err:
785 logger.warning(__('cannot copy downloadable file %r: %s'),
786 path.join(self.srcdir, src), err)
787
788 def create_pygments_style_file(self) -> None:
789 """Create a style file for pygments."""
790 with open(path.join(self.outdir, '_static', 'pygments.css'), 'w',
791 encoding="utf-8") as f:
792 f.write(self.highlighter.get_stylesheet())
793
794 if self.dark_highlighter:
795 with open(path.join(self.outdir, '_static', 'pygments_dark.css'), 'w',
796 encoding="utf-8") as f:
797 f.write(self.dark_highlighter.get_stylesheet())
798
799 def copy_translation_js(self) -> None:
800 """Copy a JavaScript file for translations."""
801 jsfile = self._get_translations_js()
802 if jsfile:
803 copyfile(jsfile, path.join(self.outdir, '_static', 'translations.js'))
804
805 def copy_stemmer_js(self) -> None:
806 """Copy a JavaScript file for stemmer."""
807 if self.indexer is not None:
808 if hasattr(self.indexer, 'get_js_stemmer_rawcodes'):
809 for jsfile in self.indexer.get_js_stemmer_rawcodes():
810 copyfile(jsfile, path.join(self.outdir, '_static', path.basename(jsfile)))
811 else:
812 if js_stemmer_rawcode := self.indexer.get_js_stemmer_rawcode():
813 copyfile(js_stemmer_rawcode,
814 path.join(self.outdir, '_static', '_stemmer.js'))
815
816 def copy_theme_static_files(self, context: dict[str, Any]) -> None:
817 def onerror(filename: str, error: Exception) -> None:
818 msg = __("Failed to copy a file in the theme's 'static' directory: %s: %r")
819 logger.warning(msg, filename, error)
820
821 if self.theme:
822 for entry in reversed(self.theme.get_theme_dirs()):
823 copy_asset(path.join(entry, 'static'),
824 path.join(self.outdir, '_static'),
825 excluded=DOTFILES, context=context,
826 renderer=self.templates, onerror=onerror)
827
828 def copy_html_static_files(self, context: dict[str, Any]) -> None:
829 def onerror(filename: str, error: Exception) -> None:
830 logger.warning(__('Failed to copy a file in html_static_file: %s: %r'),
831 filename, error)
832
833 excluded = Matcher([*self.config.exclude_patterns, '**/.*'])
834 for entry in self.config.html_static_path:
835 copy_asset(path.join(self.confdir, entry),
836 path.join(self.outdir, '_static'),
837 excluded, context=context, renderer=self.templates, onerror=onerror)
838
839 def copy_html_logo(self) -> None:
840 if self.config.html_logo and not isurl(self.config.html_logo):
841 copy_asset(path.join(self.confdir, self.config.html_logo),
842 path.join(self.outdir, '_static'))
843
844 def copy_html_favicon(self) -> None:
845 if self.config.html_favicon and not isurl(self.config.html_favicon):
846 copy_asset(path.join(self.confdir, self.config.html_favicon),
847 path.join(self.outdir, '_static'))
848
849 def copy_static_files(self) -> None:
850 try:
851 with progress_message(__('copying static files')):
852 ensuredir(path.join(self.outdir, '_static'))
853
854 # prepare context for templates
855 context = self.globalcontext.copy()
856 if self.indexer is not None:
857 context.update(self.indexer.context_for_searchtool())
858
859 self.create_pygments_style_file()
860 self.copy_translation_js()
861 self.copy_stemmer_js()
862 self.copy_theme_static_files(context)
863 self.copy_html_static_files(context)
864 self.copy_html_logo()
865 self.copy_html_favicon()
866 except OSError as err:
867 logger.warning(__('cannot copy static file %r'), err)
868
869 def copy_extra_files(self) -> None:
870 """Copy html_extra_path files."""
871 try:
872 with progress_message(__('copying extra files')):
873 excluded = Matcher(self.config.exclude_patterns)
874 for extra_path in self.config.html_extra_path:
875 entry = path.join(self.confdir, extra_path)
876 copy_asset(entry, self.outdir, excluded)
877 except OSError as err:
878 logger.warning(__('cannot copy extra file %r'), err)
879
880 def write_buildinfo(self) -> None:
881 try:
882 with open(path.join(self.outdir, '.buildinfo'), 'w', encoding="utf-8") as fp:
883 self.build_info.dump(fp)
884 except OSError as exc:
885 logger.warning(__('Failed to write build info file: %r'), exc)
886
887 def cleanup(self) -> None:
888 # clean up theme stuff
889 if self.theme:
890 self.theme._cleanup()
891
892 def post_process_images(self, doctree: Node) -> None:
893 """Pick the best candidate for an image and link down-scaled images to
894 their high resolution version.
895 """
896 super().post_process_images(doctree)
897
898 if self.config.html_scaled_image_link and self.html_scaled_image_link:
899 for node in doctree.findall(nodes.image):
900 if not any((key in node) for key in ('scale', 'width', 'height')):
901 # resizing options are not given. scaled image link is available
902 # only for resized images.
903 continue
904 if isinstance(node.parent, nodes.reference):
905 # A image having hyperlink target
906 continue
907 if 'no-scaled-link' in node['classes']:
908 # scaled image link is disabled for this node
909 continue
910
911 uri = node['uri']
912 reference = nodes.reference('', '', internal=True)
913 if uri in self.images:
914 reference['refuri'] = posixpath.join(self.imgpath,
915 self.images[uri])
916 else:
917 reference['refuri'] = uri
918 node.replace_self(reference)
919 reference.append(node)
920
921 def load_indexer(self, docnames: Iterable[str]) -> None:
922 assert self.indexer is not None
923 keep = set(self.env.all_docs) - set(docnames)
924 try:
925 searchindexfn = path.join(self.outdir, self.searchindex_filename)
926 if self.indexer_dumps_unicode:
927 with open(searchindexfn, encoding='utf-8') as ft:
928 self.indexer.load(ft, self.indexer_format)
929 else:
930 with open(searchindexfn, 'rb') as fb:
931 self.indexer.load(fb, self.indexer_format)
932 except (OSError, ValueError):
933 if keep:
934 logger.warning(__("search index couldn't be loaded, but not all "
935 'documents will be built: the index will be '
936 'incomplete.'))
937 # delete all entries for files that will be rebuilt
938 self.indexer.prune(keep)
939
940 def index_page(self, pagename: str, doctree: nodes.document, title: str) -> None:
941 # only index pages with title
942 if self.indexer is not None and title:
943 filename = self.env.doc2path(pagename, base=False)
944 metadata = self.env.metadata.get(pagename, {})
945 if 'no-search' in metadata or 'nosearch' in metadata:
946 self.indexer.feed(pagename, filename, '', new_document(''))
947 else:
948 self.indexer.feed(pagename, filename, title, doctree)
949
950 def _get_local_toctree(self, docname: str, collapse: bool = True, **kwargs: Any) -> str:
951 if 'includehidden' not in kwargs:
952 kwargs['includehidden'] = False
953 if kwargs.get('maxdepth') == '':
954 kwargs.pop('maxdepth')
955 toctree = global_toctree_for_doc(self.env, docname, self, collapse=collapse, **kwargs)
956 return self.render_partial(toctree)['fragment']
957
958 def get_outfilename(self, pagename: str) -> str:
959 return path.join(self.outdir, os_path(pagename) + self.out_suffix)
960
961 def add_sidebars(self, pagename: str, ctx: dict[str, Any]) -> None:
962 def has_wildcard(pattern: str) -> bool:
963 return any(char in pattern for char in '*?[')
964
965 matched = None
966
967 # default sidebars settings for selected theme
968 sidebars = list(self.theme.sidebar_templates)
969
970 # user sidebar settings
971 html_sidebars = self.get_builder_config('sidebars', 'html')
972 msg = __('page %s matches two patterns in html_sidebars: %r and %r')
973 for pattern, pat_sidebars in html_sidebars.items():
974 if patmatch(pagename, pattern):
975 if matched and has_wildcard(pattern):
976 # warn if both patterns contain wildcards
977 if has_wildcard(matched):
978 logger.warning(msg, pagename, matched)
979 # else the already matched pattern is more specific
980 # than the present one, because it contains no wildcard
981 continue
982 matched = pattern
983 sidebars = pat_sidebars
984
985 # See error_on_html_sidebars_string_values.
986 # Replace with simple list coercion in Sphinx 8.0
987 # xref: RemovedInSphinx80Warning
988 ctx['sidebars'] = sidebars
989
990 # --------- these are overwritten by the serialization builder
991
992 def get_target_uri(self, docname: str, typ: str | None = None) -> str:
993 return quote(docname) + self.link_suffix
994
995 def handle_page(
996 self, pagename: str,
997 addctx: dict[str, Any],
998 templatename: str = 'page.html',
999 outfilename: str | None = None,
1000 event_arg: Any = None,
1001 ) -> None:
1002 ctx = self.globalcontext.copy()
1003 # current_page_name is backwards compatibility
1004 ctx['pagename'] = ctx['current_page_name'] = pagename
1005 ctx['encoding'] = self.config.html_output_encoding
1006 default_baseuri = self.get_target_uri(pagename)
1007 # in the singlehtml builder, default_baseuri still contains an #anchor
1008 # part, which relative_uri doesn't really like...
1009 default_baseuri = default_baseuri.rsplit('#', 1)[0]
1010
1011 if self.config.html_baseurl:
1012 ctx['pageurl'] = posixpath.join(self.config.html_baseurl,
1013 pagename + self.out_suffix)
1014 else:
1015 ctx['pageurl'] = None
1016
1017 def pathto(
1018 otheruri: str, resource: bool = False, baseuri: str = default_baseuri,
1019 ) -> str:
1020 if resource and '://' in otheruri:
1021 # allow non-local resources given by scheme
1022 return otheruri
1023 elif not resource:
1024 otheruri = self.get_target_uri(otheruri)
1025 uri = relative_uri(baseuri, otheruri) or '#'
1026 if uri == '#' and not self.allow_sharp_as_current_path:
1027 uri = baseuri
1028 return uri
1029 ctx['pathto'] = pathto
1030
1031 def hasdoc(name: str) -> bool:
1032 if name in self.env.all_docs:
1033 return True
1034 if name == 'search' and self.search:
1035 return True
1036 return name == 'genindex' and self.get_builder_config('use_index', 'html')
1037 ctx['hasdoc'] = hasdoc
1038
1039 ctx['toctree'] = lambda **kwargs: self._get_local_toctree(pagename, **kwargs)
1040 self.add_sidebars(pagename, ctx)
1041 ctx.update(addctx)
1042
1043 # 'blah.html' should have content_root = './' not ''.
1044 ctx['content_root'] = (f'..{SEP}' * default_baseuri.count(SEP)) or f'.{SEP}'
1045
1046 outdir = self.app.outdir
1047
1048 def css_tag(css: _CascadingStyleSheet) -> str:
1049 attrs = [f'{key}="{html.escape(value, quote=True)}"'
1050 for key, value in css.attributes.items()
1051 if value is not None]
1052 uri = pathto(os.fspath(css.filename), resource=True)
1053 # the EPUB format does not allow the use of query components
1054 # the Windows help compiler requires that css links
1055 # don't have a query component
1056 if self.name not in {'epub', 'htmlhelp'}:
1057 if checksum := _file_checksum(outdir, css.filename):
1058 uri += f'?v={checksum}'
1059 return f'<link {" ".join(sorted(attrs))} href="{uri}" />'
1060
1061 ctx['css_tag'] = css_tag
1062
1063 def js_tag(js: _JavaScript | str) -> str:
1064 if not isinstance(js, _JavaScript):
1065 # str value (old styled)
1066 return f'<script src="{pathto(js, resource=True)}"></script>'
1067
1068 body = js.attributes.get('body', '')
1069 attrs = [f'{key}="{html.escape(value, quote=True)}"'
1070 for key, value in js.attributes.items()
1071 if key != 'body' and value is not None]
1072
1073 if not js.filename:
1074 if attrs:
1075 return f'<script {" ".join(sorted(attrs))}>{body}</script>'
1076 return f'<script>{body}</script>'
1077
1078 uri = pathto(os.fspath(js.filename), resource=True)
1079 if 'MathJax.js?' in os.fspath(js.filename):
1080 # MathJax v2 reads a ``?config=...`` query parameter,
1081 # special case this and just skip adding the checksum.
1082 # https://docs.mathjax.org/en/v2.7-latest/configuration.html#considerations-for-using-combined-configuration-files
1083 # https://github.com/sphinx-doc/sphinx/issues/11658
1084 pass
1085 # the EPUB format does not allow the use of query components
1086 elif self.name != 'epub':
1087 if checksum := _file_checksum(outdir, js.filename):
1088 uri += f'?v={checksum}'
1089 if attrs:
1090 return f'<script {" ".join(sorted(attrs))} src="{uri}"></script>'
1091 return f'<script src="{uri}"></script>'
1092
1093 ctx['js_tag'] = js_tag
1094
1095 # revert _css_files and _js_files
1096 self._css_files[:] = self._orig_css_files
1097 self._js_files[:] = self._orig_js_files
1098
1099 self.update_page_context(pagename, templatename, ctx, event_arg)
1100 newtmpl = self.app.emit_firstresult('html-page-context', pagename,
1101 templatename, ctx, event_arg)
1102 if newtmpl:
1103 templatename = newtmpl
1104
1105 # sort JS/CSS before rendering HTML
1106 try: # NoQA: SIM105
1107 # Convert script_files to list to support non-list script_files (refs: #8889)
1108 ctx['script_files'] = sorted(ctx['script_files'], key=lambda js: js.priority)
1109 except AttributeError:
1110 # Skip sorting if users modifies script_files directly (maybe via `html_context`).
1111 # refs: #8885
1112 #
1113 # Note: priority sorting feature will not work in this case.
1114 pass
1115
1116 with contextlib.suppress(AttributeError):
1117 ctx['css_files'] = sorted(ctx['css_files'], key=lambda css: css.priority)
1118
1119 try:
1120 output = self.templates.render(templatename, ctx)
1121 except UnicodeError:
1122 logger.warning(__("a Unicode error occurred when rendering the page %s. "
1123 "Please make sure all config values that contain "
1124 "non-ASCII content are Unicode strings."), pagename)
1125 return
1126 except Exception as exc:
1127 raise ThemeError(__("An error happened in rendering the page %s.\nReason: %r") %
1128 (pagename, exc)) from exc
1129
1130 if not outfilename:
1131 outfilename = self.get_outfilename(pagename)
1132 # outfilename's path is in general different from self.outdir
1133 ensuredir(path.dirname(outfilename))
1134 try:
1135 with open(outfilename, 'w', encoding=ctx['encoding'],
1136 errors='xmlcharrefreplace') as f:
1137 f.write(output)
1138 except OSError as err:
1139 logger.warning(__("error writing file %s: %s"), outfilename, err)
1140 if self.copysource and ctx.get('sourcename'):
1141 # copy the source file for the "show source" link
1142 source_name = path.join(self.outdir, '_sources',
1143 os_path(ctx['sourcename']))
1144 ensuredir(path.dirname(source_name))
1145 copyfile(self.env.doc2path(pagename), source_name,
1146 __overwrite_warning__=False)
1147
1148 def update_page_context(self, pagename: str, templatename: str,
1149 ctx: dict[str, Any], event_arg: Any) -> None:
1150 pass
1151
1152 def handle_finish(self) -> None:
1153 self.finish_tasks.add_task(self.dump_search_index)
1154 self.finish_tasks.add_task(self.dump_inventory)
1155
1156 @progress_message(__('dumping object inventory'))
1157 def dump_inventory(self) -> None:
1158 InventoryFile.dump(path.join(self.outdir, INVENTORY_FILENAME), self.env, self)
1159
1160 def dump_search_index(self) -> None:
1161 if self.indexer is None:
1162 return
1163
1164 with progress_message(__('dumping search index in %s') % self.indexer.label()):
1165 self.indexer.prune(self.env.all_docs)
1166 searchindexfn = path.join(self.outdir, self.searchindex_filename)
1167 # first write to a temporary file, so that if dumping fails,
1168 # the existing index won't be overwritten
1169 if self.indexer_dumps_unicode:
1170 with open(searchindexfn + '.tmp', 'w', encoding='utf-8') as ft:
1171 self.indexer.dump(ft, self.indexer_format)
1172 else:
1173 with open(searchindexfn + '.tmp', 'wb') as fb:
1174 self.indexer.dump(fb, self.indexer_format)
1175 os.replace(searchindexfn + '.tmp', searchindexfn)
1176
1177
1178def convert_html_css_files(app: Sphinx, config: Config) -> None:
1179 """Convert string styled html_css_files to tuple styled one."""
1180 html_css_files: list[tuple[str, dict[str, str]]] = []
1181 for entry in config.html_css_files:
1182 if isinstance(entry, str):
1183 html_css_files.append((entry, {}))
1184 else:
1185 try:
1186 filename, attrs = entry
1187 html_css_files.append((filename, attrs))
1188 except Exception:
1189 logger.warning(__('invalid css_file: %r, ignored'), entry)
1190 continue
1191
1192 config.html_css_files = html_css_files
1193
1194
1195def _format_modified_time(timestamp: float) -> str:
1196 """Return an RFC 3339 formatted string representing the given timestamp."""
1197 seconds, fraction = divmod(timestamp, 1)
1198 return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) + f'.{fraction:.3f}'
1199
1200
1201def convert_html_js_files(app: Sphinx, config: Config) -> None:
1202 """Convert string styled html_js_files to tuple styled one."""
1203 html_js_files: list[tuple[str, dict[str, str]]] = []
1204 for entry in config.html_js_files:
1205 if isinstance(entry, str):
1206 html_js_files.append((entry, {}))
1207 else:
1208 try:
1209 filename, attrs = entry
1210 html_js_files.append((filename, attrs))
1211 except Exception:
1212 logger.warning(__('invalid js_file: %r, ignored'), entry)
1213 continue
1214
1215 config.html_js_files = html_js_files
1216
1217
1218def setup_resource_paths(app: Sphinx, pagename: str, templatename: str,
1219 context: dict[str, Any], doctree: Node) -> None:
1220 """Set up relative resource paths."""
1221 pathto = context['pathto']
1222
1223 # favicon_url
1224 favicon_url = context.get('favicon_url')
1225 if favicon_url and not isurl(favicon_url):
1226 context['favicon_url'] = pathto('_static/' + favicon_url, resource=True)
1227
1228 # logo_url
1229 logo_url = context.get('logo_url')
1230 if logo_url and not isurl(logo_url):
1231 context['logo_url'] = pathto('_static/' + logo_url, resource=True)
1232
1233
1234def validate_math_renderer(app: Sphinx) -> None:
1235 if app.builder.format != 'html':
1236 return
1237
1238 name = app.builder.math_renderer_name # type: ignore[attr-defined]
1239 if name is None:
1240 raise ConfigError(__('Many math_renderers are registered. '
1241 'But no math_renderer is selected.'))
1242 if name not in app.registry.html_inline_math_renderers:
1243 raise ConfigError(__('Unknown math_renderer %r is given.') % name)
1244
1245
1246def validate_html_extra_path(app: Sphinx, config: Config) -> None:
1247 """Check html_extra_paths setting."""
1248 for entry in config.html_extra_path[:]:
1249 extra_path = path.normpath(path.join(app.confdir, entry))
1250 if not path.exists(extra_path):
1251 logger.warning(__('html_extra_path entry %r does not exist'), entry)
1252 config.html_extra_path.remove(entry)
1253 elif (path.splitdrive(app.outdir)[0] == path.splitdrive(extra_path)[0] and
1254 path.commonpath((app.outdir, extra_path)) == path.normpath(app.outdir)):
1255 logger.warning(__('html_extra_path entry %r is placed inside outdir'), entry)
1256 config.html_extra_path.remove(entry)
1257
1258
1259def validate_html_static_path(app: Sphinx, config: Config) -> None:
1260 """Check html_static_paths setting."""
1261 for entry in config.html_static_path[:]:
1262 static_path = path.normpath(path.join(app.confdir, entry))
1263 if not path.exists(static_path):
1264 logger.warning(__('html_static_path entry %r does not exist'), entry)
1265 config.html_static_path.remove(entry)
1266 elif (path.splitdrive(app.outdir)[0] == path.splitdrive(static_path)[0] and
1267 path.commonpath((app.outdir, static_path)) == path.normpath(app.outdir)):
1268 logger.warning(__('html_static_path entry %r is placed inside outdir'), entry)
1269 config.html_static_path.remove(entry)
1270
1271
1272def validate_html_logo(app: Sphinx, config: Config) -> None:
1273 """Check html_logo setting."""
1274 if (config.html_logo and
1275 not path.isfile(path.join(app.confdir, config.html_logo)) and
1276 not isurl(config.html_logo)):
1277 logger.warning(__('logo file %r does not exist'), config.html_logo)
1278 config.html_logo = None
1279
1280
1281def validate_html_favicon(app: Sphinx, config: Config) -> None:
1282 """Check html_favicon setting."""
1283 if (config.html_favicon and
1284 not path.isfile(path.join(app.confdir, config.html_favicon)) and
1285 not isurl(config.html_favicon)):
1286 logger.warning(__('favicon file %r does not exist'), config.html_favicon)
1287 config.html_favicon = None
1288
1289
1290def error_on_html_sidebars_string_values(app: Sphinx, config: Config) -> None:
1291 """Support removed in Sphinx 2."""
1292 errors = {}
1293 for pattern, pat_sidebars in config.html_sidebars.items():
1294 if isinstance(pat_sidebars, str):
1295 errors[pattern] = [pat_sidebars]
1296 if not errors:
1297 return
1298 msg = __("Values in 'html_sidebars' must be a list of strings. "
1299 "At least one pattern has a string value: %s. "
1300 "Change to `html_sidebars = %r`.")
1301 bad_patterns = ', '.join(map(repr, errors))
1302 fixed = config.html_sidebars | errors
1303 logger.error(msg, bad_patterns, fixed)
1304 # Enable hard error in next major version.
1305 # xref: RemovedInSphinx80Warning
1306 # raise ConfigError(msg % (bad_patterns, fixed))
1307
1308
1309def error_on_html_4(_app: Sphinx, config: Config) -> None:
1310 """Error on HTML 4."""
1311 if config.html4_writer:
1312 raise ConfigError(_(
1313 'HTML 4 is no longer supported by Sphinx. '
1314 '("html4_writer=True" detected in configuration options)',
1315 ))
1316
1317
1318def setup(app: Sphinx) -> ExtensionMetadata:
1319 # builders
1320 app.add_builder(StandaloneHTMLBuilder)
1321
1322 # config values
1323 app.add_config_value('html_theme', 'alabaster', 'html')
1324 app.add_config_value('html_theme_path', [], 'html')
1325 app.add_config_value('html_theme_options', {}, 'html')
1326 app.add_config_value(
1327 'html_title', lambda c: _('%s %s documentation') % (c.project, c.release), 'html', str)
1328 app.add_config_value('html_short_title', lambda self: self.html_title, 'html')
1329 app.add_config_value('html_style', None, 'html', {list, str})
1330 app.add_config_value('html_logo', None, 'html', str)
1331 app.add_config_value('html_favicon', None, 'html', str)
1332 app.add_config_value('html_css_files', [], 'html')
1333 app.add_config_value('html_js_files', [], 'html')
1334 app.add_config_value('html_static_path', [], 'html')
1335 app.add_config_value('html_extra_path', [], 'html')
1336 app.add_config_value('html_last_updated_fmt', None, 'html', str)
1337 app.add_config_value('html_sidebars', {}, 'html')
1338 app.add_config_value('html_additional_pages', {}, 'html')
1339 app.add_config_value('html_domain_indices', True, 'html', types={set, list})
1340 app.add_config_value('html_permalinks', True, 'html')
1341 app.add_config_value('html_permalinks_icon', '¶', 'html')
1342 app.add_config_value('html_use_index', True, 'html')
1343 app.add_config_value('html_split_index', False, 'html')
1344 app.add_config_value('html_copy_source', True, 'html')
1345 app.add_config_value('html_show_sourcelink', True, 'html')
1346 app.add_config_value('html_sourcelink_suffix', '.txt', 'html')
1347 app.add_config_value('html_use_opensearch', '', 'html')
1348 app.add_config_value('html_file_suffix', None, 'html', str)
1349 app.add_config_value('html_link_suffix', None, 'html', str)
1350 app.add_config_value('html_show_copyright', True, 'html')
1351 app.add_config_value('html_show_search_summary', True, 'html')
1352 app.add_config_value('html_show_sphinx', True, 'html')
1353 app.add_config_value('html_context', {}, 'html')
1354 app.add_config_value('html_output_encoding', 'utf-8', 'html')
1355 app.add_config_value('html_compact_lists', True, 'html')
1356 app.add_config_value('html_secnumber_suffix', '. ', 'html')
1357 app.add_config_value('html_search_language', None, 'html', str)
1358 app.add_config_value('html_search_options', {}, 'html')
1359 app.add_config_value('html_search_scorer', '', '')
1360 app.add_config_value('html_scaled_image_link', True, 'html')
1361 app.add_config_value('html_baseurl', '', 'html')
1362 # removal is indefinitely on hold (ref: https://github.com/sphinx-doc/sphinx/issues/10265)
1363 app.add_config_value('html_codeblock_linenos_style', 'inline', 'html',
1364 ENUM('table', 'inline'))
1365 app.add_config_value('html_math_renderer', None, 'env')
1366 app.add_config_value('html4_writer', False, 'html')
1367
1368 # events
1369 app.add_event('html-collect-pages')
1370 app.add_event('html-page-context')
1371
1372 # event handlers
1373 app.connect('config-inited', convert_html_css_files, priority=800)
1374 app.connect('config-inited', convert_html_js_files, priority=800)
1375 app.connect('config-inited', validate_html_extra_path, priority=800)
1376 app.connect('config-inited', validate_html_static_path, priority=800)
1377 app.connect('config-inited', validate_html_logo, priority=800)
1378 app.connect('config-inited', validate_html_favicon, priority=800)
1379 app.connect('config-inited', error_on_html_sidebars_string_values, priority=800)
1380 app.connect('config-inited', error_on_html_4, priority=800)
1381 app.connect('builder-inited', validate_math_renderer)
1382 app.connect('html-page-context', setup_resource_paths)
1383
1384 # load default math renderer
1385 app.setup_extension('sphinx.ext.mathjax')
1386
1387 # load transforms for HTML builder
1388 app.setup_extension('sphinx.builders.html.transforms')
1389
1390 return {
1391 'version': 'builtin',
1392 'parallel_read_safe': True,
1393 'parallel_write_safe': True,
1394 }
1395
1396
1397# deprecated name -> (object to return, canonical path or empty string, removal version)
1398_DEPRECATED_OBJECTS: dict[str, tuple[Any, str, tuple[int, int]]] = {
1399 'Stylesheet': (_CascadingStyleSheet, 'sphinx.builders.html._assets._CascadingStyleSheet', (9, 0)), # NoQA: E501
1400 'JavaScript': (_JavaScript, 'sphinx.builders.html._assets._JavaScript', (9, 0)),
1401}
1402
1403
1404def __getattr__(name: str) -> Any:
1405 if name not in _DEPRECATED_OBJECTS:
1406 msg = f'module {__name__!r} has no attribute {name!r}'
1407 raise AttributeError(msg)
1408
1409 from sphinx.deprecation import _deprecation_warning
1410
1411 deprecated_object, canonical_name, remove = _DEPRECATED_OBJECTS[name]
1412 _deprecation_warning(__name__, name, canonical_name, remove=remove)
1413 return deprecated_object