sphinx-hackerrank 2026.10.30.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sphinx_hackerrank/__init__.py +31 -0
- sphinx_hackerrank/builder.py +117 -0
- sphinx_hackerrank/html.py +54 -0
- sphinx_hackerrank/py.typed +0 -0
- sphinx_hackerrank/sections.py +182 -0
- sphinx_hackerrank/static/mini_map.css +36 -0
- sphinx_hackerrank/targets.py +49 -0
- sphinx_hackerrank-2026.10.30.1.dist-info/METADATA +185 -0
- sphinx_hackerrank-2026.10.30.1.dist-info/RECORD +11 -0
- sphinx_hackerrank-2026.10.30.1.dist-info/WHEEL +4 -0
- sphinx_hackerrank-2026.10.30.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Sphinx extension for portable HackerRank interviewer mini-maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from sphinx_hackerrank.builder import HackerRankBuilder
|
|
8
|
+
from sphinx_hackerrank.sections import MiniMapSection, discard_guide_cache, simplify_mini_map
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sphinx.application import Sphinx
|
|
12
|
+
|
|
13
|
+
__version__ = "2026.10.30.1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup(app: Sphinx) -> dict[str, object]:
|
|
17
|
+
"""Register the builder, directive, and project-independent settings."""
|
|
18
|
+
app.setup_extension("sphinx_jinja2")
|
|
19
|
+
app.setup_extension("sphinx_toolbox.collapse")
|
|
20
|
+
app.add_config_value("hackerrank_manifest_pattern", "**/*-mini-map.yml", "env", types=str)
|
|
21
|
+
app.add_config_value("hackerrank_languages", {}, "env", types=dict)
|
|
22
|
+
app.add_config_value("hackerrank_jinja_context", "languages", "env", types=str)
|
|
23
|
+
app.add_config_value("hackerrank_source_url", None, "html", types=(str, type(None)))
|
|
24
|
+
app.add_config_value("hackerrank_source_suffix", ".html", "html", types=str)
|
|
25
|
+
app.add_config_value("hackerrank_source_anchors", True, "html", types=bool)
|
|
26
|
+
app.add_config_value("hackerrank_exclude_admonitions", True, "html", types=bool)
|
|
27
|
+
app.add_builder(HackerRankBuilder)
|
|
28
|
+
app.add_directive("mini-map-section", MiniMapSection)
|
|
29
|
+
app.connect("doctree-read", discard_guide_cache)
|
|
30
|
+
app.connect("doctree-resolved", simplify_mini_map)
|
|
31
|
+
return {"version": __version__, "parallel_read_safe": True, "parallel_write_safe": True}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Build HackerRank fragments using Sphinx's standard HTML renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from sphinx.builders.html import StandaloneHTMLBuilder
|
|
10
|
+
|
|
11
|
+
from sphinx_hackerrank.html import fragment_path, render_fragment
|
|
12
|
+
from sphinx_hackerrank.targets import load_targets
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HackerRankBuilder(StandaloneHTMLBuilder):
|
|
19
|
+
"""Add standalone language fragments to the normal HTML output."""
|
|
20
|
+
|
|
21
|
+
name = "hackerrank"
|
|
22
|
+
format = "html"
|
|
23
|
+
|
|
24
|
+
def init(self) -> None:
|
|
25
|
+
"""Validate all question mappings before writing any output."""
|
|
26
|
+
super().init()
|
|
27
|
+
self.mini_map_targets = load_targets(
|
|
28
|
+
source_dir=Path(self.srcdir), pattern=self.config.hackerrank_manifest_pattern
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def get_outdated_docs(self) -> Iterator[str]:
|
|
32
|
+
"""Rebuild missing fragments and changed mappings or
|
|
33
|
+
presentation.
|
|
34
|
+
"""
|
|
35
|
+
self.mini_map_targets = load_targets(
|
|
36
|
+
source_dir=Path(self.srcdir), pattern=self.config.hackerrank_manifest_pattern
|
|
37
|
+
)
|
|
38
|
+
outdated = set(super().get_outdated_docs())
|
|
39
|
+
if not (Path(self.outdir) / "mini-maps" / "index.html").is_file():
|
|
40
|
+
outdated.update(self.mini_map_targets)
|
|
41
|
+
for docname, targets in self.mini_map_targets.items():
|
|
42
|
+
newest_dependency = max(
|
|
43
|
+
path.stat().st_mtime_ns
|
|
44
|
+
for path in (
|
|
45
|
+
targets[0].metadata_path,
|
|
46
|
+
Path(__file__),
|
|
47
|
+
Path(__file__).with_name(name="html.py"),
|
|
48
|
+
Path(__file__).parent / "static" / "mini_map.css",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
outputs = (
|
|
52
|
+
fragment_path(output_dir=Path(self.outdir), docname=docname, language=target.language)
|
|
53
|
+
for target in targets
|
|
54
|
+
)
|
|
55
|
+
if any(not output.is_file() or output.stat().st_mtime_ns < newest_dependency for output in outputs):
|
|
56
|
+
outdated.add(docname)
|
|
57
|
+
return iter(sorted(outdated))
|
|
58
|
+
|
|
59
|
+
def handle_page(
|
|
60
|
+
self,
|
|
61
|
+
pagename: str,
|
|
62
|
+
addctx: dict[str, Any],
|
|
63
|
+
templatename: str = "page.html",
|
|
64
|
+
*,
|
|
65
|
+
outfilename: Path | None = None,
|
|
66
|
+
event_arg: object = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Export the rendered body before the HTML theme adds chrome."""
|
|
69
|
+
for target in self.mini_map_targets.get(pagename, []):
|
|
70
|
+
output_path = fragment_path(output_dir=Path(self.outdir), docname=pagename, language=target.language)
|
|
71
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
output_path.write_text(
|
|
73
|
+
data=render_fragment(
|
|
74
|
+
html=addctx["body"],
|
|
75
|
+
language=target.language,
|
|
76
|
+
docname=pagename,
|
|
77
|
+
source_url=self.config.hackerrank_source_url,
|
|
78
|
+
source_suffix=self.config.hackerrank_source_suffix,
|
|
79
|
+
source_anchors=self.config.hackerrank_source_anchors,
|
|
80
|
+
),
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
super().handle_page(
|
|
84
|
+
pagename=pagename,
|
|
85
|
+
addctx=addctx,
|
|
86
|
+
templatename=templatename,
|
|
87
|
+
outfilename=outfilename,
|
|
88
|
+
event_arg=event_arg,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def finish(self) -> None:
|
|
92
|
+
"""Add a local preview index linking to each standalone mini-
|
|
93
|
+
map.
|
|
94
|
+
"""
|
|
95
|
+
super().finish()
|
|
96
|
+
preview_dir = Path(self.outdir) / "mini-maps"
|
|
97
|
+
preview_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
links = "\n".join(
|
|
99
|
+
f'<li><a href="{escape(s=docname.removesuffix("-mini-map"))}/{target.language}.html">'
|
|
100
|
+
f"{escape(s=target.name)}</a></li>"
|
|
101
|
+
for docname, targets in self.mini_map_targets.items()
|
|
102
|
+
for target in targets
|
|
103
|
+
)
|
|
104
|
+
(preview_dir / "index.html").write_text(
|
|
105
|
+
data=(
|
|
106
|
+
'<!doctype html>\n<html lang="en"><head><meta charset="utf-8">'
|
|
107
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
108
|
+
"<title>HackerRank mini-maps</title>"
|
|
109
|
+
"<style>body{font:16px/1.5 Arial,sans-serif;margin:2rem;max-width:60rem}"
|
|
110
|
+
"li{margin:.5rem 0}</style></head><body><main>"
|
|
111
|
+
"<h1>HackerRank mini-maps</h1>"
|
|
112
|
+
"<p>Choose a question and language to preview its interviewer mini-map.</p>"
|
|
113
|
+
"<p>These previews use the same HTML published to HackerRank interviewer notes.</p>"
|
|
114
|
+
f"<ul>{links}</ul></main></body></html>\n"
|
|
115
|
+
),
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Render standalone HTML without a theme or external assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fragment_path(*, output_dir: Path, docname: str, language: str) -> Path:
|
|
14
|
+
"""Locate a language fragment in a builder output directory."""
|
|
15
|
+
return output_dir / "mini-maps" / docname.removesuffix("-mini-map") / f"{language}.html"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_fragment(
|
|
19
|
+
*,
|
|
20
|
+
html: str,
|
|
21
|
+
language: str,
|
|
22
|
+
docname: str = "mini-map",
|
|
23
|
+
source_url: str | None = None,
|
|
24
|
+
source_suffix: str = ".html",
|
|
25
|
+
source_anchors: bool = True,
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Extract a language view, retaining exact code and portable links."""
|
|
28
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
29
|
+
containers = soup.select(f".mini-map-{language}")
|
|
30
|
+
if len(containers) != 1:
|
|
31
|
+
raise ValueError(f"Expected one mini-map for {language}, found {len(containers)}")
|
|
32
|
+
container = containers[0]
|
|
33
|
+
for link in container.select("a.headerlink"):
|
|
34
|
+
link.decompose()
|
|
35
|
+
output = fragment_path(output_dir=Path(), docname=docname, language=language)
|
|
36
|
+
for link in container.select("a[href]"):
|
|
37
|
+
url = urlsplit(str(link["href"]))
|
|
38
|
+
if not url.scheme and not url.netloc and url.path.endswith(".html"):
|
|
39
|
+
target = posixpath.normpath(posixpath.join(posixpath.dirname(docname), url.path))
|
|
40
|
+
if source_url is None:
|
|
41
|
+
destination = posixpath.relpath(target, output.parent.as_posix())
|
|
42
|
+
else:
|
|
43
|
+
destination = urljoin(source_url.rstrip("/") + "/", target.removesuffix(".html") + source_suffix)
|
|
44
|
+
if source_anchors and url.fragment:
|
|
45
|
+
destination += "#" + url.fragment
|
|
46
|
+
link["href"] = destination
|
|
47
|
+
for pre in container.find_all("pre"):
|
|
48
|
+
code = soup.new_tag("code")
|
|
49
|
+
code.string = pre.get_text()
|
|
50
|
+
pre.clear()
|
|
51
|
+
pre.append(code)
|
|
52
|
+
fragment = str(container).encode("ascii", errors="xmlcharrefreplace").decode("ascii")
|
|
53
|
+
style = files("sphinx_hackerrank").joinpath("static/mini_map.css").read_text(encoding="utf-8")
|
|
54
|
+
return f'<style type="text/css">{style}</style>\n{fragment}\n'
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Reuse parsed guide sections in language-specific Sphinx mini-maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import copy
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from docutils import nodes
|
|
10
|
+
from docutils.parsers.rst import Parser, directives
|
|
11
|
+
from docutils.transforms.references import (
|
|
12
|
+
AnonymousHyperlinks,
|
|
13
|
+
ExternalTargets,
|
|
14
|
+
IndirectHyperlinks,
|
|
15
|
+
InternalTargets,
|
|
16
|
+
PropagateTargets,
|
|
17
|
+
Substitutions,
|
|
18
|
+
)
|
|
19
|
+
from docutils.utils import new_document
|
|
20
|
+
from sphinx.transforms import DefaultSubstitutions
|
|
21
|
+
from sphinx.util.docutils import SphinxDirective
|
|
22
|
+
from sphinx_toolbox.collapse import CollapseNode, CollapseSummaryNode
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
from sphinx.application import Sphinx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def select_section(*, document: nodes.document, selector: str) -> nodes.section:
|
|
31
|
+
"""Resolve an exact heading path or a standard RST section label."""
|
|
32
|
+
sections = list(document.findall(condition=nodes.section))
|
|
33
|
+
if selector.startswith("#"):
|
|
34
|
+
label = nodes.fully_normalize_name(name=selector[1:])
|
|
35
|
+
matches = [section for section in sections if label in section["names"] or label in section["ids"]]
|
|
36
|
+
else:
|
|
37
|
+
parts = [part.strip() for part in selector.split(sep="/")]
|
|
38
|
+
matches = [section for section in sections if section[0].astext() == parts[0]]
|
|
39
|
+
for part in parts[1:]:
|
|
40
|
+
matches = [
|
|
41
|
+
child
|
|
42
|
+
for section in matches
|
|
43
|
+
for child in section.children
|
|
44
|
+
if isinstance(child, nodes.section) and child[0].astext() == part
|
|
45
|
+
]
|
|
46
|
+
if len(matches) != 1:
|
|
47
|
+
msg = f"Expected one guide section for {selector!r}, found {len(matches)}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
return matches[0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rubric(title: nodes.Node) -> nodes.rubric:
|
|
53
|
+
assert isinstance(title, nodes.title)
|
|
54
|
+
rubric = nodes.rubric(rawsource=title.rawsource)
|
|
55
|
+
rubric += title.deepcopy().children
|
|
56
|
+
return rubric
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def section_content(*, section: nodes.section, guide: str, language: str, include_heading: bool) -> list[nodes.Node]:
|
|
60
|
+
"""Copy selected nodes without importing the guide's anchors or
|
|
61
|
+
language selectors.
|
|
62
|
+
"""
|
|
63
|
+
container = nodes.container()
|
|
64
|
+
if include_heading:
|
|
65
|
+
container += _rubric(title=section[0])
|
|
66
|
+
container += [child.deepcopy() for child in section.children[1:]]
|
|
67
|
+
for subsection in list(container.findall(condition=nodes.section)):
|
|
68
|
+
subsection.replace_self(new=[_rubric(title=subsection[0]), *subsection.children[1:]])
|
|
69
|
+
for target in list(container.findall(condition=nodes.target)):
|
|
70
|
+
assert target.parent is not None
|
|
71
|
+
target.parent.remove(item=target)
|
|
72
|
+
for element in container.findall(condition=nodes.Element):
|
|
73
|
+
element["ids"] = []
|
|
74
|
+
element["names"] = []
|
|
75
|
+
if isinstance(element, nodes.reference) and "refid" in element:
|
|
76
|
+
element["refuri"] = f"{guide}.html#{element['refid']}"
|
|
77
|
+
del element["refid"]
|
|
78
|
+
for collapse in list(container.findall(condition=CollapseNode)):
|
|
79
|
+
if collapse["label"] == language:
|
|
80
|
+
collapse.replace_self(
|
|
81
|
+
new=[child for child in collapse.children if not isinstance(child, CollapseSummaryNode)]
|
|
82
|
+
)
|
|
83
|
+
return container.children
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MiniMapSection(SphinxDirective):
|
|
87
|
+
"""Include one parsed section from a maintained guide in one
|
|
88
|
+
language.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
required_arguments = 1
|
|
92
|
+
option_spec: ClassVar[dict[str, Callable[[str], Any]] | None] = {
|
|
93
|
+
"section": directives.unchanged_required,
|
|
94
|
+
"language": directives.unchanged_required,
|
|
95
|
+
"include-heading": directives.flag,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def run(self) -> list[nodes.Node]:
|
|
99
|
+
"""Select a guide section, preserving source files and dependency
|
|
100
|
+
tracking.
|
|
101
|
+
"""
|
|
102
|
+
source_root = Path(self.env.srcdir).resolve()
|
|
103
|
+
_relative, filename = self.env.relfn2path(filename=self.arguments[0])
|
|
104
|
+
source_path = Path(filename).resolve()
|
|
105
|
+
if not source_path.is_relative_to(source_root) or source_path.suffix != ".rst":
|
|
106
|
+
raise self.error(message="Mini-map source must be an RST guide within the source directory")
|
|
107
|
+
if source_path == Path(self.env.doc2path(docname=self.env.docname)).resolve():
|
|
108
|
+
raise self.error(message="A mini-map cannot include itself")
|
|
109
|
+
if "section" not in self.options or "language" not in self.options:
|
|
110
|
+
raise self.error(message="mini-map-section requires :section: and :language:")
|
|
111
|
+
language = self.config.hackerrank_languages.get(self.options["language"])
|
|
112
|
+
if language is None:
|
|
113
|
+
raise self.error(message=f"Expected one mini-map language config for {self.options['language']!r}")
|
|
114
|
+
self.env.note_dependency(filename=source_path)
|
|
115
|
+
cache: dict[tuple[str, str], nodes.document] = self.state.document.attributes.setdefault("_mini_map_guides", {})
|
|
116
|
+
key = (str(object=source_path), self.options["language"])
|
|
117
|
+
if key not in cache:
|
|
118
|
+
settings = copy(x=self.state.document.settings)
|
|
119
|
+
document = new_document(
|
|
120
|
+
source_path=str(object=self.env.doc2path(docname=self.env.docname)), settings=settings
|
|
121
|
+
)
|
|
122
|
+
contexts = self.config.jinja2_contexts
|
|
123
|
+
context_name = self.config.hackerrank_jinja_context
|
|
124
|
+
self.config.jinja2_contexts = {
|
|
125
|
+
**contexts,
|
|
126
|
+
context_name: {**contexts.get(context_name, {}), **language.get("context", {})},
|
|
127
|
+
}
|
|
128
|
+
try:
|
|
129
|
+
source = "\n\n".join(
|
|
130
|
+
part
|
|
131
|
+
for part in (
|
|
132
|
+
self.config.rst_prolog or "",
|
|
133
|
+
f".. include:: /{source_path.relative_to(source_root).as_posix()}",
|
|
134
|
+
self.config.rst_epilog or "",
|
|
135
|
+
)
|
|
136
|
+
if part
|
|
137
|
+
)
|
|
138
|
+
Parser().parse(inputstring=source, document=document)
|
|
139
|
+
for transform in (
|
|
140
|
+
DefaultSubstitutions,
|
|
141
|
+
Substitutions,
|
|
142
|
+
PropagateTargets,
|
|
143
|
+
AnonymousHyperlinks,
|
|
144
|
+
IndirectHyperlinks,
|
|
145
|
+
ExternalTargets,
|
|
146
|
+
InternalTargets,
|
|
147
|
+
):
|
|
148
|
+
transform(document=document).apply()
|
|
149
|
+
finally:
|
|
150
|
+
self.config.jinja2_contexts = contexts
|
|
151
|
+
cache[key] = document
|
|
152
|
+
try:
|
|
153
|
+
section = select_section(document=cache[key], selector=self.options["section"])
|
|
154
|
+
except ValueError as error:
|
|
155
|
+
raise self.error(message=str(object=error)) from error
|
|
156
|
+
content = section_content(
|
|
157
|
+
section=section,
|
|
158
|
+
guide=source_path.relative_to(source_root).with_suffix(suffix="").as_posix(),
|
|
159
|
+
language=language.get("name", self.options["language"]),
|
|
160
|
+
include_heading="include-heading" in self.options,
|
|
161
|
+
)
|
|
162
|
+
for root in content:
|
|
163
|
+
for node in root.findall():
|
|
164
|
+
node.document = self.state.document
|
|
165
|
+
return content
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def discard_guide_cache(_app: Sphinx, doctree: nodes.document) -> None:
|
|
169
|
+
"""Keep temporary language variants out of Sphinx's persisted doctrees."""
|
|
170
|
+
doctree.attributes.pop("_mini_map_guides", None)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def simplify_mini_map(_app: Sphinx, doctree: nodes.document, docname: str) -> None:
|
|
174
|
+
"""Remove admonitions from mini-map views after includes render."""
|
|
175
|
+
if not _app.config.hackerrank_exclude_admonitions:
|
|
176
|
+
return
|
|
177
|
+
containers = [node for node in doctree.findall(condition=nodes.container) if "hr-mini-map" in node["classes"]]
|
|
178
|
+
for container in containers:
|
|
179
|
+
for admonition in list(container.findall(condition=lambda node: isinstance(node, nodes.Admonition))):
|
|
180
|
+
parent = admonition.parent
|
|
181
|
+
assert parent is not None
|
|
182
|
+
parent.remove(item=admonition)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
.hr-mini-map {
|
|
2
|
+
font-family: Arial, sans-serif;
|
|
3
|
+
line-height: 1.5;
|
|
4
|
+
padding: 10px;
|
|
5
|
+
}
|
|
6
|
+
.hr-mini-map details {
|
|
7
|
+
margin-bottom: 12px;
|
|
8
|
+
}
|
|
9
|
+
.hr-mini-map summary {
|
|
10
|
+
cursor: pointer;
|
|
11
|
+
font-size: 16px;
|
|
12
|
+
font-weight: bold;
|
|
13
|
+
}
|
|
14
|
+
.hr-mini-map .rubric {
|
|
15
|
+
font-weight: bold;
|
|
16
|
+
margin-top: 1em;
|
|
17
|
+
}
|
|
18
|
+
.hr-mini-map pre {
|
|
19
|
+
background: #f5f5f5;
|
|
20
|
+
border: 1px solid #ddd;
|
|
21
|
+
color: #222;
|
|
22
|
+
overflow-x: auto;
|
|
23
|
+
padding: 10px;
|
|
24
|
+
white-space: pre;
|
|
25
|
+
}
|
|
26
|
+
.hr-mini-map table {
|
|
27
|
+
border-collapse: collapse;
|
|
28
|
+
width: 100%;
|
|
29
|
+
}
|
|
30
|
+
.hr-mini-map th,
|
|
31
|
+
.hr-mini-map td {
|
|
32
|
+
border: 1px solid #ddd;
|
|
33
|
+
padding: 8px;
|
|
34
|
+
text-align: left;
|
|
35
|
+
vertical-align: top;
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Read rendering metadata without depending on an API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Target:
|
|
14
|
+
"""One language and its interviewer-facing title."""
|
|
15
|
+
|
|
16
|
+
language: str
|
|
17
|
+
name: str
|
|
18
|
+
metadata_path: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_targets(*, source_dir: Path, pattern: str) -> dict[str, list[Target]]:
|
|
22
|
+
"""Read adjacent mapping files; publishing metadata stays with the consumer."""
|
|
23
|
+
targets: dict[str, list[Target]] = {}
|
|
24
|
+
for path in sorted(source_dir.glob(pattern)):
|
|
25
|
+
source = path.with_suffix(".rst")
|
|
26
|
+
if not source.is_file():
|
|
27
|
+
raise ValueError(f"Missing RST source beside {path}")
|
|
28
|
+
metadata = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
29
|
+
if not isinstance(metadata, dict) or metadata.get("version") != 1:
|
|
30
|
+
raise ValueError(f"Expected version: 1 in {path}")
|
|
31
|
+
questions = metadata.get("questions")
|
|
32
|
+
if not isinstance(questions, list) or not questions:
|
|
33
|
+
raise ValueError(f"Expected nonempty questions list in {path}")
|
|
34
|
+
document: list[Target] = []
|
|
35
|
+
seen: set[str] = set()
|
|
36
|
+
for question in questions:
|
|
37
|
+
if not isinstance(question, dict):
|
|
38
|
+
raise ValueError(f"Expected a question mapping in {path}")
|
|
39
|
+
language, name = question.get("language"), question.get("name")
|
|
40
|
+
if not isinstance(language, str) or re.fullmatch(r"[a-z][a-z0-9]*", language) is None:
|
|
41
|
+
raise ValueError(f"Invalid language in {path}: {language!r}")
|
|
42
|
+
if not isinstance(name, str) or not name.strip():
|
|
43
|
+
raise ValueError(f"Expected a question name in {path}")
|
|
44
|
+
if language in seen:
|
|
45
|
+
raise ValueError(f"Duplicate language {language!r} in {path}")
|
|
46
|
+
seen.add(language)
|
|
47
|
+
document.append(Target(language, name, path))
|
|
48
|
+
targets[source.relative_to(source_dir).with_suffix("").as_posix()] = document
|
|
49
|
+
return targets
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sphinx-hackerrank
|
|
3
|
+
Version: 2026.10.30.1
|
|
4
|
+
Summary: Build portable HackerRank interviewer mini-maps from Sphinx documentation
|
|
5
|
+
Project-URL: Source, https://github.com/adamtheturtle/sphinx-hackerrank
|
|
6
|
+
Project-URL: Issues, https://github.com/adamtheturtle/sphinx-hackerrank/issues
|
|
7
|
+
Author-email: Adam Dangoor <adamdangoor@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Sphinx :: Extension
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Requires-Dist: sphinx-jinja2>=0.0.1
|
|
20
|
+
Requires-Dist: sphinx-toolbox<5,>=4.3
|
|
21
|
+
Requires-Dist: sphinx<10,>=8.2
|
|
22
|
+
Description-Content-Type: text/x-rst
|
|
23
|
+
|
|
24
|
+
sphinx-hackerrank
|
|
25
|
+
=================
|
|
26
|
+
|
|
27
|
+
Build portable interviewer mini-maps from maintained Sphinx guides.
|
|
28
|
+
The ``hackerrank`` builder reuses Sphinx's HTML renderer and exports one
|
|
29
|
+
standalone HTML fragment per question and language, plus a local preview index.
|
|
30
|
+
No HackerRank login or API token is required to build or preview.
|
|
31
|
+
|
|
32
|
+
Installation
|
|
33
|
+
------------
|
|
34
|
+
|
|
35
|
+
.. code-block:: console
|
|
36
|
+
|
|
37
|
+
pip install sphinx-hackerrank
|
|
38
|
+
|
|
39
|
+
Built wheels are also published as GitHub release assets:
|
|
40
|
+
|
|
41
|
+
.. code-block:: console
|
|
42
|
+
|
|
43
|
+
pip install https://github.com/adamtheturtle/sphinx-hackerrank/releases/download/2026.10.30.1/sphinx_hackerrank-2026.10.30.1-py3-none-any.whl
|
|
44
|
+
|
|
45
|
+
Enable the installed extension in ``conf.py``:
|
|
46
|
+
|
|
47
|
+
.. code-block:: python
|
|
48
|
+
|
|
49
|
+
extensions = ["sphinx_hackerrank"]
|
|
50
|
+
|
|
51
|
+
No ``sys.path`` changes are needed.
|
|
52
|
+
|
|
53
|
+
Authoring
|
|
54
|
+
---------
|
|
55
|
+
|
|
56
|
+
Keep the full guide and its mini-map RST beside each other. Include native
|
|
57
|
+
sections by exact heading path or an existing RST section label:
|
|
58
|
+
|
|
59
|
+
.. code-block:: rst
|
|
60
|
+
|
|
61
|
+
.. container:: hr-mini-map mini-map-python
|
|
62
|
+
|
|
63
|
+
.. collapse:: Prompt
|
|
64
|
+
|
|
65
|
+
.. mini-map-section:: widgets.rst
|
|
66
|
+
:section: Part 1 / Prompt
|
|
67
|
+
:language: python
|
|
68
|
+
|
|
69
|
+
Use ``:section: #widget-prompt`` for a labelled section and
|
|
70
|
+
``:include-heading:`` to retain the selected section's heading.
|
|
71
|
+
Nested headings become rubrics inside disclosures. Missing or ambiguous
|
|
72
|
+
selections are reported as directive errors; build with ``-W`` to reject them.
|
|
73
|
+
The guide does not need extraction markers or duplicated content.
|
|
74
|
+
|
|
75
|
+
The directive uses native includes, substitutions, and the guide's existing
|
|
76
|
+
Jinja templates. Variants are cached within each mini-map read and discarded
|
|
77
|
+
before Sphinx persists the document tree. Guide and included-file dependencies
|
|
78
|
+
are tracked for incremental builds.
|
|
79
|
+
|
|
80
|
+
Language contexts
|
|
81
|
+
-----------------
|
|
82
|
+
|
|
83
|
+
Configure how each language renders the guide's Jinja context:
|
|
84
|
+
|
|
85
|
+
.. code-block:: python
|
|
86
|
+
|
|
87
|
+
hackerrank_jinja_context = "snippets"
|
|
88
|
+
hackerrank_languages = {
|
|
89
|
+
"python": {
|
|
90
|
+
"name": "Python",
|
|
91
|
+
"context": {"variants": [{"name": "Python", "lexer": "python"}]},
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
``context`` overrides values in the named Jinja context while that variant is
|
|
96
|
+
parsed. The original configuration is restored afterwards.
|
|
97
|
+
``name`` identifies redundant language disclosures to flatten in copied content.
|
|
98
|
+
All other part, category, and test-alternative disclosures remain intact.
|
|
99
|
+
|
|
100
|
+
By default, admonitions are omitted within ``hr-mini-map`` containers.
|
|
101
|
+
Set ``hackerrank_exclude_admonitions = False`` to retain them.
|
|
102
|
+
The original guide's admonitions remain unchanged.
|
|
103
|
+
|
|
104
|
+
Question mappings
|
|
105
|
+
-----------------
|
|
106
|
+
|
|
107
|
+
Create a mapping beside each mini-map, such as ``widgets-mini-map.yml`` beside
|
|
108
|
+
``widgets-mini-map.rst``:
|
|
109
|
+
|
|
110
|
+
.. code-block:: yaml
|
|
111
|
+
|
|
112
|
+
version: 1
|
|
113
|
+
questions:
|
|
114
|
+
- language: python
|
|
115
|
+
name: Widgets - Python
|
|
116
|
+
|
|
117
|
+
The default ``hackerrank_manifest_pattern`` is ``**/*-mini-map.yml``, relative
|
|
118
|
+
to Sphinx's source directory. Nested directories and custom source layouts are
|
|
119
|
+
supported. Extra fields such as question IDs can be managed by your publishing
|
|
120
|
+
tool; the builder reads only the language and name.
|
|
121
|
+
|
|
122
|
+
Build and preview
|
|
123
|
+
-----------------
|
|
124
|
+
|
|
125
|
+
.. code-block:: console
|
|
126
|
+
|
|
127
|
+
sphinx-build -W -b hackerrank docs build/hackerrank
|
|
128
|
+
|
|
129
|
+
Open ``build/hackerrank/mini-maps/index.html`` to select a question and language.
|
|
130
|
+
Each preview uses the same self-contained HTML that can be saved as HackerRank
|
|
131
|
+
interviewer notes. Native disclosure elements work without JavaScript.
|
|
132
|
+
The builder also produces ordinary themed HTML pages. The extension's directives
|
|
133
|
+
work in the standard ``html`` builder too.
|
|
134
|
+
|
|
135
|
+
Sphinx's native ``only`` directive can select ``builder_hackerrank``,
|
|
136
|
+
``builder_html``, or the shared ``html`` format tag.
|
|
137
|
+
|
|
138
|
+
Source links
|
|
139
|
+
------------
|
|
140
|
+
|
|
141
|
+
By default, links to full guide pages point back into the local HTML build.
|
|
142
|
+
To target a hosted documentation or source site, configure:
|
|
143
|
+
|
|
144
|
+
.. code-block:: python
|
|
145
|
+
|
|
146
|
+
hackerrank_source_url = "https://example.org/guides/"
|
|
147
|
+
hackerrank_source_suffix = ".html"
|
|
148
|
+
hackerrank_source_anchors = True
|
|
149
|
+
|
|
150
|
+
A source repository can use a different suffix, such as ``.rst``, and disable
|
|
151
|
+
anchors. External URLs and links within a fragment are retained.
|
|
152
|
+
|
|
153
|
+
Publishing
|
|
154
|
+
----------
|
|
155
|
+
|
|
156
|
+
Publishing is separate from rendering. This package does not call the HackerRank
|
|
157
|
+
API or manage credentials, question identities, candidate prompts, or projects.
|
|
158
|
+
Your publishing tool can read the generated fragments and verify saved notes.
|
|
159
|
+
|
|
160
|
+
Development
|
|
161
|
+
-----------
|
|
162
|
+
|
|
163
|
+
.. code-block:: console
|
|
164
|
+
|
|
165
|
+
uv run pytest --cov --cov-report=term-missing
|
|
166
|
+
uv run ruff check .
|
|
167
|
+
uv run ruff format --check .
|
|
168
|
+
uv build
|
|
169
|
+
uv run python tests/wheel_smoke.py
|
|
170
|
+
|
|
171
|
+
The minimal example under ``examples/minimal/manual`` uses a custom Jinja context
|
|
172
|
+
and nested guide files. The wheel smoke test installs the built wheel into a
|
|
173
|
+
fresh environment and builds that example outside the checkout.
|
|
174
|
+
|
|
175
|
+
To release, update the version in ``pyproject.toml`` and
|
|
176
|
+
``src/sphinx_hackerrank/__init__.py``, then publish a matching GitHub release.
|
|
177
|
+
The release workflow builds and checks distributions before uploading with a
|
|
178
|
+
PyPI trusted publisher configured for ``release.yml`` and environment ``release``.
|
|
179
|
+
Set the repository variable ``PYPI_PUBLISH_ENABLED`` to ``true`` after configuring
|
|
180
|
+
the publisher. The workflow can also be dispatched with a release ``tag``.
|
|
181
|
+
|
|
182
|
+
License
|
|
183
|
+
-------
|
|
184
|
+
|
|
185
|
+
MIT. See ``LICENSE``.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sphinx_hackerrank/__init__.py,sha256=XsYjow15EjBkJqZLZOwG_aJ8KyKvifbBa8grB_sbHZo,1482
|
|
2
|
+
sphinx_hackerrank/builder.py,sha256=penMdbLG6GUYS6GqVYx4wzkVJs7ybLR2RyO7vGwynDw,4698
|
|
3
|
+
sphinx_hackerrank/html.py,sha256=3tXAL65GkkXsOidB8pWPA4FdVFPDWPY2sgBNnkqMzSc,2246
|
|
4
|
+
sphinx_hackerrank/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sphinx_hackerrank/sections.py,sha256=fhgIY3ZgaIUanBrauXnTONk__-kcimVEKFout48R180,7913
|
|
6
|
+
sphinx_hackerrank/targets.py,sha256=miSc8BtCa4IWt6Tb8Sxh92Iyp1XDXDueTnyH6CLG1mE,2044
|
|
7
|
+
sphinx_hackerrank/static/mini_map.css,sha256=i61odQc_pvKDCtVcHEEVs6UoP9q7I3qbysgx8UzN-ow,616
|
|
8
|
+
sphinx_hackerrank-2026.10.30.1.dist-info/METADATA,sha256=iaa9jzcTpwStOdE20oAB2vlZ-T2R9tLkErcOApLrczk,6296
|
|
9
|
+
sphinx_hackerrank-2026.10.30.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
sphinx_hackerrank-2026.10.30.1.dist-info/licenses/LICENSE,sha256=hvkADUCIndXpdC1L7yhnIKS99N0Wdtaf9YF06bRgZCo,1065
|
|
11
|
+
sphinx_hackerrank-2026.10.30.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rippling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|