From 4577c5052cd28cf6f642a3333b444e78f2966210 Mon Sep 17 00:00:00 2001 From: rayangler <27821750+rayangler@users.noreply.github.com> Date: Fri, 13 Sep 2024 15:51:51 -0400 Subject: [PATCH] DOP-4689: Implement multi-page tutorials (#617) Co-authored-by: Caesar Bell --- snooty/postprocess.py | 84 +++++++++++++++++++ snooty/rstspec.toml | 9 ++ snooty/test_postprocess.py | 168 ++++++++++++++++++++++++++++++++++++- snooty/types.py | 1 + 4 files changed, 261 insertions(+), 1 deletion(-) diff --git a/snooty/postprocess.py b/snooty/postprocess.py index f7faaead..bd0c849b 100644 --- a/snooty/postprocess.py +++ b/snooty/postprocess.py @@ -1987,6 +1987,45 @@ def exit_page(self, fileid_stack: FileIdStack, page: Page) -> None: self.pending_diagnostics = [] +class MultiPageTutorialHandler(Handler): + """Handles page-wide settings for a multi-page tutorial page.""" + + def __init__(self, context: Context) -> None: + super().__init__(context) + self.target_directive_name = "multi-page-tutorial" + self.found_directive: Optional[n.Directive] = None + self.pending_node_removals: List[n.Directive] = [] + + def enter_page(self, fileid_stack: FileIdStack, page: Page) -> None: + self.found_directive = None + self.pending_node_removals = [] + + def enter_node(self, fileid_stack: FileIdStack, node: n.Node) -> None: + if not isinstance(node, n.Directive) or node.name != self.target_directive_name: + return + + self.pending_node_removals.append(node) + + if self.found_directive: + DuplicateDirective(self.target_directive_name, node.start[0]) + return + + self.found_directive = node + + def exit_page(self, fileid_stack: FileIdStack, page: Page) -> None: + if not self.found_directive: + return + + page.ast.options["multi_page_tutorial_settings"] = { + "time_required": self.found_directive.options.get("time-required", 0), + "show_next_top": self.found_directive.options.get("show-next-top", False), + } + + # Remove AST(s) to avoid unnecessary duplicate data + for node in self.pending_node_removals: + page.ast.children.remove(node) + + class PostprocessorResult(NamedTuple): pages: Dict[FileId, Page] metadata: Dict[str, SerializableType] @@ -2055,6 +2094,7 @@ class Postprocessor: CollapsibleHandler, WayfindingHandler, MethodSelectorHandler, + MultiPageTutorialHandler, ], [TargetHandler, IAHandler, NamedReferenceHandlerPass1], [RefsHandler, NamedReferenceHandlerPass2], @@ -2147,6 +2187,7 @@ def generate_metadata(cls, context: Context) -> n.SerializedNode: ) for k, v in context[HeadingHandler].slug_title_mapping.items() } + multi_pages_tutorials = context[ProjectConfig].multi_page_tutorials # Run postprocessing operations related to toctree and append to metadata document. # If iatree is found, use it to generate breadcrumbs and parent paths and save it to metadata as well. iatree = cls.build_iatree(context) @@ -2160,6 +2201,9 @@ def generate_metadata(cls, context: Context) -> n.SerializedNode: "toctree": toctree, "toctreeOrder": cls.toctree_order(tree), "parentPaths": cls.breadcrumbs(tree), + "multiPageTutorials": cls.generate_multi_page_tutorials( + tree, multi_pages_tutorials + ), } ) @@ -2440,6 +2484,22 @@ def find_toctree_nodes( visited_file_ids, ) + @staticmethod + def generate_multi_page_tutorials( + tree: Dict[str, SerializableType], multi_page_tutorials: List[str] + ) -> Dict[str, n.SerializedNode]: + """Generate steps for multi page tutorials for each parent listed in the multi_page_tutorials array""" + result: Dict[str, n.SerializedNode] = {} + + if not multi_page_tutorials: + return result + + if "children" in tree and isinstance(tree["children"], List): + for node in tree["children"]: + find_multi_page_tutorial_children(node, multi_page_tutorials, result) + + return result + @staticmethod def breadcrumbs(tree: Dict[str, SerializableType]) -> Dict[str, List[str]]: """Generate breadcrumbs for each page represented in the provided toctree""" @@ -2500,6 +2560,30 @@ def get_paths(node: Dict[str, Any], path: List[str], all_paths: List[Any]) -> No get_paths(child, subpath, all_paths) +def find_multi_page_tutorial_children( + node: Dict[str, SerializableType], + multi_page_tutorials: List[str], + result: Dict[str, n.SerializedNode], +) -> None: + slug = node.get("slug", "") + if not (slug and isinstance(slug, str)): + return + + children = node.get("children", []) + if not (children and isinstance(children, List)): + return + + formatted_slug = f"/{slug}" + if formatted_slug in multi_page_tutorials: + result[slug] = { + "total_steps": len(children), + "slugs": [child["slug"] for child in children], + } + + for child in children: + find_multi_page_tutorial_children(child, multi_page_tutorials, result) + + def clean_slug(slug: str) -> str: """Strip file extension and leading/trailing slashes (/) from string""" slug = slug.strip("/") diff --git a/snooty/rstspec.toml b/snooty/rstspec.toml index a7e6c48f..093f6690 100644 --- a/snooty/rstspec.toml +++ b/snooty/rstspec.toml @@ -829,6 +829,15 @@ options.id = {type = "string", required = true} help = "An optional description to be used for a method option." content_type = "block" +[directive."mongodb:multi-page-tutorial"] +help = "Configure settings for a multi-page tutorial page, if it is one." +options.time-required = {type = "nonnegative_integer", required = true} +options.show-next-top = "flag" +example = """.. multi-page-tutorial:: + :time-required: 5 + :show-next-top: +""" + ###### Misc. [directive.atf-image] help = "Path to the image to use for the above-the-fold header image" diff --git a/snooty/test_postprocess.py b/snooty/test_postprocess.py index dd6c1df8..36b95aa6 100644 --- a/snooty/test_postprocess.py +++ b/snooty/test_postprocess.py @@ -1,7 +1,7 @@ """An alternative and more granular approach to writing postprocessing tests. Eventually most postprocessor tests should probably be moved into this format.""" -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, Dict, cast from snooty.types import Facet @@ -3878,3 +3878,169 @@ def test_method_selector() -> None: assert result.pages[FileId("valid_method_selector.txt")].ast.options.get( target_option_field, False ) + + +def test_multi_page_tutorials() -> None: + test_page_template = """ +.. multi-page-tutorial:: + :time-required: 3 + :show-next-top: + +========= +Test Page +========= + +Words. + +""" + + mock_filenames = [ + "tutorial/create-new-cluster.txt", + "tutorial/create-serverless-instance.txt", + "tutorial/create-global-cluster.txt", + "tutorial/create-atlas-account.txt", + "tutorial/deploy-free-tier-cluster.txt", + "tutorial/create-mongodb-user-for-cluster.txt", + "tutorial/connect-to-your-cluster.txt", + "tutorial/insert-data-into-your-cluster.txt", + ] + mock_files: Dict[PurePath, Any] = {} + for filename in mock_filenames: + mock_files[PurePath(f"source/{filename}")] = test_page_template + + mock_files[ + PurePath("snooty.toml") + ] = """ +name = "test_multi_page_tutorials" +title = "MongoDB title" + +toc_landing_pages = [ + "/create-connect-deployments", + "/create-database-deployment", + "/getting-started", + "/foo", +] + +multi_page_tutorials = [ + "/create-database-deployment", + "/getting-started", +] +""" + mock_files[ + PurePath("source/index.txt") + ] = """ +======== +Homepage +======== + +Words!!! + +.. toctree:: + :titlesonly: + + Getting Started + Create & Connect Deployments + Foo + +""" + + # Handles nested TOC case + mock_files[ + PurePath("source/create-connect-deployments.txt") + ] = """ +============================== +Create and Connect Deployments +============================== + +Words. + +.. toctree:: + :titlesonly: + + Create a Cluster + +""" + mock_files[ + PurePath("source/create-database-deployment.txt") + ] = """ +.. toctree:: + :titlesonly: + + Cluster + Serverless Instance + Global Cluster + +========================== +Create Database Deployment +========================== + +Words. + +""" + + # Handles root TOC case + mock_files[ + PurePath("source/getting-started.txt") + ] = """ +=============== +Getting Started +=============== + +Words. + +.. toctree:: + :titlesonly: + + Create an Account + Deploy a Free Cluster + Manage Database Users + Connect to the Cluster + Insert and View a Document + +""" + + # Control; not intended to be included as MTP + mock_files[ + PurePath("source/foo.txt") + ] = """ +=== +Foo +=== + +Words! + +""" + + with make_test(mock_files) as result: + # Ensure handler adds settings to page options + for filename in mock_filenames: + mtp_options = result.pages[FileId(filename)].ast.options.get( + "multi_page_tutorial_settings", None + ) + assert isinstance(mtp_options, Dict) + assert mtp_options.get("time_required", 0) > 0 + assert mtp_options.get("show_next_top", False) + + # Ensure metadata has a record of all multi-page tutorials + multi_page_tutorials = result.metadata.get("multiPageTutorials", None) + assert isinstance(multi_page_tutorials, Dict) + assert multi_page_tutorials == { + "getting-started": { + "total_steps": 5, + "slugs": [ + "tutorial/create-atlas-account", + "tutorial/deploy-free-tier-cluster", + "tutorial/create-mongodb-user-for-cluster", + "tutorial/connect-to-your-cluster", + "tutorial/insert-data-into-your-cluster", + ], + }, + "create-database-deployment": { + "total_steps": 3, + "slugs": [ + "tutorial/create-new-cluster", + "tutorial/create-serverless-instance", + "tutorial/create-global-cluster", + ], + }, + } diff --git a/snooty/types.py b/snooty/types.py index a2cb397a..2452f28a 100644 --- a/snooty/types.py +++ b/snooty/types.py @@ -210,6 +210,7 @@ class ProjectConfig: sharedinclude_root: Optional[str] = field(default=None) substitutions: Dict[str, str] = field(default_factory=dict) toc_landing_pages: List[str] = field(default_factory=list) + multi_page_tutorials: List[str] = field(default_factory=list) page_groups: Dict[str, List[str]] = field(default_factory=dict) manpages: Dict[str, ManPageConfig] = field(default_factory=dict) bundle: BundleConfig = field(default_factory=BundleConfig)