created extensions mechanism and Breadcrumbs extension

This commit is contained in:
Amy G. Bowersox 2024-08-19 21:44:51 -06:00
parent 17a9ed1273
commit 13bdcf7f58
8 changed files with 236 additions and 1 deletions

View File

@ -62,3 +62,11 @@ lang = "en"
sitebase = "" sitebase = ""
# The site title. If supplied, this will be included in page metadata and used to formulate the default title. # The site title. If supplied, this will be included in page metadata and used to formulate the default title.
sitetitle = "" sitetitle = ""
[extensions]
# Full classnames of the extensions to be loaded.
load = ["dragonglass.extensions.breadcrumbs.BreadcrumbExtension"]
[extensions.BreadcrumbExtension]
# Configuration data for BreadcrumbExtension.
var = "value"

View File

@ -16,6 +16,11 @@ tags
(Obsidian standard metadata) (Obsidian standard metadata)
List of tags for this page. Tags may be defined here or inline in the text. List of tags for this page. Tags may be defined here or inline in the text.
parent
(Breadcrumbs extension)
Reference to the parent page of this one. May be an Obsidian link, i.e. "[[pagename|text]]".
If this page is considered a "root" of a breadcrumb hierarchy, use the value ".".
template template
The file name of the template to be used to render this page, overriding the default. The file name of the template to be used to render this page, overriding the default.

View File

@ -4,6 +4,12 @@ backlinks:
A list of pages that link to the page being rendered. Formatted as a list of dicts with A list of pages that link to the page being rendered. Formatted as a list of dicts with
two elements, "name" and "link", containing the page title and the link to it, respectively. two elements, "name" and "link", containing the page title and the link to it, respectively.
breadcrumbs:
(Breadcrumbs extension)
A list of pages forming the "breadcrumbs" from a root point to the page being rendered.
Formatted as a list of dicts with two elements, "name" and "link", containing the page title
and the link to it, respectively. The last element always has an empty "link" value.
default_stylesheet: default_stylesheet:
The filename of the default stylesheet which is generated by dragonglass and added to the The filename of the default stylesheet which is generated by dragonglass and added to the
generated pages. generated pages.

View File

