Skip to content

Commit

Permalink
DOP-4689: Implement multi-page tutorials (#617)
Browse files Browse the repository at this point in the history
Co-authored-by: Caesar Bell <[email protected]>
  • Loading branch information
rayangler and Caesar Bell authored Sep 13, 2024
1 parent 2e54e02 commit 4577c50
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 1 deletion.
84 changes: 84 additions & 0 deletions snooty/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -2055,6 +2094,7 @@ class Postprocessor:
CollapsibleHandler,
WayfindingHandler,
MethodSelectorHandler,
MultiPageTutorialHandler,
],
[TargetHandler, IAHandler, NamedReferenceHandlerPass1],
[RefsHandler, NamedReferenceHandlerPass2],
Expand Down Expand Up @@ -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)
Expand All @@ -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
),
}
)

Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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("/")
Expand Down
9 changes: 9 additions & 0 deletions snooty/rstspec.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
168 changes: 167 additions & 1 deletion snooty/test_postprocess.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 </getting-started>
Create & Connect Deployments </create-connect-deployments>
Foo </foo>
"""

# Handles nested TOC case
mock_files[
PurePath("source/create-connect-deployments.txt")
] = """
==============================
Create and Connect Deployments
==============================
Words.
.. toctree::
:titlesonly:
Create a Cluster </create-database-deployment>
"""
mock_files[
PurePath("source/create-database-deployment.txt")
] = """
.. toctree::
:titlesonly:
Cluster </tutorial/create-new-cluster>
Serverless Instance </tutorial/create-serverless-instance>
Global Cluster </tutorial/create-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 </tutorial/create-atlas-account>
Deploy a Free Cluster </tutorial/deploy-free-tier-cluster>
Manage Database Users </tutorial/create-mongodb-user-for-cluster>
Connect to the Cluster </tutorial/connect-to-your-cluster>
Insert and View a Document </tutorial/insert-data-into-your-cluster>
"""

# 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",
],
},
}
1 change: 1 addition & 0 deletions snooty/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 4577c50

Please sign in to comment.