Pi66

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

← back to log

feat: add md5-hashing-based caching to skip unchanged files

author: pi66
date: 2026-07-06 01:44
hash: f4f15771ffe1292fa6f72fc8ee3fdf35f43ae34c

Diffstat:

M

src/build.py
6 +++

M

src/config.py
8 +++

A

src/hash.py
71 ++++++++++++++++++++++++++++++

M

src/init_project.py
8 +++

M

src/modules.py
5 ++

M

src/watcher.py
8 ++-
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
diff --git a/src/build.py b/src/build.py
index 349bf4e..11cb50e 100644
--- a/src/build.py
+++ b/src/build.py
@@ -1,6 +1,8 @@
 import re, sys
 import importlib.util
 import latex2mathml.converter
+
+from src.hash import handle_hash_sync
 from . import settings

 from jinja2 import Environment, FileSystemLoader
@@ -109,6 +111,9 @@ def compile_md_to_html(md_file:str, html_dest:str ):
     """Convert a Markdown file to HTML, applying filters and Jinja2 processing, and save to dest."""

     info(f"Building {GRAY(md_file)}...")
+    hash = handle_hash_sync(md_file)
+    if hash is None:
+        return None 
     md_content = read_file(md_file)
     math_rendered = render_math(md_content)
     highlight = "noclasses"
@@ -146,6 +151,7 @@ def compile_md_to_html(md_file:str, html_dest:str ):
     html_content = jinja_handler(md_file, filtered_html)
     save_html(html_content, html_dest)
     info(f"Done: {GRAY(html_dest)}...")
+    return True

 def build(building_type:str,project_path:str, file:list[str]):
     try:
diff --git a/src/config.py b/src/config.py
index 6bcfc80..5bd7dc3 100644
--- a/src/config.py
+++ b/src/config.py
@@ -3,7 +3,7 @@ from os.path import exists
 from tomllib import loads
 from .fileops import read_file
 from .log import GRAY
-from .modules import Config, Extras, Project, Tree, Webview
+from .modules import Cache, Config, Extras, Project, Tree, Webview


 def find_project_from_path(project_path: str):
@@ -59,6 +59,11 @@ def load_extras_config(config) -> Extras:
         highlight = config["extras"].get("highlight", "monokai"),
     )

+def load_cache_config(config) -> Cache:
+    return Cache(
+        hash = config["cache"].get("hash", ".hash.cache"),
+    )
+
 def load_config() -> Config:
     config_raw_content = read_file("config.toml")
     config = loads(config_raw_content)
@@ -67,5 +72,6 @@ def load_config() -> Config:
         tree    = load_tree_config(config),
         webview = load_webview_config(config),
         extras  = load_extras_config(config),
+        cache   = load_cache_config(config),
     )

diff --git a/src/hash.py b/src/hash.py
new file mode 100644
index 0000000..c56a088
--- /dev/null
+++ b/src/hash.py
@@ -0,0 +1,71 @@
+import hashlib
+import json
+from os import path
+
+from src.fileops import read_file, write_file
+from src.log import info, warn
+from . import settings
+
+HASH_FILE_CONTENT: dict | None = None
+
+
+def read_hash_file():
+    global HASH_FILE_CONTENT
+    if HASH_FILE_CONTENT is None:
+        with open(settings.CONFIG.cache.hash, "r") as f:
+            HASH_FILE_CONTENT = json.load(f)
+
+    return
+
+
+def write_hash_file():
+    global HASH_FILE_CONTENT
+    with open(settings.CONFIG.cache.hash, "w") as f:
+        json.dump(HASH_FILE_CONTENT, f)
+
+def md5_handler(md_path):
+    md_content = read_file(md_path)
+    return hashlib.md5(md_content.encode()).hexdigest()
+
+
+def hash_is_modified(md_path, new_hash):
+    global HASH_FILE_CONTENT
+    old_hash = HASH_FILE_CONTENT.get(md_path)
+    if old_hash == new_hash:
+        return None
+    return new_hash
+
+
+def append_md5(key, value):
+    global HASH_FILE_CONTENT
+    HASH_FILE_CONTENT[key] = value
+
+
+def handle_hash_sync(md_path):
+    global HASH_FILE_CONTENT
+    try:
+        hash_dir = settings.CONFIG.cache.hash
+        if settings.CONFIG is None:
+            return
+        if not (path.exists(hash_dir) and path.isfile(hash_dir)):
+            write_file(hash_dir, "{}")
+
+        read_hash_file()
+
+        if HASH_FILE_CONTENT is None:
+            HASH_FILE_CONTENT = {}
+
+        current_hash = md5_handler(md_path)
+        md_rel_path = path.relpath(md_path, settings.CONFIG.tree.markdown)
+        new_hash = hash_is_modified(md_rel_path, current_hash)
+
+        if new_hash is None:
+            return None
+
+        info(f"File hash: {new_hash}")
+        append_md5(md_rel_path, new_hash)
+        write_hash_file()
+        return True
+
+    except Exception as e:
+        warn(f"Hashing failed: {e}")
diff --git a/src/init_project.py b/src/init_project.py
index c4c379c..36c3529 100644
--- a/src/init_project.py
+++ b/src/init_project.py
@@ -1,6 +1,6 @@
 from os import path, getcwd, makedirs

-from .modules import Config, Project, Tree, Webview, Extras
+from .modules import Cache, Config, Project, Tree, Webview, Extras
 from .log import *
 from .errors import fatal
 from .templates import *
@@ -30,6 +30,9 @@ def init_config_struct(project_name:str) -> Config:
         ),
         extras = Extras(
             highlight = "monokai"
+        ),
+        cache = Cache(
+            hash = ".hash.cache"
         )
     )

@@ -55,6 +58,9 @@ static_path = "{config.webview.static_path}"

 [extras]
 highlight = "{config.extras.highlight}"
+
+[cache]
+hash = "{config.cache.hash}"
 """

 def check_not_already_initialized(file_path:str) -> None:
diff --git a/src/modules.py b/src/modules.py
index 2e1ecb2..9e8b61c 100644
--- a/src/modules.py
+++ b/src/modules.py
@@ -26,9 +26,14 @@ class Webview:
 class Extras:
     highlight :str 

+@dataclass
+class Cache:
+    hash :str 
+
 @dataclass()
 class Config:
     project:Project
     tree:Tree
     webview:Webview
     extras:Extras
+    cache:Cache
diff --git a/src/watcher.py b/src/watcher.py
index 1aef4b4..5301220 100644
--- a/src/watcher.py
+++ b/src/watcher.py
@@ -12,7 +12,7 @@ from .errors import fatal

 current_md_file = None

-def reload(changed_path, tree_config, extras_config):
+def reload(changed_path, tree_config):
     global current_md_file
     try:
         if changed_path.endswith(".md"):
@@ -23,7 +23,9 @@ def reload(changed_path, tree_config, extras_config):
         md_relpath = path.relpath(current_md_file, tree_config.markdown)
         html = path.splitext(md_relpath)[0] + ".html"
         dest = path.join(tree_config.dest, html)
-        compile_md_to_html(current_md_file, dest)
+        compile_status = compile_md_to_html(current_md_file, dest)
+        if compile_status is None:
+            return None
         return html
     except Exception as e:
         warn(str(e))
@@ -42,7 +44,7 @@ def watch_files(reload_func:Callable | None=None):
             if now - last < 0.3:
                 return
             last_reload[reload_path] = now
-            file = reload( reload_path, config.tree , config.extras)
+            file = reload( reload_path, config.tree)
             if reload_func and file: reload_func(file)
     observer = Observer()
     handler = ReloadHandler()