build.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import logging
  2. import os
  3. import gzip
  4. from urllib.parse import urlparse
  5. from jinja2.exceptions import TemplateNotFound
  6. import jinja2
  7. from mkdocs import utils
  8. from mkdocs.structure.files import get_files
  9. from mkdocs.structure.nav import get_navigation
  10. import mkdocs
  11. class DuplicateFilter:
  12. ''' Avoid logging duplicate messages. '''
  13. def __init__(self):
  14. self.msgs = set()
  15. def filter(self, record):
  16. rv = record.msg not in self.msgs
  17. self.msgs.add(record.msg)
  18. return rv
  19. log = logging.getLogger(__name__)
  20. log.addFilter(DuplicateFilter())
  21. log.addFilter(utils.warning_filter)
  22. def get_context(nav, files, config, page=None, base_url=''):
  23. """
  24. Return the template context for a given page or template.
  25. """
  26. if page is not None:
  27. base_url = utils.get_relative_url('.', page.url)
  28. extra_javascript = utils.create_media_urls(config['extra_javascript'], page, base_url)
  29. extra_css = utils.create_media_urls(config['extra_css'], page, base_url)
  30. return {
  31. 'nav': nav,
  32. 'pages': files.documentation_pages(),
  33. 'base_url': base_url,
  34. 'extra_css': extra_css,
  35. 'extra_javascript': extra_javascript,
  36. 'mkdocs_version': mkdocs.__version__,
  37. 'build_date_utc': utils.get_build_datetime(),
  38. 'config': config,
  39. 'page': page,
  40. }
  41. def _build_template(name, template, files, config, nav):
  42. """
  43. Return rendered output for given template as a string.
  44. """
  45. # Run `pre_template` plugin events.
  46. template = config['plugins'].run_event(
  47. 'pre_template', template, template_name=name, config=config
  48. )
  49. if utils.is_error_template(name):
  50. # Force absolute URLs in the nav of error pages and account for the
  51. # possability that the docs root might be different than the server root.
  52. # See https://github.com/mkdocs/mkdocs/issues/77.
  53. # However, if site_url is not set, assume the docs root and server root
  54. # are the same. See https://github.com/mkdocs/mkdocs/issues/1598.
  55. base_url = urlparse(config['site_url'] or '/').path
  56. else:
  57. base_url = utils.get_relative_url('.', name)
  58. context = get_context(nav, files, config, base_url=base_url)
  59. # Run `template_context` plugin events.
  60. context = config['plugins'].run_event(
  61. 'template_context', context, template_name=name, config=config
  62. )
  63. output = template.render(context)
  64. # Run `post_template` plugin events.
  65. output = config['plugins'].run_event(
  66. 'post_template', output, template_name=name, config=config
  67. )
  68. return output
  69. def _build_theme_template(template_name, env, files, config, nav):
  70. """ Build a template using the theme environment. """
  71. log.debug("Building theme template: {}".format(template_name))
  72. try:
  73. template = env.get_template(template_name)
  74. except TemplateNotFound:
  75. log.warning("Template skipped: '{}' not found in theme directories.".format(template_name))
  76. return
  77. output = _build_template(template_name, template, files, config, nav)
  78. if output.strip():
  79. output_path = os.path.join(config['site_dir'], template_name)
  80. utils.write_file(output.encode('utf-8'), output_path)
  81. if template_name == 'sitemap.xml':
  82. log.debug("Gzipping template: %s", template_name)
  83. gz_filename = '{}.gz'.format(output_path)
  84. with open(gz_filename, 'wb') as f:
  85. timestamp = utils.get_build_timestamp()
  86. with gzip.GzipFile(fileobj=f, filename=gz_filename, mode='wb', mtime=timestamp) as gz_buf:
  87. gz_buf.write(output.encode('utf-8'))
  88. else:
  89. log.info("Template skipped: '{}' generated empty output.".format(template_name))
  90. def _build_extra_template(template_name, files, config, nav):
  91. """ Build user templates which are not part of the theme. """
  92. log.debug("Building extra template: {}".format(template_name))
  93. file = files.get_file_from_path(template_name)
  94. if file is None:
  95. log.warning("Template skipped: '{}' not found in docs_dir.".format(template_name))
  96. return
  97. try:
  98. with open(file.abs_src_path, 'r', encoding='utf-8', errors='strict') as f:
  99. template = jinja2.Template(f.read())
  100. except Exception as e:
  101. log.warning("Error reading template '{}': {}".format(template_name, e))
  102. return
  103. output = _build_template(template_name, template, files, config, nav)
  104. if output.strip():
  105. utils.write_file(output.encode('utf-8'), file.abs_dest_path)
  106. else:
  107. log.info("Template skipped: '{}' generated empty output.".format(template_name))
  108. def _populate_page(page, config, files, dirty=False):
  109. """ Read page content from docs_dir and render Markdown. """
  110. try:
  111. # When --dirty is used, only read the page if the file has been modified since the
  112. # previous build of the output.
  113. if dirty and not page.file.is_modified():
  114. return
  115. # Run the `pre_page` plugin event
  116. page = config['plugins'].run_event(
  117. 'pre_page', page, config=config, files=files
  118. )
  119. page.read_source(config)
  120. # Run `page_markdown` plugin events.
  121. page.markdown = config['plugins'].run_event(
  122. 'page_markdown', page.markdown, page=page, config=config, files=files
  123. )
  124. page.render(config, files)
  125. # Run `page_content` plugin events.
  126. page.content = config['plugins'].run_event(
  127. 'page_content', page.content, page=page, config=config, files=files
  128. )
  129. except Exception as e:
  130. log.error("Error reading page '{}': {}".format(page.file.src_path, e))
  131. raise
  132. def _build_page(page, config, files, nav, env, dirty=False):
  133. """ Pass a Page to theme template and write output to site_dir. """
  134. try:
  135. # When --dirty is used, only build the page if the file has been modified since the
  136. # previous build of the output.
  137. if dirty and not page.file.is_modified():
  138. return
  139. log.debug("Building page {}".format(page.file.src_path))
  140. # Activate page. Signals to theme that this is the current page.
  141. page.active = True
  142. context = get_context(nav, files, config, page)
  143. # Allow 'template:' override in md source files.
  144. if 'template' in page.meta:
  145. template = env.get_template(page.meta['template'])
  146. else:
  147. template = env.get_template('main.html')
  148. # Run `page_context` plugin events.
  149. context = config['plugins'].run_event(
  150. 'page_context', context, page=page, config=config, nav=nav
  151. )
  152. # Render the template.
  153. output = template.render(context)
  154. # Run `post_page` plugin events.
  155. output = config['plugins'].run_event(
  156. 'post_page', output, page=page, config=config
  157. )
  158. # Write the output file.
  159. if output.strip():
  160. utils.write_file(output.encode('utf-8', errors='xmlcharrefreplace'), page.file.abs_dest_path)
  161. else:
  162. log.info("Page skipped: '{}'. Generated empty output.".format(page.file.src_path))
  163. # Deactivate page
  164. page.active = False
  165. except Exception as e:
  166. log.error("Error building page '{}': {}".format(page.file.src_path, e))
  167. raise
  168. def build(config, live_server=False, dirty=False):
  169. """ Perform a full site build. """
  170. from time import time
  171. start = time()
  172. # Run `config` plugin events.
  173. config = config['plugins'].run_event('config', config)
  174. # Run `pre_build` plugin events.
  175. config['plugins'].run_event('pre_build', config=config)
  176. if not dirty:
  177. log.info("Cleaning site directory")
  178. utils.clean_directory(config['site_dir'])
  179. else: # pragma: no cover
  180. # Warn user about problems that may occur with --dirty option
  181. log.warning("A 'dirty' build is being performed, this will likely lead to inaccurate navigation and other"
  182. " links within your site. This option is designed for site development purposes only.")
  183. if not live_server: # pragma: no cover
  184. log.info("Building documentation to directory: %s", config['site_dir'])
  185. if dirty and site_directory_contains_stale_files(config['site_dir']):
  186. log.info("The directory contains stale files. Use --clean to remove them.")
  187. # First gather all data from all files/pages to ensure all data is consistent across all pages.
  188. files = get_files(config)
  189. env = config['theme'].get_env()
  190. files.add_files_from_theme(env, config)
  191. # Run `files` plugin events.
  192. files = config['plugins'].run_event('files', files, config=config)
  193. nav = get_navigation(files, config)
  194. # Run `nav` plugin events.
  195. nav = config['plugins'].run_event('nav', nav, config=config, files=files)
  196. log.debug("Reading markdown pages.")
  197. for file in files.documentation_pages():
  198. log.debug("Reading: " + file.src_path)
  199. _populate_page(file.page, config, files, dirty)
  200. # Run `env` plugin events.
  201. env = config['plugins'].run_event(
  202. 'env', env, config=config, files=files
  203. )
  204. # Start writing files to site_dir now that all data is gathered. Note that order matters. Files
  205. # with lower precedence get written first so that files with higher precedence can overwrite them.
  206. log.debug("Copying static assets.")
  207. files.copy_static_files(dirty=dirty)
  208. for template in config['theme'].static_templates:
  209. _build_theme_template(template, env, files, config, nav)
  210. for template in config['extra_templates']:
  211. _build_extra_template(template, files, config, nav)
  212. log.debug("Building markdown pages.")
  213. for file in files.documentation_pages():
  214. _build_page(file.page, config, files, nav, env, dirty)
  215. # Run `post_build` plugin events.
  216. config['plugins'].run_event('post_build', config=config)
  217. if config['strict'] and utils.warning_filter.count:
  218. raise SystemExit('\nExited with {} warnings in strict mode.'.format(utils.warning_filter.count))
  219. log.info('Documentation built in %.2f seconds', time() - start)
  220. def site_directory_contains_stale_files(site_directory):
  221. """ Check if the site directory contains stale files from a previous build. """
  222. return True if os.path.exists(site_directory) and os.listdir(site_directory) else False