diff --git a/doc/configuration.toml b/doc/configuration.toml index fc0a516..87cec92 100644 --- a/doc/configuration.toml +++ b/doc/configuration.toml @@ -62,3 +62,11 @@ lang = "en" sitebase = "" # The site title. If supplied, this will be included in page metadata and used to formulate the default title. sitetitle = "" + +[extensions] +# Full classnames of the extensions to be loaded. +load = ["dragonglass.extensions.breadcrumbs.BreadcrumbExtension"] + +[extensions.BreadcrumbExtension] +# Configuration data for BreadcrumbExtension. +var = "value" diff --git a/doc/metadata-values.txt b/doc/metadata-values.txt index 84dff5c..d653d0b 100644 --- a/doc/metadata-values.txt +++ b/doc/metadata-values.txt @@ -16,6 +16,11 @@ tags (Obsidian standard metadata) 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 The file name of the template to be used to render this page, overriding the default. diff --git a/doc/template-vars.txt b/doc/template-vars.txt index 347f0f3..d96eeb1 100644 --- a/doc/template-vars.txt +++ b/doc/template-vars.txt @@ -4,6 +4,12 @@ backlinks: 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. +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: The filename of the default stylesheet which is generated by dragonglass and added to the generated pages. diff --git a/src/dragonglass/config.py b/src/dragonglass/config.py index bb3a7a1..e3c6ec2 100644 --- a/src/dragonglass/config.py +++ b/src/dragonglass/config.py @@ -8,6 +8,7 @@ from typing import Any import tomllib +from .extension import Extension from .tree import SourceIndex, SourceNode @@ -48,6 +49,33 @@ CALLOUT_ICONS = { 'todo': 'circle-check', '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: """ @@ -68,6 +96,7 @@ class Context: self.src_index: SourceIndex | None = None self.current_node: SourceNode | None = None self._default_template_name: str | None = None + self._extensions: list[Extension] = [] def load_config(self, args: Namespace) -> None: """ @@ -82,10 +111,18 @@ class Context: if config_path.exists() and config_path.is_file(): with open(config_path, "rb") as f: self.config = tomllib.load(f) + + # Load several base variables. templates_section = self.config.get("templates", {}) self.template_dir = self.source_dir / templates_section.get("directory", DEFAULT_TEMPLATE_DIRECTORY) 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 def default_stylesheet(self) -> str: """Returns the default stylesheet name.""" @@ -182,3 +219,36 @@ class Context: """ callout_icons = self.config.get('callout-icons', {}) 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 diff --git a/src/dragonglass/dragonglass.py b/src/dragonglass/dragonglass.py index bc29071..26e6758 100644 --- a/src/dragonglass/dragonglass.py +++ b/src/dragonglass/dragonglass.py @@ -81,8 +81,10 @@ def main() -> int: logger.info(f"Loading metadata for {node}") context.current_node = node node.load_metadata() + context.on_loaded_metadata(node) context.src_index = SourceIndex(nodes) + context.on_indexed(context.src_index) mdparse = create_markdown_parser(context) for node in nodes: diff --git a/src/dragonglass/extension.py b/src/dragonglass/extension.py new file mode 100644 index 0000000..d3f539a --- /dev/null +++ b/src/dragonglass/extension.py @@ -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 diff --git a/src/dragonglass/extensions/breadcrumbs.py b/src/dragonglass/extensions/breadcrumbs.py new file mode 100644 index 0000000..8a8a603 --- /dev/null +++ b/src/dragonglass/extensions/breadcrumbs.py @@ -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} diff --git a/src/dragonglass/template.py b/src/dragonglass/template.py index 8f2c236..7d61bed 100644 --- a/src/dragonglass/template.py +++ b/src/dragonglass/template.py @@ -4,7 +4,7 @@ import sys 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 .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. tvars['default_stylesheet'] = makepath(ctxt, node, ctxt.default_stylesheet) + # Augment template variables with the extensions. + ctxt.augment_template_vars(node, tvars) return tvars