Skip to content

Commit 938d20a

Browse files
authored
DOP-1573: Add directive for banner support (#308)
* DOP-1573: Add directive for banner support * Add support for banners in project.config * DOP-1573: Explicitly copy node children as list * Add test suite and variant enum * Address feedback in domain and matching logic * Add linter updates * Multi-target and multi-banner support * Update test_postprocess.py * Ensure path comparisons for target occur relative to source * Add basic multi-banner test case * Added help string for banner directive * Address last round of feedback
1 parent 7bdfc6f commit 938d20a

File tree

7 files changed

+435
-2
lines changed

7 files changed

+435
-2
lines changed

snooty/parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
from .types import (
6868
BuildIdentifierSet,
6969
FileId,
70+
ParsedBannerConfig,
7071
ProjectConfig,
7172
SerializableType,
7273
StaticAsset,
@@ -1159,6 +1160,26 @@ def __init__(
11591160

11601161
self.config.substitution_nodes = substitution_nodes
11611162

1163+
# Parse banner value and instantiate a banner node for postprocessing, if a banner value is defined.
1164+
for banner in self.config.banners:
1165+
if banner.value:
1166+
1167+
options = {"variant": banner.variant}
1168+
banner_node = ParsedBannerConfig(
1169+
banner.targets,
1170+
n.Directive((-1,), [], "mongodb", "banner", [], options),
1171+
)
1172+
1173+
page, banner_diagnostics = parse_rst(inline_parser, root, banner.value)
1174+
banner_node.node.children = page.ast.children
1175+
if banner_node.node.children:
1176+
self.config.banner_nodes.append(banner_node)
1177+
if banner_diagnostics:
1178+
backend.on_diagnostics(
1179+
self.config.get_fileid(self.config.config_path),
1180+
banner_diagnostics,
1181+
)
1182+
11621183
username = getpass.getuser()
11631184
try:
11641185
branch = subprocess.check_output(

snooty/postprocess.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import urllib.parse
77
from collections import defaultdict
88
from copy import deepcopy
9+
from pathlib import Path
910
from typing import (
1011
Any,
1112
Callable,
@@ -41,7 +42,7 @@
4142
from .eventparser import EventParser, FileIdStack
4243
from .page import Page
4344
from .target_database import TargetDatabase
44-
from .types import FileId, ProjectConfig, SerializableType
45+
from .types import FileId, ParsedBannerConfig, ProjectConfig, SerializableType
4546
from .util import SOURCE_FILE_EXTENSIONS
4647

4748
logger = logging.getLogger(__name__)
@@ -546,6 +547,79 @@ def __call__(self, fileid_stack: FileIdStack, node: n.Node) -> None:
546547
)
547548

548549

550+
class BannerHandler:
551+
"""Traverse a series of pages matching specified targets in Snooty.toml
552+
and append Banner directive nodes"""
553+
554+
def __init__(self, banners: List[ParsedBannerConfig], root: Path) -> None:
555+
self.banners = banners
556+
self.root = root
557+
558+
def __find_target_insertion_node(self, node: n.Parent[n.Node]) -> Optional[n.Node]:
559+
"""Search via BFS for the first 'section' from a root node, arbitrarily terminating early if
560+
no 'section' is found within the first 50 nodes."""
561+
queue: List[n.Node] = list(node.children)
562+
curr_iteration = 0
563+
max_iteration = 50
564+
565+
insertion_node = None
566+
567+
while queue and curr_iteration < max_iteration:
568+
candidate = queue.pop(0)
569+
if candidate.type == "section":
570+
insertion_node = candidate
571+
break
572+
if isinstance(candidate, n.Parent):
573+
queue.extend(candidate.children)
574+
575+
curr_iteration += 1
576+
return insertion_node
577+
578+
def __determine_banner_index(self, node: n.Parent[n.Node]) -> int:
579+
"""Determine if there's a heading within the first level of the target insertion node's children.
580+
If so, return the index position after the first detected heading. Otherwise, return 0."""
581+
return (
582+
next(
583+
(
584+
idx
585+
for idx, child in enumerate(node.children)
586+
if isinstance(child, n.Heading)
587+
),
588+
0,
589+
)
590+
+ 1
591+
)
592+
593+
def __page_target_match(
594+
self, targets: List[str], page: Page, fileid: FileId
595+
) -> bool:
596+
"""Check if page matches target specified, but assert to ensure this does not run on includes"""
597+
assert fileid.suffix == ".txt"
598+
599+
page_path_relative_to_source = page.source_path.relative_to(
600+
self.root / "source"
601+
)
602+
603+
for target in targets:
604+
if page_path_relative_to_source.match(target):
605+
return True
606+
return False
607+
608+
def __call__(self, fileid_stack: FileIdStack, page: Page) -> None:
609+
"""Attach a banner as specified throughout project for target pages"""
610+
for banner in self.banners:
611+
if not self.__page_target_match(banner.targets, page, fileid_stack.current):
612+
continue
613+
614+
banner_parent = self.__find_target_insertion_node(page.ast)
615+
if isinstance(banner_parent, n.Parent):
616+
target_insertion = self.__determine_banner_index(banner_parent)
617+
assert banner_parent is not None
618+
banner_parent.children.insert(
619+
target_insertion, util.fast_deep_copy(banner.node)
620+
)
621+
622+
549623
class IAHandler:
550624
"""Identify IA directive on a page and save a list of its entries as a page-level option."""
551625

@@ -714,6 +788,9 @@ def run(
714788
tabs_selector_handler = TabsSelectorHandler(self.diagnostics)
715789
contents_handler = ContentsHandler(self.diagnostics)
716790
self.heading_handler = HeadingHandler(self.targets)
791+
banner_handler = BannerHandler(
792+
self.project_config.banner_nodes, self.project_config.root
793+
)
717794

718795
self.run_event_parser(
719796
[
@@ -732,6 +809,7 @@ def run(
732809
(EventParser.PAGE_START_EVENT, option_handler.reset),
733810
(EventParser.PAGE_START_EVENT, tabs_selector_handler.reset),
734811
(EventParser.PAGE_START_EVENT, contents_handler.reset),
812+
(EventParser.PAGE_START_EVENT, banner_handler),
735813
(EventParser.PAGE_END_EVENT, contents_handler.finalize_headings),
736814
(EventParser.PAGE_END_EVENT, tabs_selector_handler.finalize_tabsets),
737815
(EventParser.PAGE_END_EVENT, self.heading_handler.reset),
@@ -1365,7 +1443,6 @@ def clean_and_validate_page_group_slug(slug: str) -> Optional[str]:
13651443

13661444
if page_groups:
13671445
document.update({"pageGroups": page_groups})
1368-
13691446
return document, self.diagnostics
13701447

13711448
def reset_query_fields(self, fileid_stack: FileIdStack, page: Page) -> None:

snooty/rstspec.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ version = 0
44
[enum]
55
alignment = ["left", "center", "right"]
66
backlinks = ["entry", "top", "none"]
7+
banner_variant = ["info", "warning", "danger"]
78
card_layout = ["default", "carousel"]
89
card_style = ["default", "compact", "extra-compact"]
910
card_type = ["small", "large"]
@@ -614,6 +615,16 @@ content_type = "block"
614615
# """
615616

616617
##### Internal "mongodb" directives
618+
[directive."mongodb:banner"]
619+
help = """A skinny admonition, meant for alerts or smaller notifications."""
620+
content_type = "block"
621+
options.variant = "banner_variant"
622+
example = """.. banner::
623+
:variant: ${1: warning (Optional)}
624+
625+
${2:banner content}
626+
"""
627+
617628
[directive."mongodb:button"]
618629
help = "Make a button."
619630
example = """.. button:: ${1: string}

snooty/test_parser.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,34 @@ def test_admonition_deprecated() -> None:
784784
)
785785

786786

787+
def test_banner() -> None:
788+
path = ROOT_PATH.joinpath(Path("test.rst"))
789+
project_config = ProjectConfig(ROOT_PATH, "", source="./")
790+
parser = rstparser.Parser(project_config, JSONVisitor)
791+
792+
page, diagnostics = parse_rst(
793+
parser,
794+
path,
795+
"""
796+
.. banner::
797+
:variant: warning
798+
799+
Content
800+
""",
801+
)
802+
page.finish(diagnostics)
803+
assert diagnostics == []
804+
print(page.ast)
805+
check_ast_testing_string(
806+
page.ast,
807+
"""<root fileid="test.rst">
808+
<directive domain="mongodb" name="banner" variant="warning">
809+
<paragraph><text>Content</text></paragraph>
810+
</directive>
811+
</root>""",
812+
)
813+
814+
787815
def test_rst_replacement() -> None:
788816
path = ROOT_PATH.joinpath(Path("test.rst"))
789817
project_config = ProjectConfig(ROOT_PATH, "", source="./")

0 commit comments

Comments
 (0)