diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index 561ed849..95574313 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -310,6 +310,8 @@ exclude_docs: | This follows the [.gitignore pattern format](https://git-scm.com/docs/gitignore#_pattern_format). +Note that `mkdocs serve` does *not* follow this setting and instead displays excluded documents but with a "DRAFT" mark. + The following defaults are always implicitly prepended - to exclude dot-files (and directories) as well as the top-level `templates` directory: ```yaml @@ -327,6 +329,31 @@ exclude_docs: | !.assets # Don't exclude '.assets' although all other '.*' are excluded ``` +### not_in_nav + +NEW: **New in version 1.5.** + +NOTE: This option does *not* actually exclude anything from the nav. + +If you want to include some docs into the site but intentionally exclude them from the nav, normally MkDocs warns about this. + +Adding such patterns of files (relative to [`docs_dir`](#docs_dir)) into the `not_in_nav` config will prevent such warnings. + +Example: + +```yaml +nav: + - Foo: foo.md + - Bar: bar.md + +not_in_nav: | + /private.md +``` + +As the previous option, this follows the .gitignore pattern format. + +NOTE: Adding a given file to [`exclude_docs`](#exclude_docs) takes precedence over and implies `not_in_nav`. + ## Build directories ### theme diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py index cc53ff54..82bec232 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -14,7 +14,7 @@ import mkdocs from mkdocs import utils from mkdocs.config.defaults import MkDocsConfig from mkdocs.exceptions import Abort, BuildError -from mkdocs.structure.files import File, Files, get_files +from mkdocs.structure.files import File, Files, InclusionLevel, _set_exclusions, get_files from mkdocs.structure.nav import Navigation, get_navigation from mkdocs.structure.pages import Page @@ -200,6 +200,7 @@ def _build_page( nav: Navigation, env: jinja2.Environment, dirty: bool = False, + excluded: bool = False, ) -> None: """Pass a Page to theme template and write output to site_dir.""" @@ -227,6 +228,13 @@ def _build_page( 'page_context', context, page=page, config=config, nav=nav ) + if excluded: + page.content = ( + '
' + 'DRAFT' + '
' + (page.content or '') + ) + # Render the template. output = template.render(context) @@ -263,6 +271,8 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) if config.strict: logging.getLogger('mkdocs').addHandler(warning_counter) + inclusion = InclusionLevel.all if live_server else InclusionLevel.is_included + try: start = time.monotonic() @@ -295,6 +305,8 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) # Run `files` plugin events. files = config.plugins.run_event('files', files, config=config) + # If plugins have added files but haven't set their inclusion level, calculate it again. + _set_exclusions(files._files, config) nav = get_navigation(files, config) @@ -302,10 +314,20 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) nav = config.plugins.run_event('nav', nav, config=config, files=files) log.debug("Reading markdown pages.") - for file in files.documentation_pages(): + excluded = [] + for file in files.documentation_pages(inclusion=inclusion): log.debug(f"Reading: {file.src_uri}") + if file.page is None and file.inclusion.is_excluded(): + excluded.append(file.src_path) + Page(None, file, config) assert file.page is not None _populate_page(file.page, config, files, dirty) + if excluded: + log.info( + "The following pages are being built only for the preview " + "but will be excluded from `mkdocs build` per `exclude_docs`:\n - " + + "\n - ".join(excluded) + ) # Run `env` plugin events. env = config.plugins.run_event('env', env, config=config, files=files) @@ -314,7 +336,7 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) # with lower precedence get written first so that files with higher precedence can overwrite them. log.debug("Copying static assets.") - files.copy_static_files(dirty=dirty) + files.copy_static_files(dirty=dirty, inclusion=inclusion) for template in config.theme.static_templates: _build_theme_template(template, env, files, config, nav) @@ -323,10 +345,12 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False) _build_extra_template(template, files, config, nav) log.debug("Building markdown pages.") - doc_files = files.documentation_pages() + doc_files = files.documentation_pages(inclusion=inclusion) for file in doc_files: assert file.page is not None - _build_page(file.page, config, doc_files, nav, env, dirty) + _build_page( + file.page, config, doc_files, nav, env, dirty, excluded=file.inclusion.is_excluded() + ) # Run `post_build` plugin events. config.plugins.run_event('post_build', config=config) diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py index 5561379a..ade08ff8 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -29,6 +29,9 @@ class MkDocsConfig(base.Config): exclude_docs = c.Optional(c.PathSpec()) """Gitignore-like patterns of files (relative to docs dir) to exclude from the site.""" + not_in_nav = c.Optional(c.PathSpec()) + """Gitignore-like patterns of files (relative to docs dir) that are not intended to be in the nav.""" + site_url = c.Optional(c.URL(is_dir=True)) """The full URL to where the documentation will be hosted.""" diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py index 50d445e7..49f7565a 100644 --- a/mkdocs/structure/files.py +++ b/mkdocs/structure/files.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import fnmatch import logging import os @@ -7,7 +8,7 @@ import posixpath import shutil import warnings from pathlib import PurePath -from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping, Sequence from urllib.parse import quote as urlquote import jinja2.environment @@ -25,6 +26,32 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +class InclusionLevel(enum.Enum): + Excluded = -2 + """The file is excluded from the final site, but will still be populated during `mkdocs serve`.""" + NotInNav = -1 + """The file is part of the site, but doesn't produce nav warnings.""" + Undefined = 0 + """Still needs to be computed based on the config. If the config doesn't kick in, acts the same as `included`.""" + Included = 1 + """The file is part of the site. Documentation pages that are omitted from the nav will produce warnings.""" + + def all(self): + return True + + def is_included(self): + return self.value > self.Excluded.value + + def is_excluded(self): + return self.value <= self.Excluded.value + + def is_in_nav(self): + return self.value > self.NotInNav.value + + def is_not_in_nav(self): + return self.value <= self.NotInNav.value + + class Files: """A collection of [File][mkdocs.structure.files.File] objects.""" @@ -71,15 +98,22 @@ class Files: self._src_uris = None self._files.remove(file) - def copy_static_files(self, dirty: bool = False) -> None: + def copy_static_files( + self, + dirty: bool = False, + *, + inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included, + ) -> None: """Copy static files from source to destination.""" for file in self: - if not file.is_documentation_page(): + if not file.is_documentation_page() and inclusion(file.inclusion): file.copy_file(dirty) - def documentation_pages(self) -> Sequence[File]: + def documentation_pages( + self, *, inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included + ) -> Sequence[File]: """Return iterable of all Markdown page file objects.""" - return [file for file in self if file.is_documentation_page()] + return [file for file in self if file.is_documentation_page() and inclusion(file.inclusion)] def static_pages(self) -> Sequence[File]: """Return iterable of all static page file objects.""" @@ -156,6 +190,9 @@ class File: url: str """The URI of the destination file relative to the destination directory as a string.""" + inclusion: InclusionLevel + """Whether the file will be excluded from the built site.""" + @property def src_path(self) -> str: """Same as `src_uri` (and synchronized with it) but will use backslashes on Windows. Discouraged.""" @@ -184,6 +221,7 @@ class File: use_directory_urls: bool, *, dest_uri: str | None = None, + inclusion: InclusionLevel = InclusionLevel.Undefined, ) -> None: self.page = None self.src_path = path @@ -194,6 +232,7 @@ class File: self.url = self._get_url(use_directory_urls) self.abs_src_path = os.path.normpath(os.path.join(src_dir, self.src_uri)) self.abs_dest_path = os.path.normpath(os.path.join(dest_dir, self.dest_uri)) + self.inclusion = inclusion def __eq__(self, other) -> bool: return ( @@ -280,16 +319,32 @@ class File: _default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/']) +def _set_exclusions(files: Iterable[File], config: MkDocsConfig | Mapping[str, Any]) -> None: + """Re-calculate which files are excluded, based on the patterns in the config.""" + exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs') + exclude = _default_exclude + exclude if exclude else _default_exclude + nav_exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('not_in_nav') + + for file in files: + if file.inclusion == InclusionLevel.Undefined: + if exclude.match_file(file.src_uri): + file.inclusion = InclusionLevel.Excluded + elif nav_exclude and nav_exclude.match_file(file.src_uri): + file.inclusion = InclusionLevel.NotInNav + else: + file.inclusion = InclusionLevel.Included + + def get_files(config: MkDocsConfig | Mapping[str, Any]) -> Files: """Walk the `docs_dir` and return a Files collection.""" - exclude = config.get('exclude_docs') - exclude = _default_exclude + exclude if exclude else _default_exclude - files = [] + files: list[File] = [] + conflicting_files: list[tuple[File, File]] = [] for source_dir, dirnames, filenames in os.walk(config['docs_dir'], followlinks=True): relative_dir = os.path.relpath(source_dir, config['docs_dir']) dirnames.sort() filenames.sort(key=_file_sort_key) + files_by_dest: dict[str, File] = {} for filename in filenames: file = File( os.path.join(relative_dir, filename), @@ -297,26 +352,31 @@ def get_files(config: MkDocsConfig | Mapping[str, Any]) -> Files: config['site_dir'], config['use_directory_urls'], ) - # Skip any excluded files - if exclude.match_file(file.src_uri): - continue - # Skip README.md if an index file also exists in dir - if filename == 'README.md' and 'index.md' in filenames: - log.warning( - f"Excluding '{file.src_uri}' from the site because " - f"it conflicts with 'index.md' in the same directory." - ) - continue + # Skip README.md if an index file also exists in dir (part 1) + prev_file = files_by_dest.setdefault(file.dest_uri, file) + if prev_file is not file: + conflicting_files.append((prev_file, file)) files.append(file) + prev_file = file + + _set_exclusions(files, config) + # Skip README.md if an index file also exists in dir (part 2) + for a, b in conflicting_files: + if b.inclusion.is_included(): + if a.inclusion.is_included(): + log.warning( + f"Excluding '{a.src_uri}' from the site because it conflicts with '{b.src_uri}'." + ) + files.remove(a) + else: + files.remove(b) return Files(files) def _file_sort_key(f: str): """Always sort `index` or `README` as first filename in list.""" - if os.path.splitext(f)[0] in ('index', 'README'): - return (0,) - return (1, f) + return (os.path.splitext(f)[0] not in ('index', 'README'), f) def _sort_files(filenames: Iterable[str]) -> list[str]: diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py index cdf1d169..1a47a7b7 100644 --- a/mkdocs/structure/nav.py +++ b/mkdocs/structure/nav.py @@ -147,7 +147,8 @@ class Link: def get_navigation(files: Files, config: MkDocsConfig | Mapping[str, Any]) -> Navigation: """Build site navigation from config and files.""" - nav_config = config['nav'] or nest_paths(f.src_uri for f in files.documentation_pages()) + documentation_pages = files.documentation_pages() + nav_config = config['nav'] or nest_paths(f.src_uri for f in documentation_pages) items = _data_to_navigation(nav_config, files, config) if not isinstance(items, list): items = [items] @@ -159,19 +160,20 @@ def get_navigation(files: Files, config: MkDocsConfig | Mapping[str, Any]) -> Na _add_previous_and_next_links(pages) _add_parent_links(items) - missing_from_config = [file for file in files.documentation_pages() if file.page is None] + missing_from_config = [] + for file in documentation_pages: + if file.page is None: + # Any documentation files not found in the nav should still have an associated page, so we + # create them here. The Page object will automatically be assigned to `file.page` during + # its creation (and this is the only way in which these page objects are accessible). + Page(None, file, config) + if file.inclusion.is_in_nav(): + missing_from_config.append(file.src_path) if missing_from_config: log.info( 'The following pages exist in the docs directory, but are not ' - 'included in the "nav" configuration:\n - {}'.format( - '\n - '.join(file.src_path for file in missing_from_config) - ) + 'included in the "nav" configuration:\n - ' + '\n - '.join(missing_from_config) ) - # Any documentation files not found in the nav should still have an associated page, so we - # create them here. The Page object will automatically be assigned to `file.page` during - # its creation (and this is the only way in which these page objects are accessible). - for file in missing_from_config: - Page(None, file, config) links = _get_by_type(items, Link) for link in links: @@ -184,11 +186,10 @@ def get_navigation(files: Files, config: MkDocsConfig | Mapping[str, Any]) -> Na "configuration, which presumably points to an external resource." ) else: - msg = ( + log.warning( f"A relative path to '{link.url}' is included in the 'nav' " - "configuration, which is not found in the documentation files" + "configuration, which is not found in the documentation files." ) - log.warning(msg) return Navigation(items, pages) @@ -210,6 +211,11 @@ def _data_to_navigation(data, files: Files, config: MkDocsConfig | Mapping[str, title, path = data if isinstance(data, tuple) else (None, data) file = files.get_file_from_path(path) if file: + if file.inclusion.is_excluded(): + log.info( + f"A relative path to '{file.src_path}' is included in the 'nav' " + "configuration, but this file is excluded from the built site." + ) return Page(title, file, config) return Link(title, path) diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py index b2785687..da5aa023 100644 --- a/mkdocs/structure/pages.py +++ b/mkdocs/structure/pages.py @@ -330,6 +330,11 @@ class _RelativePathTreeprocessor(Treeprocessor): f"'{target_uri}' which is not found in the documentation files." ) return url + if target_file.inclusion.is_excluded(): + log.info( + f"Documentation file '{self.file.src_uri}' contains a link to " + f"'{target_uri}' which is excluded from the built site." + ) path = target_file.url_relative_to(self.file) components = (scheme, netloc, path, query, fragment) return urlunsplit(components) diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py index e5c01af2..95366271 100644 --- a/mkdocs/tests/structure/file_tests.py +++ b/mkdocs/tests/structure/file_tests.py @@ -60,7 +60,7 @@ class TestFiles(PathAssertionMixin, unittest.TestCase): self.assertEqual( _sort_files(['a.md', 'index.md', 'b.md', 'index.html']), - ['index.md', 'index.html', 'a.md', 'b.md'], + ['index.html', 'index.md', 'a.md', 'b.md'], ) self.assertEqual( @@ -631,9 +631,15 @@ class TestFiles(PathAssertionMixin, unittest.TestCase): def test_get_files(self, tdir): config = load_config(docs_dir=tdir, extra_css=['bar.css'], extra_javascript=['bar.js']) files = get_files(config) - expected = ['index.md', 'bar.css', 'bar.html', 'bar.jpg', 'bar.js', 'bar.md', 'readme.md'] self.assertIsInstance(files, Files) - self.assertEqual([f.src_path for f in files], expected) + self.assertEqual( + [f.src_path for f in files if f.inclusion.is_included()], + ['index.md', 'bar.css', 'bar.html', 'bar.jpg', 'bar.js', 'bar.md', 'readme.md'], + ) + self.assertEqual( + [f.src_path for f in files if f.inclusion.is_excluded()], + ['.dotfile', 'templates/foo.html'], + ) @tempdir( files=[ @@ -644,9 +650,8 @@ class TestFiles(PathAssertionMixin, unittest.TestCase): def test_get_files_include_readme_without_index(self, tdir): config = load_config(docs_dir=tdir) files = get_files(config) - expected = ['README.md', 'foo.md'] self.assertIsInstance(files, Files) - self.assertEqual([f.src_path for f in files], expected) + self.assertEqual([f.src_path for f in files], ['README.md', 'foo.md']) @tempdir( files=[ @@ -662,11 +667,10 @@ class TestFiles(PathAssertionMixin, unittest.TestCase): self.assertRegex( '\n'.join(cm.output), r"^WARNING:mkdocs.structure.files:" - r"Excluding 'README.md' from the site because it conflicts with 'index.md' in the same directory.$", + r"Excluding 'README.md' from the site because it conflicts with 'index.md'.$", ) - expected = ['index.md', 'foo.md'] self.assertIsInstance(files, Files) - self.assertEqual([f.src_path for f in files], expected) + self.assertEqual([f.src_path for f in files], ['index.md', 'foo.md']) @tempdir() @tempdir(files={'test.txt': 'source content'}) diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py index b576ba2a..5751429e 100644 --- a/mkdocs/tests/structure/nav_tests.py +++ b/mkdocs/tests/structure/nav_tests.py @@ -159,9 +159,9 @@ class SiteNavigationTests(unittest.TestCase): cm.output, [ "WARNING:mkdocs.structure.nav:A relative path to 'missing.html' is included " - "in the 'nav' configuration, which is not found in the documentation files", + "in the 'nav' configuration, which is not found in the documentation files.", "WARNING:mkdocs.structure.nav:A relative path to 'example.com' is included " - "in the 'nav' configuration, which is not found in the documentation files", + "in the 'nav' configuration, which is not found in the documentation files.", ], ) self.assertEqual(str(site_navigation).strip(), expected)