diff --git a/README.md b/README.md index d7ecb2a..3e3eb72 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,16 @@ This can be combined with `order` above. sort_type: natural ``` +### Order Navigation By Preference + +Create a file named `.pages` in a directory and set the `order_by` attribute to `filename` or `title` to change the order of navigation items. + +```yaml +order_by: title +``` + +This can be combined with `order` and/or `sort_type` above. If `order` is not set it will order ascending. If no preference is set, it will order by filename. + ### Collapse Single Nested Pages > **Note:** This feature is disabled by default. More on how to use it below @@ -380,6 +390,9 @@ plugins: filename: .index collapse_single_pages: true strict: false + order: asc + sort_type: natural + order_by: title ``` ### `filename` @@ -399,6 +412,10 @@ Raise errors instead of warnings when: Default is `true` +### `order`, `sort_type` and `order_by` + +Global fallback values for the Meta attributes. Default is `None` or `filename`. +
## Contributing diff --git a/mkdocs_awesome_pages_plugin/meta.py b/mkdocs_awesome_pages_plugin/meta.py index 1f0311f..52e0bf9 100644 --- a/mkdocs_awesome_pages_plugin/meta.py +++ b/mkdocs_awesome_pages_plugin/meta.py @@ -109,10 +109,13 @@ class Meta: HIDE_ATTRIBUTE = "hide" ORDER_ATTRIBUTE = "order" SORT_TYPE_ATTRIBUTE = "sort_type" + ORDER_BY_ATTRIBUTE = "order_by" ORDER_ASC = "asc" ORDER_DESC = "desc" SORT_NATURAL = "natural" + ORDER_BY_FILENAME = "filename" + ORDER_BY_TITLE = "title" def __init__( self, @@ -126,6 +129,7 @@ def __init__( hide: bool = None, order: Optional[str] = None, sort_type: Optional[str] = None, + order_by: Optional[str] = None, ): if nav is None and arrange is not None: nav = [MetaNavItem.from_yaml(value, path) for value in arrange] @@ -140,6 +144,7 @@ def __init__( self.hide = hide self.order = order self.sort_type = sort_type + self.order_by = order_by @staticmethod def try_load_from(path: Optional[str]) -> "Meta": @@ -162,6 +167,7 @@ def load_from(path: str) -> "Meta": hide = contents.get(Meta.HIDE_ATTRIBUTE) order = contents.get(Meta.ORDER_ATTRIBUTE) sort_type = contents.get(Meta.SORT_TYPE_ATTRIBUTE) + order_by = contents.get(Meta.ORDER_BY_ATTRIBUTE) if title is not None: if not isinstance(title, str): @@ -242,6 +248,20 @@ def load_from(path: str) -> "Meta": ) ) + if order_by is not None: + if order_by != Meta.ORDER_BY_TITLE and order_by != Meta.ORDER_BY_FILENAME: + raise TypeError( + 'Expected "{attribute}" attribute to be one of {those} - got "{order_by}" [{context}]'.format( + attribute=Meta.ORDER_BY_ATTRIBUTE, + those=[ + Meta.ORDER_BY_FILENAME, + Meta.ORDER_BY_TITLE, + ], + order_by=order_by, + context=path, + ) + ) + return Meta( title=title, arrange=arrange, @@ -252,4 +272,5 @@ def load_from(path: str) -> "Meta": hide=hide, order=order, sort_type=sort_type, + order_by=order_by, ) diff --git a/mkdocs_awesome_pages_plugin/navigation.py b/mkdocs_awesome_pages_plugin/navigation.py index deeb354..62b1fd6 100644 --- a/mkdocs_awesome_pages_plugin/navigation.py +++ b/mkdocs_awesome_pages_plugin/navigation.py @@ -1,7 +1,10 @@ import warnings from pathlib import Path + +import mkdocs.utils +import mkdocs.utils.meta from natsort import natsort_keygen -from typing import List, Optional, Union, Set +from typing import List, Optional, Union, Set, Dict from mkdocs.structure.nav import ( Navigation as MkDocsNavigation, @@ -79,12 +82,24 @@ def _process_children(self, children: List[NavigationItem], collapse: bool, meta return result def _order(self, items: List[NavigationItem], meta: Meta): - order, sort_type = meta.order, meta.sort_type - if order is None and sort_type is None: + if len(items) < 2: + return + + order = meta.order or self.options.order + sort_type = meta.sort_type or self.options.sort_type + order_by = meta.order_by or self.options.order_by + + if order is None and sort_type is None and order_by is None: return - key = lambda i: basename(self._get_item_path(i)) + + if order_by == Meta.ORDER_BY_TITLE: + key = lambda i: get_title(i) + else: + key = lambda i: basename(self._get_item_path(i)) + if sort_type == Meta.SORT_NATURAL: key = natsort_keygen(key) + items.sort(key=key, reverse=order == Meta.ORDER_DESC) def _nav(self, items: List[NavigationItem], meta: Meta) -> List[NavigationItem]: @@ -206,32 +221,34 @@ def __init__( explicit_sections: Set[Section], ): self.options = options - self.sections = {} + self.sections: Dict[Section, Meta] = {} self.docs_dir = docs_dir self.explicit_sections = explicit_sections - root_path = self._gather_metadata(items) - self.root = Meta.try_load_from(join_paths(root_path, self.options.filename)) + self.root: Meta = self._gather_metadata(items) - def _gather_metadata(self, items: List[NavigationItem]) -> Optional[str]: - paths = [] + def _gather_metadata(self, items: List[NavigationItem]) -> Meta: + paths: List[str] = [] for item in items: if isinstance(item, Page): if Path(self.docs_dir) in Path(item.file.abs_src_path).parents: paths.append(item.file.abs_src_path) elif isinstance(item, Section): - section_dir = self._gather_metadata(item.children) + section_meta = self._gather_metadata(item.children) + if item in self.explicit_sections: self.sections[item] = Meta() - else: - if section_dir is not None: - paths.append(section_dir) - self.sections[item] = Meta.try_load_from(join_paths(section_dir, self.options.filename)) + continue + + if section_meta.path is not None: + paths.append(dirname(section_meta.path)) - return self._common_dirname(paths) + self.sections[item] = section_meta + + return Meta.try_load_from(join_paths(self._common_dirname(paths), self.options.filename)) @staticmethod - def _common_dirname(paths: List[Optional[str]]) -> Optional[str]: + def _common_dirname(paths: List[str]) -> Optional[str]: if paths: dirnames = [dirname(path) for path in paths] if len(set(dirnames)) == 1: @@ -248,3 +265,38 @@ def get_by_type(nav, T): if item.children: ret.extend(get_by_type(item.children, T)) return ret + + +# Copy of mkdocs.structure.pages.Page._set_title and Page.read_source +def get_title(item: NavigationItem) -> str: + if item.title is not None: + return item.title + + if not isinstance(item, Page): + return str(item.title) + + try: + with open(item.file.abs_src_path, encoding="utf-8-sig", errors="strict") as f: + source = f.read() + except OSError: + raise OSError(f"File not found: {item.file.src_path}") + except ValueError: + raise ValueError(f"Encoding error reading file: {item.file.src_path}") + + page_markdown, page_meta = mkdocs.utils.meta.get_data(source) + + if "title" in page_meta: + return page_meta["title"] + + title = mkdocs.utils.get_markdown_title(page_markdown) + + if title is None: + if item.is_homepage: + title = "Home" + else: + title = item.file.name.replace("-", " ").replace("_", " ") + # Capitalize if the filename was all lowercase, otherwise leave it as-is. + if title.lower() == title: + title = title.capitalize() + + return title diff --git a/mkdocs_awesome_pages_plugin/options.py b/mkdocs_awesome_pages_plugin/options.py index 5b72945..bb8e6c3 100644 --- a/mkdocs_awesome_pages_plugin/options.py +++ b/mkdocs_awesome_pages_plugin/options.py @@ -1,5 +1,17 @@ class Options: - def __init__(self, *, filename: str, collapse_single_pages: bool, strict: bool): + def __init__( + self, + *, + filename: str, + collapse_single_pages: bool, + strict: bool, + order: str = None, + sort_type: str = None, + order_by: str = None, + ): self.filename = filename self.collapse_single_pages = collapse_single_pages self.strict = strict + self.order = order + self.sort_type = sort_type + self.order_by = order_by diff --git a/mkdocs_awesome_pages_plugin/plugin.py b/mkdocs_awesome_pages_plugin/plugin.py index 89670f2..bb83193 100644 --- a/mkdocs_awesome_pages_plugin/plugin.py +++ b/mkdocs_awesome_pages_plugin/plugin.py @@ -35,6 +35,9 @@ class AwesomePagesPlugin(BasePlugin): ("filename", config_options.Type(str, default=DEFAULT_META_FILENAME)), ("collapse_single_pages", config_options.Type(bool, default=False)), ("strict", config_options.Type(bool, default=True)), + ("order", config_options.Choice(["asc", "desc"], default=None)), + ("sort_type", config_options.Choice(["natural"], default=None)), + ("order_by", config_options.Choice(["filename", "title"], default=None)), ) def __init__(self): diff --git a/mkdocs_awesome_pages_plugin/tests/e2e/base.py b/mkdocs_awesome_pages_plugin/tests/e2e/base.py index 2b4f822..2bb5ef0 100644 --- a/mkdocs_awesome_pages_plugin/tests/e2e/base.py +++ b/mkdocs_awesome_pages_plugin/tests/e2e/base.py @@ -31,6 +31,7 @@ def pagesFile( hide: bool = None, order: Optional[str] = None, sort_type: Optional[str] = None, + order_by: Optional[str] = None, ) -> Tuple[str, str]: data = self._removeDictNoneValues( { @@ -42,6 +43,7 @@ def pagesFile( "hide": hide, "order": order, "sort_type": sort_type, + "order_by": order_by, } ) @@ -53,12 +55,18 @@ def createConfig( collapse_single_pages: Optional[bool] = None, mkdocs_nav: Optional[List[Union[str, Dict[str, Union[str, list]]]]] = None, strict: Optional[bool] = None, + order: Optional[str] = None, + sort_type: Optional[str] = None, + order_by: Optional[str] = None, ) -> dict: plugin_options = self._removeDictNoneValues( { "filename": filename, "collapse_single_pages": collapse_single_pages, "strict": strict, + "order": order, + "sort_type": sort_type, + "order_by": order_by, } ) plugins_entry = "awesome-pages" diff --git a/mkdocs_awesome_pages_plugin/tests/e2e/test_mkdocs_nav.py b/mkdocs_awesome_pages_plugin/tests/e2e/test_mkdocs_nav.py index 5f9e1fc..e188e87 100644 --- a/mkdocs_awesome_pages_plugin/tests/e2e/test_mkdocs_nav.py +++ b/mkdocs_awesome_pages_plugin/tests/e2e/test_mkdocs_nav.py @@ -1,3 +1,6 @@ +import pytest +from mkdocs import __version__ as mkdocs_version + from .base import E2ETestCase from ...meta import DuplicateRestItemError from ...navigation import NavEntryNotFound @@ -176,6 +179,10 @@ def test_sections_nested_rest(self): ], ) + @pytest.mark.skipif( + mkdocs_version >= "1.3.0", + reason="Since version 1.3 MkDocs validates nav and Dict type is invalid.", + ) def test_sections_nested_rest_dict(self): navigation = self.mkdocs( self.createConfig( diff --git a/mkdocs_awesome_pages_plugin/tests/e2e/test_order_and_sort.py b/mkdocs_awesome_pages_plugin/tests/e2e/test_order_and_sort.py index 5e03475..a7573d2 100644 --- a/mkdocs_awesome_pages_plugin/tests/e2e/test_order_and_sort.py +++ b/mkdocs_awesome_pages_plugin/tests/e2e/test_order_and_sort.py @@ -253,3 +253,174 @@ def test_nav_rest_desc_natural(self): ("20", [("3", "/20/3"), ("100", "/20/100"), ("20", "/20/20")]), ], ) + + def test_global_asc_without_local(self): + navigation = self.mkdocs( + self.createConfig(order="asc"), + [ + "1.md", + "3.md", + ("2", ["1.md", "2.md"]), + ], + ) + + self.assertEqual( + navigation, + [("1", "/1"), ("2", [("1", "/2/1"), ("2", "/2/2")]), ("3", "/3")], + ) + + def test_global_asc_local_desc(self): + navigation = self.mkdocs( + self.createConfig(order="asc"), + [ + "1.md", + "3.md", + ("2", ["1.md", "2.md", self.pagesFile(order="desc")]), + self.pagesFile(order="desc"), + ], + ) + + self.assertEqual( + navigation, + [("3", "/3"), ("2", [("2", "/2/2"), ("1", "/2/1")]), ("1", "/1")], + ) + + def test_global_asc_natural_without_local(self): + navigation = self.mkdocs( + self.createConfig(order="asc", sort_type="natural"), + [ + "100.md", + "3.md", + ("20", ["100.md", "20.md"]), + ], + ) + + self.assertEqual( + navigation, + [("3", "/3"), ("20", [("20", "/20/20"), ("100", "/20/100")]), ("100", "/100")], + ) + + def test_global_asc_order_by_title_without_local_h1_title(self): + navigation = self.mkdocs( + self.createConfig(order="asc", order_by="title"), + [ + ("1.md", "# C"), + ("B", [("1.md", "# C"), ("2.md", "# B")]), + ("3.md", "# A"), + ], + ) + + self.assertEqual( + navigation, + [("A", "/3"), ("B", [("B", "/B/2"), ("C", "/B/1")]), ("C", "/1")], + ) + + def test_global_asc_order_by_title_without_local_meta_title(self): + navigation = self.mkdocs( + self.createConfig(order="asc", order_by="title"), + [ + ("1.md", "---\ntitle: c\n---\n"), + ("2.md", "---\ntitle: b\n---\n"), + ("3.md", "---\ntitle: a\n---\n"), + ], + ) + + self.assertEqual( + navigation, + [("a", "/3"), ("b", "/2"), ("c", "/1")], + ) + + def test_global_asc_order_by_title_without_local_no_title(self): + navigation = self.mkdocs( + self.createConfig(order="asc", order_by="title"), + [ + "1.md", + "2.md", + "3.md", + ], + ) + + self.assertEqual( + navigation, + [("1", "/1"), ("2", "/2"), ("3", "/3")], + ) + + def test_global_asc_order_by_title_local_desc(self): + navigation = self.mkdocs( + self.createConfig(order="asc", order_by="title"), + [ + ("1.md", "# C"), + ("B", [("1.md", "# C"), ("2.md", "# B"), self.pagesFile(order="desc")]), + ("3.md", "# A"), + self.pagesFile(order="desc"), + ], + ) + + self.assertEqual( + navigation, + [("C", "/1"), ("B", [("C", "/B/1"), ("B", "/B/2")]), ("A", "/3")], + ) + + def test_global_asc_order_by_title_local_filename(self): + navigation = self.mkdocs( + self.createConfig(order="asc", order_by="title"), + [ + ("1.md", "# C"), + ("2", [("1.md", "# C"), ("2.md", "# B"), self.pagesFile(order_by="filename")]), + ("3.md", "# A"), + self.pagesFile(order_by="filename"), + ], + ) + + self.assertEqual( + navigation, + [("C", "/1"), ("2", [("C", "/2/1"), ("B", "/2/2")]), ("A", "/3")], + ) + + def test_local_order_by_title_without_global(self): + navigation = self.mkdocs( + self.createConfig(), + [ + ("1.md", "# C"), + ("B", [("1.md", "# C"), ("2.md", "# B"), self.pagesFile(order_by="title")]), + ("3.md", "# A"), + self.pagesFile(order_by="title"), + ], + ) + + self.assertEqual( + navigation, + [("A", "/3"), ("B", [("B", "/B/2"), ("C", "/B/1")]), ("C", "/1")], + ) + + def test_local_order_by_title_with_global_filename(self): + navigation = self.mkdocs( + self.createConfig(order_by="filename"), + [ + ("1.md", "# C"), + ("B", [("1.md", "# C"), ("2.md", "# B"), self.pagesFile(order_by="title")]), + ("3.md", "# A"), + self.pagesFile(order_by="title"), + ], + ) + + self.assertEqual( + navigation, + [("A", "/3"), ("B", [("B", "/B/2"), ("C", "/B/1")]), ("C", "/1")], + ) + + def test_local_order_by_inner_filename_root_title(self): + navigation = self.mkdocs( + self.createConfig(), + [ + ("1.md", "# C"), + ("B", [("1.md", "# C"), ("2.md", "# B"), self.pagesFile(order_by="filename")]), + ("3.md", "# A"), + self.pagesFile(order_by="title"), + ], + ) + + self.assertEqual( + navigation, + [("A", "/3"), ("B", [("C", "/B/1"), ("B", "/B/2")]), ("C", "/1")], + )