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
Show less
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
...
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: