Pi66

Home | Repos | About | Blog | Gaza

merodi
A markdown-based static site generator.
git clone https://git.pi66.xyz/merodi

About | Files | Commits | Refs | License


feat: add plugin hook system across build, config, hash, and watcher pipelines

Author: pi66
Date: 2026-07-17 23:53
commit: 2b7e3fc7

Stat

M src/build.py 58 +39 -19
M src/config.py 17 +10 -7
A src/hash.py 3 +3 -0
A src/hooks.py 58 +58 -0
A src/watcher.py 6 +6 -0

Diff

diff --git a/src/build.py b/src/build.py
index 2f17d3d..6063035 100644
--- a/src/build.py
+++ b/src/build.py
@@ -2,7 +2,8 @@ import re, sys
 import importlib.util
 import latex2mathml.converter

-from src.hash import handle_hash_sync +from .hooks import hook, hook_call, hooks +from .hash import handle_hash_sync

from jinja2 import Environment, FileSystemLoader from markdown import markdown @@ -13,6 +14,7 @@ from .config import find_project_from_path, load_config from .errors import fatal, html_fatal from .fileops import is_dotfile, read_file, write_file from .log import GRAY, die, info, warn +from . import log

...
Show all
diff --git a/src/build.py b/src/build.py
index 2f17d3d..6063035 100644
--- a/src/build.py
+++ b/src/build.py
@@ -2,7 +2,8 @@ import re, sys
 import importlib.util
 import latex2mathml.converter

-from src.hash import handle_hash_sync +from .hooks import hook, hook_call, hooks +from .hash import handle_hash_sync

from jinja2 import Environment, FileSystemLoader from markdown import markdown @@ -13,6 +14,7 @@ from .config import find_project_from_path, load_config from .errors import fatal, html_fatal from .fileops import is_dotfile, read_file, write_file from .log import GRAY, die, info, warn +from . import log

patch [ ] instead of { }

AttrListTreeprocessor.BASE_RE = r'[:?[ ]*([^]\n ][^\n]*)[ ]*]' @@ -26,6 +28,8 @@ replace_filters = [ ]

def load_plugins(config): + for k in hooks: + hooks[k] = None module_dir = path.abspath(config.tree.plugins) sys.path.insert(0, module_dir) try: @@ -41,15 +45,17 @@ def load_plugins(config): module = importlib.util.module_from_spec(spec) module.CONFIG = config module.compile_page = compile_page - module.info = info - module.GRAY = GRAY + module.hook = hook + module.log = log spec.loader.exec_module(module)