@ -8,6 +8,7 @@ from typing import Any
import tomllib import tomllib
from .extension import Extension
from .tree import SourceIndex, SourceNode from .tree import SourceIndex, SourceNode
@ -48,6 +49,33 @@ CALLOUT_ICONS = {
'todo': 'circle-check', 'todo': 'circle-check',
'warning': 'triangle-alert' 'warning': 'triangle-alert'
} }
"""Cache of extension module names."""
EXTENSION_PKGS = {}
def load_extension_class(name: str, sect: dict[str, Any]) -> Extension:
"""
Load a new extension class.
Args:
name (str): Fully qualified classname of the extension to be loaded.
sect (dict[str, Any]): Reference to the [extensions] section of the configuration file.
Returns:
Extension: Instance of the extension class.
"""
components = name.split('.')
classname = components.pop()
modname = ".".join(components)
if modname not in EXTENSION_PKGS:
EXTENSION_PKGS[modname] = __import__(modname)
extclass = getattr(EXTENSION_PKGS[modname], classname)
cfg_sect = sect.get(classname, {})
rc = extclass(cfg_sect)
if not isinstance(rc, Extension):
raise RuntimeError(f"class {name} is not a valid Dragonglass extension")
return rc
class Context: class Context:
""" """
@ -68,6 +96,7 @@ class Context:
self.src_index: SourceIndex | None = None self.src_index: SourceIndex | None = None
self.current_node: SourceNode | None = None self.current_node: SourceNode | None = None
self._default_template_name: str | None = None self._default_template_name: str | None = None
self._extensions: list[Extension] = []
def load_config(self, args: Namespace) -> None: def load_config(self, args: Namespace) -> None:
""" """
@ -82,10 +111,18 @@ class Context:
if config_path.exists() and config_path.is_file(): if config_path.exists() and config_path.is_file():
with open(config_path, "rb") as f: with open(config_path, "rb") as f:
self.config = tomllib.load(f) self.config = tomllib.load(f)
# Load several base variables.
templates_section = self.config.get("templates", {}) templates_section = self.config.get("templates", {})
self.template_dir = self.source_dir / templates_section.get("directory", DEFAULT_TEMPLATE_DIRECTORY) self.template_dir = self.source_dir / templates_section.get("directory", DEFAULT_TEMPLATE_DIRECTORY)
self._default_template_name = templates_section.get("default", DEFAULT_TEMPLATE_NAME) self._default_template_name = templates_section.get("default", DEFAULT_TEMPLATE_NAME)
# Load the extensions.
extensions_section = self.config.get("extensions", {})
load_list = extensions_section.get("load", [])
for ext_name in load_list:
self._extensions.append(load_extension_class(ext_name, extensions_section))
@property @property
def default_stylesheet(self) -> str: def default_stylesheet(self) -> str:
"""Returns the default stylesheet name.""" """Returns the default stylesheet name."""
@ -182,3 +219,36 @@ class Context:
""" """
callout_icons = self.config.get('callout-icons', {}) callout_icons = self.config.get('callout-icons', {})
return callout_icons.get(callout, CALLOUT_ICONS.get(callout, callout_icons.get('_default', CALLOUT_DEFICON))) return callout_icons.get(callout, CALLOUT_ICONS.get(callout, callout_icons.get('_default', CALLOUT_DEFICON)))
def on_loaded_metadata(self, node: SourceNode) -> None:
"""
Call the "loaded_metadata" method of each extension.
Args:
node (SourceNode): Current node that's bneen processed.
"""
for ext in self._extensions:
ext.loaded_metadata(self, node)
def on_indexed(self, index: SourceIndex) -> None:
"""
Call the "indexed" method of each extension.
Args:
index (SourceIndex): The node index.
"""
for ext in self._extensions:
ext.indexed(self, index)
def augment_template_vars(self, node: SourceNode, variables: dict[str, Any]) -> None:
"""
Augment the template variables by checking extensions.
Args:
node (SourceNode): The current node about to be rendered.
variables (dict[str, Any]): The template variables being builkt for template rendering.
"""
for ext in self._extensions:
new_vars = ext.supply_template_vars(self, node)
if new_vars:
variables |= new_vars

View File

