Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.9/dist-packages/sphinx/builders/html/__init__.py: 4%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

858 statements  

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