- return { + exports = { name: value for name, value in vars(module).items() if not name.startswith("_") } + hook_call("on_plugins_loaded",exports) + return exports

 except Exception as e:
     raise RuntimeError(f"Failed loading plugin {config.tree.plugins}: {e}") from e

@@ -69,31 +75,32 @@ def html_filter(html_content:str): return result

def render_math(md_content: str) -> str: - def block(m): - return latex2mathml.converter.convert(m.group(1)) - def inline(m): + def convert(m): return latex2mathml.converter.convert(m.group(1))

- md_content = re.sub(r'$$(.+?)$$', block, md_content, flags=re.DOTALL) - md_content = re.sub(r'$(.+?)$', inline, md_content) + md_content = re.sub(r'$$(.+?)$$', convert, md_content, flags=re.DOTALL) + md_content = re.sub(r'$(.+?)$', convert, md_content) + return md_content

def jinja_handler(config, html_content, plugins=None): try: tree = config.tree if config else None - env = ( - Environment(loader=FileSystemLoader(tree.templates)) - if tree is not None - else Environment() - ) + if tree: + templates = tree.templates + templates = hook_call("on_jinja_template_dir", templates) or templates + env = ( Environment(loader=FileSystemLoader(templates)) ) + + else: + env = ( Environment() ) + template = env.from_string(html_content) - return ( - template.render(**plugins) - if plugins is not None - else template.render() - ) + result = template.render(**plugins) if plugins is not None else template.render() + result = hook_call("on_jinja_template_renderer", result) or result + return result

 except Exception as e:

+ hook_call("on_jinja_error", e) return html_fatal(e, f"Template error")

def escape_code_blocks(html_content: str) -> str: @@ -115,11 +122,13 @@ def escape_code_blocks(html_content: str) -> str: def save_html(html_content:str, html_dest:str): makedirs(path.dirname(html_dest), exist_ok=True ) write_file( html_dest, html_content) + hook_call("on_page_written", html_dest, html_content)

def process_highlighting(config): highlight = "noclasses" if config: highlight = config.extras.highlight + highlight = hook_call("on_highlight_config", highlight) or highlight return { "use_pygments": True, "noclasses": highlight != "noclasses", @@ -133,6 +142,7 @@ def process_highlighting(config): def md_to_html(config, md_content:str): """Convert a Markdown file to HTML, applying filters and Jinja2 processing, and save to dest.""" math_rendered = render_math(md_content) + math_rendered = hook_call("on_math_renderer", math_rendered) or math_rendered raw_html_content = markdown( math_rendered, extensions = [ @@ -152,14 +162,18 @@ def md_to_html(config, md_content:str): "pymdownx.highlight": process_highlighting(config) } ) + raw_html_content = hook_call("on_md_to_html", raw_html_content) or raw_html_content escaped_html = escape_code_blocks(raw_html_content) + escaped_html = hook_call("on_escape_code", escaped_html) or escaped_html filtered_html = html_filter(escaped_html) + filtered_html = hook_call("on_html_filter", filtered_html) or filtered_html return filtered_html

def compile_page(md_content:str, html_dest:str | None=None, config=None, plugins=None): html_raw = md_to_html(config, md_content) html_content = jinja_handler(config, html_raw, plugins) + html_content = hook_call("on_page_rendered", html_content) or html_content if html_dest is None: return html_content else: @@ -168,6 +182,7 @@ def compile_page(md_content:str, html_dest:str | None=None, config=None, plugins

def compile_file(md_file, html_dest, config=None, plugins=None, force:bool = False): md_content = read_file(md_file) + md_content = hook_call("on_page_read", md_file, md_content) or md_content if config and not force: hash = handle_hash_sync(config, md_file) if hash is None and path.exists(html_dest): @@ -179,9 +194,11 @@ def compile_file(md_file, html_dest, config=None, plugins=None, force:bool = Fal

def walk_and_build(config, plugins): md_path = config.tree.markdown + hook_call("on_walk_start", config) for parent, _, files in walk(md_path): for filename in files: md_file = path.join(parent, filename) + hook_call("on_walk_file", md_file) md_relpath = path.relpath(md_file,md_path) if is_dotfile(md_relpath): continue html_dest = path.join(config.tree.dest, md_relpath) @@ -189,6 +206,7 @@ def walk_and_build(config, plugins): html_dest = html_dest.removesuffix(".md") + ".html" source_file = readlink(md_file) if path.islink(md_file) else md_file compile_file(source_file, html_dest, config, plugins) + hook_call("on_walk_end", config)

def build_if_file(project_path, file): if project_path: @@ -208,7 +226,9 @@ def build(building_type:str,project_path:str, file:list[str]): chdir(project_path) config = load_config() plugins = load_plugins(config) + hook_call("on_build_start", config) walk_and_build(config, plugins) + hook_call("on_build_end", config)

 except Exception as e:

diff --git a/src/config.py b/src/config.py index 5bd7dc3..e0b7227 100644 --- a/src/config.py +++ b/src/config.py @@ -4,6 +4,7 @@ from tomllib import loads from .fileops import read_file from .log import GRAY from .modules import Cache, Config, Extras, Project, Tree, Webview +from .hooks import hook_call

def find_project_from_path(project_path: str): @@ -66,12 +67,14 @@ def load_cache_config(config) -> Cache:

def load_config() -> Config: config_raw_content = read_file("config.toml") - config = loads(config_raw_content) - return Config( - project = load_project_config(config), - tree = load_tree_config(config), - webview = load_webview_config(config), - extras = load_extras_config(config), - cache = load_cache_config(config), + config_loaded = loads(config_raw_content) + config = Config( + project = load_project_config(config_loaded), + tree = load_tree_config(config_loaded), + webview = load_webview_config(config_loaded), + extras = load_extras_config(config_loaded), + cache = load_cache_config(config_loaded), ) + config = hook_call("on_config_load", config) or config + return config

diff --git a/src/hash.py b/src/hash.py index 096210b..68c5f91 100644 --- a/src/hash.py +++ b/src/hash.py @@ -3,6 +3,7 @@ import json from os import path

from src.fileops import read_file, write_file +from src.hooks import hook_call from src.log import warn

HASH_FILE_CONTENT: dict | None = None @@ -56,6 +57,7 @@ def handle_hash_sync(config, md_path):

     current_hash = md5_handler(md_path)
     md_rel_path = path.relpath(md_path, config.tree.markdown)

+ hook_call("on_hash_check", md_rel_path, current_hash) new_hash = hash_is_modified(md_rel_path, current_hash)

     if new_hash is None:

@@ -63,6 +65,7 @@ def handle_hash_sync(config, md_path):

     append_md5(md_rel_path, new_hash)
     write_hash_file(config)

+ hook_call("on_hash_written", md_rel_path, new_hash) return True

 except Exception as e:

diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..9d52334 --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,58 @@ +from .log import GRAY, warn + +hooks = { + "on_after_compile_file" : None, + "on_after_html_filter" : None, + "on_after_jinja" : None, + "on_after_math" : None, + "on_after_escape_code" : None, + "on_after_md_to_html" : None, + "on_after_markdown" : None, + "on_after_template" : None, + "on_before_escape_code" : None, + "on_before_compile_file" : None, + "on_before_html_filter" : None, + "on_before_markdown" : None, + "on_before_math" : None, + "on_before_md_to_html" : None, + "on_before_save" : None, + "on_before_template" : None, + "on_build_end" : None, + "on_build_start" : None, + "on_config_load" : None, + "on_file_changed" : None, + "on_hash_check" : None, + "on_hash_written" : None, + "on_highlight_config" : None, + "on_page_read" : None, + "on_page_skip" : None, + "on_page_written" : None, + "on_plugins_loaded" : None, + "on_reload_error" : None, + "on_template_error" : None, + "on_walk_end" : None, + "on_walk_file" : None, + "on_walk_start" : None, + "on_watch_start" : None, + "on_watch_stop" : None, +} + +def hook(hook_name:str): + if hook_name in hooks: + def hook_fn(fn): + if not hooks.get(hook_name) == None: + warn(f"Overwriting existing hook '{hook_name}'.") + hooks[hook_name]=fn + return fn + return hook_fn + + else: + warn(f"Skipping hook `{GRAY(hook_name)}`: hook is not exists") + return lambda fn:fn + +def hook_call(hook_name, *args, **kwargs): + if hook_name not in hooks: + warn(hook_name) + func = hooks.get(hook_name) + if callable(func): + return func(*args, **kwargs) diff --git a/src/watcher.py b/src/watcher.py index 32dda4a..78e5f04 100644 --- a/src/watcher.py +++ b/src/watcher.py @@ -4,6 +4,8 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import time

+from src.hooks import hook_call + from .config import find_project_from_path, load_config from .build import compile_file, load_plugins from .log import warn @@ -27,6 +29,7 @@ def reload(changed_path, config, plugins, force=None): return None return html except Exception as e: + hook_call("on_reload_error", e) warn(str(e))

def watch_files(config, reload_func:Callable | None=None): @@ -49,6 +52,7 @@ def watch_files(config, reload_func:Callable | None=None): if now - last < 0.3: return last_reload[reload_path] = now + hook_call("on_file_changed", reload_path, config) file = reload( reload_path, config, plugins, force=force) if reload_func and file: reload_func(file) observer = Observer() @@ -59,6 +63,7 @@ def watch_files(config, reload_func:Callable | None=None): path=file, recursive=True ) + hook_call("on_watch_start", config) return observer

def run_watcher(project_path): @@ -74,6 +79,7 @@ def run_watcher(project_path): time.sleep(1) except KeyboardInterrupt: observers.stop() + hook_call("on_watch_stop", config) observers.join()

 except Exception as e:

// Created by Pi66

// Copyright ©Pi66 2026