@ -81,8 +81,10 @@ def main() -> int:
logger.info(f"Loading metadata for {node}") logger.info(f"Loading metadata for {node}")
context.current_node = node context.current_node = node
node.load_metadata() node.load_metadata()
context.on_loaded_metadata(node)
context.src_index = SourceIndex(nodes) context.src_index = SourceIndex(nodes)
context.on_indexed(context.src_index)
mdparse = create_markdown_parser(context) mdparse = create_markdown_parser(context)
for node in nodes: for node in nodes:

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
from typing import Any
from .tree import SourceNode, SourceIndex
"""Base class for extensions to the conversion process."""
class Extension:
"""The base class for all dragonglass extensions."""
def __init__(self, config: dict[str, Any]):
"""
Initialize the extension.
Args:
config (dict[str, Any]): Configuration values for this extension.
"""
self._config = config
@staticmethod
def link_target(ctxt: Any, from_node: SourceNode, to_node: SourceNode) -> str:
"""
Produces a link target value from one node to another.
Args:
ctxt (Context): Context for the operation.
from_node (SourceNode): The node that is linking to another page.
to_node (SourceNode): The node being linked to.
Returns:
str: The correct link target.
"""
return to_node.link_target(ctxt.url_prefix, from_node if ctxt.relative_links else None)
def loaded_metadata(self, ctxt: Any, node: SourceNode) -> None:
"""
Called after the metadata for a node has been loaded. Should be overridden by derived classes.
Args:
ctxt (Context): Context for the operation.
node (SourceNode): Node that's just had its metadata loaded.
"""
pass
def indexed(self, ctxt: Any, index: SourceIndex) -> None:
"""
Called after the index for the nodes has been generated.
Args:
ctxt (Context): Context for the operation.
index (SourceIndex): The index to all nodes.
"""
pass
def supply_template_vars(self, ctxt: Any, node: SourceNode) -> dict[str, Any] | None:
"""
Called to supply additional template variables to the template to be rendered.
Args:
ctxt (Context): Context for the operation.
node (SourceNode): The node currently being processed for template rendering.
Returns:
dict[str, Any]: If not ``None``, contains additional template variables to be added before
the template is rendered.
"""
return None

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
import re
from typing import Any
from ..extension import Extension
from ..tree import SourceNode, SourceIndex
"""The extension for generating breadcrumb informatioon."""
class BreadcrumbExtension(Extension):
LINK = re.compile(r'^\[\[(.+)(?:\|.+)?\]\]')
def __init__(self, config: dict[str, Any]):
super(BreadcrumbExtension, self).__init__(config)
self._root_nodes: list[SourceNode] = []
self._raw_targets: list[tuple[SourceNode, str]] = []
self._parents: dict[SourceNode, SourceNode] = {}
def loaded_metadata(self, ctxt: Any, node: SourceNode) -> None:
"""
Called after the metadata for a node has been loaded. Should be overridden by derived classes.
Args:
ctxt (Context): Context for the operation.
node (SourceNode): Node that's just had its metadata loaded.
"""
if node.publish:
target_str = node.metadata.get("parent", None)
if target_str:
m = self.LINK.match(str(target_str))
if m:
target_str = m.group(1)
if target_str == '.':
self._root_nodes.append(node)
else:
self._raw_targets.append((node, target_str))
def indexed(self, ctxt: Any, index: SourceIndex) -> None:
"""
Called after the index for the nodes has been generated.
Args:
ctxt (Context): Context for the operation.
index (SourceIndex): The index to all nodes.
"""
for node, target in self._raw_targets:
target_node, _ = index.lookup(node, target)
if target_node:
self._parents[node] = target_node
def supply_template_vars(self, ctxt: Any, node: SourceNode) -> dict[str, Any] | None:
"""
Called to supply additional template variables to the template to be rendered.
Args:
ctxt (Context): Context for the operation.
node (SourceNode): The node currently being processed for template rendering.
Returns:
dict[str, Any]: If not ``None``, contains additional template variables to be added before
the template is rendered.
"""
breadcrumbs: list[dict[str, str]] = [{"name": "." if node in self._root_nodes else node.page_title,
"link": ""}]
cur_node = node
while cur_node not in self._root_nodes:
cur_node = self._parents.get(cur_node, None)
if not cur_node:
return None
breadcrumbs.insert(0, {"name": "." if cur_node in self._root_nodes else cur_node.page_title,
"link": self.link_target(ctxt, node, cur_node)})
return {"breadcrumbs": breadcrumbs}

View File

@ -4,7 +4,7 @@
import sys import sys
from jinja2 import Environment, BaseLoader, ChoiceLoader, FunctionLoader, FileSystemLoader from jinja2 import Environment, BaseLoader, ChoiceLoader, FunctionLoader, FileSystemLoader
from typing import Any, Callable from typing import Any
from urllib.parse import quote as urlquote from urllib.parse import quote as urlquote
from .config import Context, DEFAULT_TEMPLATE_NAME from .config import Context, DEFAULT_TEMPLATE_NAME
@ -152,6 +152,8 @@ def template_vars(node: SourceNode, ctxt: Context) -> dict[str, Any]:
# Add reference to the default stylesheet. # Add reference to the default stylesheet.
tvars['default_stylesheet'] = makepath(ctxt, node, ctxt.default_stylesheet) tvars['default_stylesheet'] = makepath(ctxt, node, ctxt.default_stylesheet)
# Augment template variables with the extensions.
ctxt.augment_template_vars(node, tvars)
return tvars return tvars