mirror of
https://github.com/TeachBooks/manual.git
synced 2026-11-12 22:35:51 +00:00
Add sphinx_gated_directives extension and code-align CSS
Introduces the custom Sphinx extension 'sphinx_gated_directives' for start/end directive gating and validation, registering it in the Sphinx local extensions. Also adds a CSS file to enforce left alignment for code cells. Updates _config.yml to configure the new extension and set the documentation language to English.
This commit is contained in:
parent
7533ef1c0b
commit
c7486c0b42
3 changed files with 527 additions and 2 deletions
|
|
@ -11,15 +11,16 @@ html:
|
|||
baseurl : "https://teachbooks.io/manual/"
|
||||
|
||||
sphinx:
|
||||
local_extensions:
|
||||
sphinx_gated_directives: _ext
|
||||
extra_extensions:
|
||||
- sphinx_inline_tabs
|
||||
- sphinx.ext.imgconverter
|
||||
- teachbooks_favourites
|
||||
- teachbooks_sphinx_grasple
|
||||
- sphinx_apa_references
|
||||
- sphinx_github_alerts
|
||||
- sphinx_metadata_figure
|
||||
config:
|
||||
language: en
|
||||
html_js_files:
|
||||
- https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js
|
||||
thebe_config:
|
||||
|
|
|
|||
520
book/_ext/sphinx_gated_directives/__init__.py
Normal file
520
book/_ext/sphinx_gated_directives/__init__.py
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
# startify_directives_all.py
|
||||
"""
|
||||
Create a '-start' companion for every registered directive (class-based).
|
||||
|
||||
Rules:
|
||||
- Skip originals whose name already ends with '-start' or '-end'.
|
||||
- Skip if '<name>-start' already exists.
|
||||
- Class-based originals -> register a subclass calling original run().
|
||||
|
||||
Usage:
|
||||
extensions = ["startify_directives_all"]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from docutils.nodes import Element
|
||||
from typing import Iterator
|
||||
import re
|
||||
import copy
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Callable, Dict, Iterable, List, Optional
|
||||
from sphinx.transforms import SphinxTransform
|
||||
from sphinx.application import Sphinx
|
||||
from sphinx.environment import BuildEnvironment
|
||||
|
||||
from sphinx.util import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from docutils import nodes
|
||||
from docutils.parsers.rst import Directive
|
||||
from docutils.parsers.rst import directives as du_directives
|
||||
|
||||
SUFFIX_START = "-start"
|
||||
SUFFIX_END = "-end"
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Helpers: identification
|
||||
# ---------------------------
|
||||
|
||||
def _is_class_directive(obj) -> bool:
|
||||
return inspect.isclass(obj) and issubclass(obj, Directive)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Helpers: registry access
|
||||
# ---------------------------
|
||||
|
||||
def _get_unified_registry(app=None) -> Dict[str, object]:
|
||||
"""
|
||||
Build a unified {name -> implementation} mapping of directives.
|
||||
|
||||
Sources:
|
||||
1) docutils.parsers.rst.directives._directives (loaded/added so far)
|
||||
2) docutils.parsers.rst.directives._directive_registry (lazy map of
|
||||
name -> (module, class) for builtins); import any missing ones.
|
||||
3) Sphinx app.registry (if provided) for extension-registered directives
|
||||
"""
|
||||
# 1) Already-loaded directives (includes Sphinx & extensions registered via add_directive)
|
||||
unified: Dict[str, object] = dict(getattr(du_directives, "_directives", {}))
|
||||
|
||||
# 2) Ensure Docutils built-ins are present by importing from _directive_registry
|
||||
reg = getattr(du_directives, "_directive_registry", {})
|
||||
for name, (modname, clsname) in reg.items():
|
||||
if name in unified:
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(f"docutils.parsers.rst.directives.{modname}")
|
||||
obj = getattr(mod, clsname, None)
|
||||
if obj is not None:
|
||||
unified[name] = obj
|
||||
except Exception:
|
||||
# Be defensive: if an import fails, just skip that name
|
||||
continue
|
||||
|
||||
# 3) Add Sphinx's registry if app is provided
|
||||
if app is not None:
|
||||
# Sphinx stores directives in app.registry
|
||||
# Try different ways to access the directives mapping
|
||||
if hasattr(app.registry, 'directives'):
|
||||
# Newer Sphinx versions may have a directives attribute
|
||||
try:
|
||||
unified.update(app.registry.directives)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
# Also check for domains which contain directives (e.g., prf:theorem from sphinx-proof)
|
||||
if hasattr(app.registry, 'domains'):
|
||||
for domain_name, domain_cls in app.registry.domains.items():
|
||||
try:
|
||||
# Instantiate domain or use class attributes
|
||||
if hasattr(domain_cls, 'directives'):
|
||||
domain_directives = domain_cls.directives
|
||||
for dir_name, dir_impl in domain_directives.items():
|
||||
# Fully qualified name: domain:directive
|
||||
full_name = f"{domain_name}:{dir_name}"
|
||||
if full_name not in unified:
|
||||
unified[full_name] = dir_impl
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return unified
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Helpers: class generation
|
||||
# ---------------------------
|
||||
|
||||
def _copy_option_spec(option_spec):
|
||||
return copy.copy(option_spec) if isinstance(option_spec, dict) else option_spec
|
||||
|
||||
def make_startified_class(
|
||||
orig_name: str,
|
||||
base_cls: type[Directive],
|
||||
) -> type[Directive]:
|
||||
"""
|
||||
Subclass `base_cls` to call original run().
|
||||
"""
|
||||
attrs = {}
|
||||
for attr in (
|
||||
"required_arguments",
|
||||
"optional_arguments",
|
||||
"final_argument_whitespace",
|
||||
"has_content",
|
||||
"option_spec",
|
||||
):
|
||||
if hasattr(base_cls, attr):
|
||||
val = getattr(base_cls, attr)
|
||||
if attr == "option_spec":
|
||||
attrs[attr] = _copy_option_spec(val) or {}
|
||||
else:
|
||||
attrs[attr] = val
|
||||
|
||||
def run(self: Directive):
|
||||
current_name = self.name
|
||||
self.name = orig_name # temporarily set to original for base run()
|
||||
children = base_cls.run(self)
|
||||
self.name = current_name # restore
|
||||
if not isinstance(children, list):
|
||||
if isinstance(children, Iterable):
|
||||
children = list(children)
|
||||
else:
|
||||
children = [children]
|
||||
# create a start_node and add all result nodes as its children
|
||||
start_node_instance = start_node()
|
||||
start_node_instance += children
|
||||
result = [start_node_instance]
|
||||
|
||||
# Get environment from state.document.settings
|
||||
env = getattr(self.state.document.settings, "env", None)
|
||||
if env is not None:
|
||||
# 1) Check whether main registry is already created, if not, create it
|
||||
if not hasattr(env, "sphinx_gated_directives_registry"):
|
||||
env.sphinx_gated_directives_registry = {}
|
||||
# 2) Check whether a sub-registry for current directive type is already created, if not, create it
|
||||
if orig_name not in env.sphinx_gated_directives_registry:
|
||||
env.sphinx_gated_directives_registry[orig_name] = {}
|
||||
# 3) Register current usage in the registry for this doc and this type
|
||||
gated_registry = env.sphinx_gated_directives_registry[orig_name]
|
||||
docname = env.docname
|
||||
if docname not in gated_registry:
|
||||
gated_registry[docname] = {
|
||||
"start": [],
|
||||
"end": [],
|
||||
"sequence": [],
|
||||
"msg": [],
|
||||
}
|
||||
gated_registry[docname]["start"].append(self.lineno)
|
||||
gated_registry[docname]["sequence"].append("S")
|
||||
gated_registry[docname]["msg"].append(
|
||||
f"{self.name} at line: {self.lineno}"
|
||||
)
|
||||
# 4) Check whether super registry has been created, if not, create it
|
||||
if not hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
env.sphinx_gated_directives_super_registry = {}
|
||||
# 5) Register current usage in the super registry
|
||||
super_registry = env.sphinx_gated_directives_super_registry
|
||||
if docname not in super_registry:
|
||||
super_registry[docname] = {
|
||||
"start": [],
|
||||
"end": [],
|
||||
"sequence": [],
|
||||
"msg": [],
|
||||
"type": [],
|
||||
}
|
||||
super_registry[docname]["start"].append(self.lineno)
|
||||
super_registry[docname]["sequence"].append("S")
|
||||
super_registry[docname]["msg"].append(
|
||||
f"{self.name} at line: {self.lineno}"
|
||||
)
|
||||
super_registry[docname]["type"].append(orig_name)
|
||||
|
||||
return result
|
||||
|
||||
attrs["run"] = run
|
||||
new_cls_name = f"{base_cls.__name__}_Start_For_{orig_name.replace(':', '_')}"
|
||||
return type(new_cls_name, (base_cls,), attrs)
|
||||
|
||||
def make_endified_class(
|
||||
orig_name: str,
|
||||
base_cls: type[Directive],
|
||||
) -> type[Directive]:
|
||||
"""
|
||||
Just create a subclass to obtain an end node.
|
||||
"""
|
||||
attrs = {}
|
||||
for attr in (
|
||||
"required_arguments",
|
||||
"optional_arguments",
|
||||
"final_argument_whitespace",
|
||||
"has_content",
|
||||
"option_spec",
|
||||
):
|
||||
if hasattr(base_cls, attr):
|
||||
val = getattr(base_cls, attr)
|
||||
if attr == "option_spec":
|
||||
attrs[attr] = _copy_option_spec(val) or {}
|
||||
else:
|
||||
attrs[attr] = val
|
||||
|
||||
def run(self: Directive):
|
||||
|
||||
# Get environment from state.document.settings
|
||||
env = getattr(self.state.document.settings, "env", None)
|
||||
if env is not None:
|
||||
# 1) Check whether main registry is already created, if not, create it
|
||||
if not hasattr(env, "sphinx_gated_directives_registry"):
|
||||
env.sphinx_gated_directives_registry = {}
|
||||
# 2) Check whether a sub-registry for current directive type is already created, if not, create it
|
||||
if orig_name not in env.sphinx_gated_directives_registry:
|
||||
env.sphinx_gated_directives_registry[orig_name] = {}
|
||||
# 3) Register current usage in the registry for this doc and this type
|
||||
gated_registry = env.sphinx_gated_directives_registry[orig_name]
|
||||
docname = env.docname
|
||||
if docname not in gated_registry:
|
||||
gated_registry[docname] = {
|
||||
"start": [],
|
||||
"end": [],
|
||||
"sequence": [],
|
||||
"msg": [],
|
||||
}
|
||||
gated_registry[docname]["end"].append(self.lineno)
|
||||
gated_registry[docname]["sequence"].append("E")
|
||||
gated_registry[docname]["msg"].append(
|
||||
f"{self.name} at line: {self.lineno}"
|
||||
)
|
||||
# 4) Check whether super registry has been created, if not, create it
|
||||
if not hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
env.sphinx_gated_directives_super_registry = {}
|
||||
# 5) Register current usage in the super registry
|
||||
super_registry = env.sphinx_gated_directives_super_registry
|
||||
if docname not in super_registry:
|
||||
super_registry[docname] = {
|
||||
"start": [],
|
||||
"end": [],
|
||||
"sequence": [],
|
||||
"msg": [],
|
||||
"type": [],
|
||||
}
|
||||
super_registry[docname]["end"].append(self.lineno)
|
||||
super_registry[docname]["sequence"].append("E")
|
||||
super_registry[docname]["msg"].append(
|
||||
f"{self.name} at line: {self.lineno}"
|
||||
)
|
||||
super_registry[docname]["type"].append(orig_name)
|
||||
|
||||
# return []
|
||||
# next line should be good enough, but raises an error as not all has been implemented
|
||||
# maybe we need end nodes per directive type?
|
||||
return [end_node()]
|
||||
|
||||
attrs["run"] = run
|
||||
new_cls_name = f"{base_cls.__name__}_End_For_{orig_name.replace(':', '_')}"
|
||||
return type(new_cls_name, (base_cls,), attrs)
|
||||
|
||||
# ---------------------------
|
||||
# Core logic
|
||||
# ---------------------------
|
||||
|
||||
def _should_skip_name(orig_name: str) -> bool:
|
||||
return orig_name.endswith(SUFFIX_START) or orig_name.endswith(SUFFIX_END)
|
||||
|
||||
def _register_startified_directives(app, env, docnames):
|
||||
unified = _get_unified_registry(app)
|
||||
snapshot_names = set(unified.keys())
|
||||
added = 0
|
||||
|
||||
for orig_name, obj in sorted(unified.items()):
|
||||
if _should_skip_name(orig_name):
|
||||
continue
|
||||
|
||||
new_name = f"{orig_name}{SUFFIX_START}"
|
||||
if new_name in snapshot_names:
|
||||
continue
|
||||
|
||||
try:
|
||||
if _is_class_directive(obj):
|
||||
start_cls = make_startified_class(orig_name, obj)
|
||||
app.add_directive(new_name, start_cls)
|
||||
end_cls = make_endified_class(orig_name, Directive) # because it does generate anything, most simple directive
|
||||
app.add_directive(f"{orig_name}{SUFFIX_END}", end_cls)
|
||||
added += 1
|
||||
else:
|
||||
logger.debug(f"[startify] '{orig_name}' is not a class directive; skipping.")
|
||||
except Exception as e:
|
||||
logger.warning(f"[startify] failed to register '{new_name}': {e}")
|
||||
|
||||
def setup(app):
|
||||
|
||||
# register function to perge registries at start of build per document
|
||||
app.connect("env-purge-doc", purge_registries)
|
||||
|
||||
# At the latest possible moment, register startified directives (also creates the endified ones)
|
||||
app.connect("env-before-read-docs", _register_startified_directives,priority=10000000000000000000000000000)
|
||||
|
||||
# Register nodes, used as placeholder for end directives
|
||||
# will be resolved in the transforms to something meaningful
|
||||
# during merging of start-end blocks
|
||||
app.add_node(start_node)
|
||||
app.add_node(end_node)
|
||||
|
||||
# Register a transform to check validity of start-end pairs
|
||||
app.add_transform(CheckGatedDirectivesTransform)
|
||||
|
||||
# Register a transform to merge start-end pairs into gated content
|
||||
app.add_transform(MergeGatedDirectivesTransform)
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"parallel_read_safe": True,
|
||||
}
|
||||
|
||||
# Node classes for start and end directives
|
||||
|
||||
class start_node(nodes.Admonition, nodes.Element):
|
||||
pass
|
||||
|
||||
class end_node(nodes.Admonition, nodes.Element):
|
||||
pass
|
||||
|
||||
# purge_registry
|
||||
def purge_registries(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
|
||||
|
||||
if hasattr(env, "sphinx_gated_directives_registry") or hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
# only perge if needed
|
||||
if hasattr(env, "sphinx_gated_directives_registry"):
|
||||
registry = env.sphinx_gated_directives_registry
|
||||
for directive_name in registry:
|
||||
if docname in registry[directive_name]:
|
||||
del registry[directive_name][docname]
|
||||
if hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
super_registry = env.sphinx_gated_directives_super_registry
|
||||
if docname in super_registry:
|
||||
del super_registry[docname]
|
||||
|
||||
# Transform to check validity of start-end pairs
|
||||
class CheckGatedDirectivesTransform(SphinxTransform):
|
||||
default_priority = 1
|
||||
|
||||
def apply(self, **kwargs):
|
||||
env = self.env
|
||||
if not hasattr(env, "sphinx_gated_directives_registry"):
|
||||
return
|
||||
if not hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
return
|
||||
|
||||
# First check the super registry for a correct structure
|
||||
# demands:
|
||||
# 1) equal number of start and end directives
|
||||
# 2) no nesting of start-end pairs (so SSEE is not allowed, only SESE)
|
||||
super_registry = env.sphinx_gated_directives_super_registry
|
||||
error = False
|
||||
docname = self.env.docname
|
||||
if docname in super_registry:
|
||||
start = super_registry[docname]["start"]
|
||||
end = super_registry[docname]["end"]
|
||||
structure = "\n ".join(super_registry[docname]["msg"])
|
||||
sequence = "".join(super_registry[docname]["sequence"])
|
||||
groups = re.findall("(SE)", sequence)
|
||||
if len(start) > len(end):
|
||||
msg = f"The document {docname} contains more start directives than end directives:\n {structure}\nPlease ensure each start directive has a corresponding end directive."
|
||||
logger.error(msg)
|
||||
error = True
|
||||
elif len(end) > len(start):
|
||||
msg = f"The document {docname} contains more end directives than start directives:\n {structure}\nPlease ensure each end directive has a corresponding start directive."
|
||||
logger.error(msg)
|
||||
error = True
|
||||
# at this point, len(start) == len(end)
|
||||
elif len(groups) != len(start) or len(groups) != len(end):
|
||||
msg = f"The document {docname} contains nested start and end directives:\n {structure}\nThis is not allowed. Please correct the nesting."
|
||||
logger.error(msg)
|
||||
error = True
|
||||
else:
|
||||
# At this point, every start is followed by an end.
|
||||
# Now check that types match in order.
|
||||
types = super_registry[docname]["type"]
|
||||
start_type = types[::2]
|
||||
end_type = types[1::2]
|
||||
for i in range(len(start_type)):
|
||||
if start_type[i] != end_type[i]:
|
||||
msg = f"The document {docname} contains mismatched start and end directives at lines {start[i]} and {end[i]}:\n {structure}\nPlease ensure that start and end directives match in type."
|
||||
logger.error(msg)
|
||||
error = True
|
||||
|
||||
if error:
|
||||
raise Exception(f"[sphinx-gated-directives] An error has occurred when parsing gated directives in {docname}.\nPlease check warning messages above.")
|
||||
|
||||
# no check per directive type is needed if no error in super registry is raised,
|
||||
# as the super registry already ensures correctness for each type.
|
||||
# if nesting is allowed in the future, this part needs to be re-implemented.
|
||||
|
||||
|
||||
class MergeGatedDirectivesTransform(SphinxTransform):
|
||||
default_priority = 10
|
||||
|
||||
def apply(self, **kwargs):
|
||||
env = self.env
|
||||
# Something to do here
|
||||
# idea:
|
||||
# 1) Find all start_nodes
|
||||
# 2) for each start_node, find the corresponding end_node (i.e. next end_node)
|
||||
# 3) collect all nodes in between
|
||||
# 4) Take the first node after start_node, and add all other collected nodes as its children
|
||||
if not hasattr(env, "sphinx_gated_directives_super_registry"):
|
||||
return
|
||||
super_registry = env.sphinx_gated_directives_super_registry
|
||||
docname = self.env.docname
|
||||
if docname not in super_registry:
|
||||
return
|
||||
|
||||
# Each start-end pair is valid (checked before), so we can proceed to merge
|
||||
# find all start nodes, as an iterator
|
||||
# for each start node, find the next end node within the parent of start node
|
||||
start_nodes = findall(self.document, start_node)
|
||||
for start_n in start_nodes:
|
||||
parent = start_n.parent
|
||||
# find the next end_node after start_n in parent
|
||||
found_start = False
|
||||
end_n = None
|
||||
for child in parent.children:
|
||||
if child is start_n:
|
||||
found_start = True
|
||||
elif found_start and isinstance(child, end_node):
|
||||
end_n = child
|
||||
break
|
||||
if end_n is None:
|
||||
continue # should not happen due to prior checks
|
||||
|
||||
# collect all nodes between start_n and end_n
|
||||
start_index = parent.children.index(start_n)
|
||||
end_index = parent.children.index(end_n)
|
||||
between_nodes = parent.children[start_index + 1:end_index]
|
||||
|
||||
# print the nodes for debugging
|
||||
logger.info(f"{start_n.pformat()}",color="green")
|
||||
if between_nodes:
|
||||
for bn in between_nodes:
|
||||
logger.info(f"{bn.pformat()}",color="yellow")
|
||||
logger.info(f"{end_n.pformat()}",color="red")
|
||||
|
||||
# We have to merge, but how is the question.
|
||||
# we handle based on "experience" and "observation" of what kind of nodes are generated
|
||||
# step 1: create a new container node to hold the merged content
|
||||
# content of start_node
|
||||
new_nodes = start_n.children
|
||||
# add content in between, depending on content in start_n
|
||||
if between_nodes:
|
||||
has_section = False
|
||||
has_caption = False
|
||||
si = -1
|
||||
content = new_nodes[-1] # we assume that the last child of start_n holds the main content
|
||||
for sn in content.children:
|
||||
si += 1
|
||||
if isinstance(sn, nodes.section):
|
||||
has_section = True # So probably a topic or similar (from sphinx-proof)
|
||||
section_index = si
|
||||
section_node = sn
|
||||
break
|
||||
elif isinstance(sn, nodes.caption):
|
||||
has_caption = True # So probably a figure or similar
|
||||
caption_index = si
|
||||
caption_node = sn
|
||||
break
|
||||
if has_section:
|
||||
# if there are sections, we add all between nodes to this section
|
||||
content[section_index] += between_nodes
|
||||
elif has_caption:
|
||||
# if there is a caption, we add all between nodes before the caption
|
||||
n = len(content.children)
|
||||
pos = n - 1
|
||||
for i, bn in enumerate(between_nodes):
|
||||
content.insert(pos + i, bn) # insert sets parent/doc links
|
||||
else:
|
||||
logger.info(f"Found no caption and no section node in start_node content.",color="blue")
|
||||
# otherwise, we add all between nodes to the main content directly\
|
||||
for bn in between_nodes:
|
||||
content += bn # add sets parent/doc links
|
||||
logger.info(f"New nodes after merging:",color="blue")
|
||||
for nn in new_nodes:
|
||||
logger.info(f"{nn.pformat()}",color="blue")
|
||||
|
||||
# finally, replace start_n with new_nodes, and remove end_n and between_nodes
|
||||
start_pos = parent.children.index(start_n)
|
||||
# remove end_n
|
||||
parent.remove(end_n)
|
||||
# remove start_n
|
||||
parent.remove(start_n)
|
||||
# remove between_nodes
|
||||
for bn in between_nodes:
|
||||
parent.remove(bn)
|
||||
# insert new_nodes at start_pos
|
||||
for i, nn in enumerate(new_nodes):
|
||||
parent.insert(start_pos + i, nn) # insert new structure
|
||||
|
||||
|
||||
def findall(node: Element, *args, **kwargs) -> Iterator[Element]:
|
||||
# findall replaces traverse in docutils v0.18
|
||||
# note a difference is that findall is an iterator
|
||||
impl = getattr(node, "findall", node.traverse)
|
||||
return iter(impl(*args, **kwargs))
|
||||
4
book/_static/code-align.css
Normal file
4
book/_static/code-align.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/* this to enforce left alignment for docutils content (i.e. code cells) */
|
||||
div.cell.docutils.container {
|
||||
text-align: left !important;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue