merodi
A markdown-based static site generator.
git clone https://git.pi66.xyz/merodi
About | Files | Commits | Refs | License
Init commit
Author: pi66
Date: 2026-06-25 17:12
commit: b2b3c1b9
Stat
| A | .gitignore | 5 | +5 | -0 |
| A | README.md | 79 | +79 | -0 |
| A | pyproject.toml | 24 | +24 | -0 |
| M | src/__init__.py | 0 | +0 | -0 |
| A | src/build.py | 93 | +93 | -0 |
| A | src/config.py | 57 | +57 | -0 |
| A | src/errors.py | 56 | +56 | -0 |
| A | src/fileops.py | 22 | +22 | -0 |
| A | src/init_project.py | 99 | +99 | -0 |
| A | src/log.py | 27 | +27 | -0 |
| A | src/main.py | 43 | +43 | -0 |
| A | src/modules.py | 27 | +27 | -0 |
| A | src/settings.py | 2 | +2 | -0 |
| A | src/templates.py | 38 | +38 | -0 |
| A | src/webviewer.py | 122 | +122 | -0 |
Diff
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e52ec37
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[oc]
+build/
+dist/
+*.egg-info
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e81edf6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# Merodi
+
+A markdown-based static site generator built with Python.
...
Show all
Show less
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e52ec37
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[oc]
+build/
+dist/
+*.egg-info
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e81edf6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# Merodi
+
+A markdown-based static site generator built with Python.
...
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e52ec37
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[oc]
+build/
+dist/
+*.egg-info
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e81edf6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# Merodi
+
+A markdown-based static site generator built with Python.
+
+## Build
+
+- with pip
+```
+pip(x) install .
+```
+
+## Usage
+
+### Init a new project
+
+```
+merodi init <path> # default is the current directory
+```
+
+Creates:
+
+```
+my-site/
+ config.toml
+ src/
+ md/
+ index.md
+ templates/
+ layout.html
+ static/
+ style.css
+ plugins.py
+ dest/
+```
+
+### Build
+
+Build the entire project:
+
+```
+merodi build <path> # default is the current directory
+```
+
+Build a single file:
+
+```
+merodi build --file input.md output.html
+
+### Webview (live preview)
+
+```
+merodi webview <path> # default is the current directory
+```
+
+Opens a GUI window with live reload on file changes.
+
+## Configuration
+
+Project settings are defined in `config.toml`:
+
+```toml
+[project]
+name = "my-site"
+version = "0.1.0"
+description = "Add your description here"
+
+[tree]
+markdown = "src/md"
+static = "src/static"
+templates = "src/templates"
+dest = "src/dest"
+plugins = "src/plugins.py"
+
+[webview]
+host = "localhost"
+port = 8866
+html_path = "/"
+static_path = "/static"
+```
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e02f737
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "merodi"
+version = "0.1.0"
+description = "A markdown-based static site generator."
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "markdown",
+ "jinja2",
+ "pywebview",
+ "watchdog",
+ "PyGObject",
+]
+
+[project.scripts]
+merodi = "src.main:main"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["src*"]
+
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/build.py b/src/build.py
new file mode 100644
index 0000000..a868837
--- /dev/null
+++ b/src/build.py
@@ -0,0 +1,93 @@
+from os import getcwd, makedirs, path, walk
+from os.path import abspath, dirname
+from markdown import markdown
+from jinja2 import Environment, FileSystemLoader
+import importlib.util
+
+from .config import find_project_from_path, load_tree_config
+from .errors import fatal, html_fatal
+from .fileops import read_file, write_file
+from .log import GRAY, info
+
+replace_filters = [
+ ("<p>{%" , "{%" ),
+ ("%}</p>", "%}" ),
+ ("<p>{{" , "{{" ),
+ ("}}</p>", "}}" ),
+]
+
+def load_plugins(path):
+ global context
+ spec = importlib.util.spec_from_file_location("plugins", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ return {
+ name: value
+ for name, value in vars(module).items()
+ if not name.startswith("_")
+ }
+
+
+def html_filter(html_content:str):
+ """ filter jinja placeholders from html """
+ html = []
+ for line in html_content.splitlines():
+ for fltr in replace_filters:
+ line = line.replace(*fltr)
+ html.append(line)
+ return("\n".join(html))
+
+def jinja_handler(file, html_content, config=None):
+ try:
+ env = Environment( loader=FileSystemLoader(config.templates)) if config else Environment()
+ template = env.from_string(html_content)
+
+ if config:
+ return template.render(**load_plugins(config.plugins))
+ return template.render()
+ except Exception as e:
+ return html_fatal(e, f"Template error in {file}")
+
+def save_html(html_content:str, html_dest:str):
+ makedirs( dirname( abspath(html_dest)), exist_ok=True )
+ write_file( html_dest, html_content)
+
+def compile_md_to_html(md_file:str, html_dest:str, config =None):
+ """Convert a Markdown file to HTML, applying filters and Jinja2 processing, and save to dest."""
+ info(f"Building {GRAY(md_file)}...")
+ md_content = read_file(md_file)
+ raw_html_content = markdown(md_content)
+ filtered_html = html_filter(raw_html_content)
+ html_content = jinja_handler(md_file, filtered_html, config)
+ save_html(html_content, html_dest)
+ info(f"Done: {GRAY(html_dest)}...")
+
+def build(building_type:str,project_path:str, file:list[str]):
+ try:
+ if file:
+ if project_path:
+ raise ValueError("Please specify either a project path or a file path, not both.")
+
+ elif len(file) != 2:
+ raise ValueError("A file path must include exactly a source and a destination.")
+
+ else:
+ compile_md_to_html(file[0], file[1].removesuffix(".md") + ".html")
+ return
+
+ else:
+ project_path = project_path if project_path else getcwd()
+ find_project_from_path(project_path)
+ config = load_tree_config(project_path)
+ md_path = config.markdown
+
+ for parent, _, files in walk(md_path):
+ for filename in files:
+ md_file = path.join(parent, filename)
+ md_relpath = path.relpath(md_file,md_path)
+ html_dest = path.join(config.dest, md_relpath).removesuffix(".md") + ".html"
+ compile_md_to_html(md_file, html_dest, config)
+
+ except Exception as e:
+ fatal(e, f"Build failed: {e}")
diff --git a/src/config.py b/src/config.py
new file mode 100644
index 0000000..a1b2933
--- /dev/null
+++ b/src/config.py
@@ -0,0 +1,57 @@
+from os import makedirs, path
+from os.path import exists
+from tomllib import loads
+
+from .fileops import read_file
+from .log import GRAY
+from .modules import Tree, Webview
+
+
+def find_project_from_path(project_path: str):
+ if not path.exists(project_path):
+ raise Exception("project does not exist")
+ if not path.exists(path.join(project_path, "config.toml")):
+ raise Exception(f"cannot find `config.toml` in: {GRAY(project_path)}")
+
+def load_tree_config(project_path) -> Tree:
+ config_raw_content = read_file(path.join(project_path,"config.toml"))
+ config = loads(config_raw_content)
+ markdown = path.join(project_path, config["tree"]["markdown"])
+ static = path.join(project_path, config["tree"]["static"])
+ templates = path.join(project_path, config["tree"]["templates"])
+ dest = path.join(project_path, config["tree"]["dest"])
+ plugins = path.join(project_path, config["tree"]["plugins"])
+
+ if not exists(markdown):
+ raise FileNotFoundError(f"markdown directory does not exist: {markdown}")
+
+ if not exists(static):
+ raise FileNotFoundError(f"static directory does not exist: {static}")
+
+ if not exists(templates):
+ raise FileNotFoundError(f"templates directory does not exist: {templates}")
+
+ if not exists(plugins):
+ raise FileNotFoundError(f"plugins file does not exist: {plugins}")
+
+ if not exists(dest):
+ makedirs(dest)
+
+ return Tree(
+ markdown = markdown,
+ static = static,
+ templates = templates,
+ dest = dest,
+ plugins = plugins,
+ )
+
+
+def load_webview_config(project_path:str) -> Webview:
+ config_raw_content = read_file(path.join(project_path,"config.toml"))
+ config = loads(config_raw_content)
+ return Webview(
+ host = config["webview"]["host"],
+ port = config["webview"]["port"],
+ html_path = config["webview"]["html_path"],
+ static_path = config["webview"]["static_path"],
+ )
diff --git a/src/errors.py b/src/errors.py
new file mode 100644
index 0000000..93889e6
--- /dev/null
+++ b/src/errors.py
@@ -0,0 +1,56 @@
+from os.path import basename
+from html import escape
+import traceback
+
+from .log import *
+from . import settings
+
+def get_user_frame(exc: Exception):
+ tb = traceback.extract_tb(exc.__traceback__)
+ for frame in reversed(tb):
+ if "site-packages" not in frame.filename and "<frozen" not in frame.filename:
+ return frame
+ return tb[-1]
+
+def get_error_line(exc: Exception):
+ if hasattr(exc, 'source') and exc.source:
+ lines = exc.source.splitlines()
+ lineno = getattr(exc, 'lineno', 1) or 1
+ if lineno <= len(lines):
+ return lines[lineno - 1].strip()
+ frame = get_user_frame(exc)
+ return frame.line.strip() if frame.line else None
+
+def fatal(exc: Exception, message: str):
+ if settings.VERBOSE:
+ frame = get_user_frame(exc)
+ print(f"{BLUE(frame.name + '()')} {GRAY('—')} {basename(frame.filename)}:{YELLOW(str(frame.lineno))}")
+ print(f"{GRAY('│')} {frame.line}\n")
+ die(message)
+
+def html_fatal(exc: Exception, message: str) -> str:
+ frame = get_user_frame(exc)
+ source_line = get_error_line(exc)
+ warn(escape(message))
+ warn(f"{type(exc).__name__}: {exc.message if hasattr(exc, 'message') else exc}")
+ line = escape(source_line) if source_line else "<i style='color:#666'>line not available</i>"
+ detail = escape(str(exc.message)) if hasattr(exc, 'message') else escape(str(exc))
+ return f"""<html>
+ <head><meta charset="UTF-8"></head>
+ <body style="font-family: monospace; margin: 0; background: #1e1e1e; color: #ccc; min-height: 100vh; font-size: 18px;">
+ <div style="background: #f44; padding: 0.75rem 2rem;">
+ <span style="color: #fff; font-size: 1.2rem; font-weight: bold;">! {type(exc).__name__}</span>
+ </div>
+ <div style="padding: 2rem;">
+ <p style="color: #f90; font-size: 1.3rem; margin: 0 0 0.25rem;">{detail}</p>
+ <p style="color: #888; font-size: 1.2rem; margin: 0 0 2rem;">{escape(message)}</p>
+ <div style="background: #2a2a2a; border-radius: 6px; overflow: hidden;">
+ <div style="background: #333; padding: 0.5rem 1rem; display: flex; justify-content: space-between;">
+ <span style="color: #569cd6;">{escape(frame.name)}()</span>
+ <span style="color: #888;">{escape(basename(frame.filename))}:{frame.lineno}</span>
+ </div>
+ <pre style="margin: 0; padding: 1rem; color: #ddd; white-space: pre-wrap; border-left: 3px solid #f44; font-size: 1.1rem;">{line}</pre>
+ </div>
+ </div>
+ </body>
+ </html>"""
diff --git a/src/fileops.py b/src/fileops.py
new file mode 100644
index 0000000..4490913
--- /dev/null
+++ b/src/fileops.py
@@ -0,0 +1,22 @@
+from os import path
+from .modules import Tree
+
+def write_file(file_path: str, file_content: str) -> None:
+ with open(file_path, "w") as f:
+ f.write(file_content)
+
+def read_file(file_path:str) -> str:
+ """Read and return file contents"""
+ with open(file_path, "r") as f :
+ return f.read()
+
+def resolve_tree_paths(project_dir: str, tree: Tree) -> Tree:
+ """Return a new Tree with all paths joined onto project_dir."""
+ return Tree(
+ markdown=path.join(project_dir, tree.markdown),
+ static=path.join(project_dir, tree.static),
+ templates=path.join(project_dir, tree.templates),
+ dest=path.join(project_dir, tree.dest),
+ plugins=path.join(project_dir, tree.plugins),
+ )
+
diff --git a/src/init_project.py b/src/init_project.py
new file mode 100644
index 0000000..aa1523d
--- /dev/null
+++ b/src/init_project.py
@@ -0,0 +1,99 @@
+from os import path, getcwd, makedirs
+from .modules import Config, Project, Tree, Webview
+from .log import *
+from .errors import fatal
+from .templates import *
+from .fileops import *
+
+def render_config(config:Config) -> str:
+ return f"""[project]
+name = "{config.project.name}"
+version = "{config.project.version}"
+description = "{config.project.description}"
+
+[tree]
+markdown = "{config.tree.markdown}"
+static = "{config.tree.static}"
+templates = "{config.tree.templates}"
+dest = "{config.tree.dest}"
+plugins = "{config.tree.plugins}"
+
+[webview]
+host = "{config.webview.host}"
+port = {config.webview.port}
+html_path = "{config.webview.html_path}"
+static_path = "{config.webview.static_path}"
+"""
+
+def init_config_struct(project_name:str) -> Config:
+ """Build a Config with default values for a newly initialized project."""
+ return Config(
+ project=Project(
+ name = project_name,
+ version = "0.1.0",
+ description = "Add your description here",
+ ),
+ tree = Tree(
+ markdown = "src/md",
+ static = "src/static",
+ templates = "src/templates",
+ dest = "src/dest",
+ plugins = "src/plugins.py",
+ ),
+ webview = Webview(
+ host = "localhost",
+ port = 8866,
+ html_path = "/",
+ static_path = "/static"
+ )
+ )
+
+def check_not_already_initialized(file_path:str) -> None:
+ """raise FileExistsError when config file exists"""
+ if path.exists(file_path):
+ raise Exception("Project already initialized: `config.toml` already exists")
+
+def create_tree_dirs (tree:Tree) -> None:
+ """ Creates parent dirs before start writing """
+ makedirs(tree.markdown , exist_ok=True)
+ makedirs(tree.static , exist_ok=True)
+ makedirs(tree.templates, exist_ok=True)
+ makedirs(tree.dest , exist_ok=True)
+ makedirs(path.dirname(tree.plugins) ,exist_ok=True)
+
+def write_default_content(tree:Tree) -> None:
+ """ Write default content """
+ md_file = path.join(tree.markdown, "index.md")
+ write_file(md_file,MARKDOWN_CONTENT )
+
+ html_file = path.join(tree.templates, "layout.html")
+ write_file(html_file,HTML_CONTENT )
+
+ css_file = path.join(tree.static, "style.css")
+ write_file(css_file,CSS_CONTENT )
+
+ write_file(tree.plugins,PLUGINS_CONTENT )
+
+def write_config_file(project_path, config_content):
+ makedirs(project_path, exist_ok=True)
+ config_path = path.join(project_path, "config.toml")
+ check_not_already_initialized(config_path)
+
+ # Writing config.toml
+ write_file(config_path, config_content)
+
+
+def init(project_path):
+ try:
+ project_path = project_path if project_path else getcwd()
+ config = init_config_struct(path.basename(project_path) )
+ default_config_content = render_config(config)
+ config.tree = resolve_tree_paths(project_path, config.tree)
+ write_config_file(project_path, default_config_content)
+
+ create_tree_dirs(config.tree)
+ write_default_content(config.tree)
+ info("Project initialized.")
+ except Exception as e:
+ fatal(e, f"Initialization failed: {e}")
+
diff --git a/src/log.py b/src/log.py
new file mode 100644
index 0000000..a8b3816
--- /dev/null
+++ b/src/log.py
@@ -0,0 +1,27 @@
+from . import settings
+
+def info(message: str):
+ print(f"{BLUE('[INFO]:')} {message}")
+
+def warn(message: str):
+ print(f"{YELLOW('[WARN]:')} {message}")
+
+def die(err: str, status_code: int = 1, exit_from_code=True):
+ print(f"{RED('[ERROR]:')} {err}")
+ if exit_from_code: exit(status_code)
+
+def YELLOW(text):
+ if settings.NO_COLOR: return text
+ return f"\033[33m{text}\033[0m"
+
+def BLUE(text):
+ if settings.NO_COLOR: return text
+ return f"\033[34m{text}\033[0m"
+
+def GRAY(text):
+ if settings.NO_COLOR: return text
+ return f"\033[90m{text}\033[0m"
+
+def RED(text):
+ if settings.NO_COLOR: return text
+ return f"\033[31m{text}\033[0m"
diff --git a/src/main.py b/src/main.py
new file mode 100755
index 0000000..3054af2
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,43 @@
+import sys
+sys.dont_write_bytecode = True
+
+from argparse import ArgumentParser
+from os import environ
+from .log import *
+from . import settings
+
+def main():
+ common = ArgumentParser(add_help=False)
+ common.add_argument("--verbose", action="store_true")
+ common.add_argument("--no-color", action="store_true")
+
+ parser = ArgumentParser()
+ sub_parser = parser.add_subparsers(dest="command",required=True)
+
+ init_parser = sub_parser.add_parser("init", parents=[common])
+ init_parser.add_argument("path", nargs="?")
+
+ webview_parser = sub_parser.add_parser("webview", parents=[common])
+ webview_parser.add_argument("path", nargs="?")
+
+ build_parser = sub_parser.add_parser("build", parents=[common])
+ build_parser.add_argument("--release", action="store_true")
+ build_parser.add_argument("--debug", action="store_true")
+ build_parser.add_argument("path", nargs="?")
+ build_parser.add_argument("--file", nargs=2, metavar=("SRC", "DEST"))
+
+ args = parser.parse_args()
+ settings.VERBOSE = args.verbose or ( environ.get("VERBOSE") in ["true", "1"])
+ settings.NO_COLOR = args.no_color or ( environ.get("NO_COLOR") in ["true", "1"])
+
+ if args.command == "init":
+ from .init_project import init
+ init(project_path = args.path)
+
+ elif args.command == "build":
+ from .build import build
+ build(building_type = "release" if args.release else "debug", project_path = args.path, file=args.file)
+
+ elif args.command == "webview":
+ from .webviewer import run
+ run(args.path)
diff --git a/src/modules.py b/src/modules.py
new file mode 100644
index 0000000..52af1dd
--- /dev/null
+++ b/src/modules.py
@@ -0,0 +1,27 @@
+from dataclasses import dataclass
+
+@dataclass()
+class Project:
+ name : str
+ version : str
+ description : str
+
+@dataclass()
+class Tree:
+ markdown : str
+ static : str
+ templates : str
+ dest : str
+ plugins : str
+
+@dataclass()
+class Webview:
+ host : str
+ port : int
+ html_path : str
+ static_path : str
+@dataclass()
+class Config:
+ project:Project
+ tree:Tree
+ webview:Webview
diff --git a/src/settings.py b/src/settings.py
new file mode 100644
index 0000000..c02319b
--- /dev/null
+++ b/src/settings.py
@@ -0,0 +1,2 @@
+VERBOSE = False
+NO_COLOR = False
diff --git a/src/templates.py b/src/templates.py
new file mode 100644
index 0000000..c722192
--- /dev/null
+++ b/src/templates.py
@@ -0,0 +1,38 @@
+MARKDOWN_CONTENT = """{% block content %}
+# Hello, Alice
+{% endblock %}"""
+CSS_CONTENT = """body {
+ background-color: #1d2021;
+ color: #d5c4a1;
+}"""
+HTML_CONTENT = """<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>{% block title %}{% endblock %}</title>
+ <link rel="stylesheet" href="{{ style_css }}">
+ </head>
+ <body>
+ {% block content %}{% endblock %}
+ </body>
+</html>"""
+PLUGINS_CONTENT = """from urllib.request import urlopen
+from json import loads
+
+def fetch(url, type:str="text"):
+ with urlopen(url) as request:
+ if request.status == 200:
+ value = request.read().decode("utf-8")
+ if type=="json":
+ return loads(value)
+ if value.endswith("\\n"):
+ return value.removesuffix("\\n")
+ return value
+ else:
+ raise RuntimeError(f"Failed to fetch '{url}': {request.status}")
+
+def read(file:str):
+ content = open(file, "r").read()
+ if content.endswith("\\n"):
+ return content.removesuffix("\\n")
+ return content
+"""
diff --git a/src/webviewer.py b/src/webviewer.py
new file mode 100644
index 0000000..cc17bab
--- /dev/null
+++ b/src/webviewer.py
@@ -0,0 +1,122 @@
+from os import getcwd, path
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from threading import Thread
+from watchdog.observers import Observer
+from watchdog.events import FileSystemEventHandler
+import webview
+import time
+
+from .config import find_project_from_path, load_tree_config, load_webview_config
+from .errors import fatal, html_fatal
+from .build import compile_md_to_html
+from .log import info
+
+current_url = ""
+
+def http_server(host, port, routes):
+ class Handler(SimpleHTTPRequestHandler):
+ def translate_path(self, url_path):
+ url = routes["url_path"]
+ fs = routes["fs_path"]
+ relpath = url_path.lstrip("/").rstrip("/")
+ if url_path.startswith(url["static"]):
+ extracted_path = path.relpath(url_path,url["static"])
+ return path.join(fs["static"], extracted_path)
+ if url_path.startswith(url["html"]):
+ extracted_path = path.relpath(url_path,url["html"])
+ return path.join(fs["html"], extracted_path)
+ return path.join(fs["html"], relpath)
+
+ def send_error(self, code, message=None, explain=None):
+ # html = html_fatal(code, self.path, message, explain)
+ html = html_fatal(Exception(f"{code}: {message}"), explain or self.path)
+
+ self.send_response(code)
+ self.send_header("Content-Type", "text/html")
+ self.send_header("Content-Length", len(html))
+ self.end_headers()
+
+ self.wfile.write(html.encode())
+
+ def log_message(self, format, *args):
+ method, path, _ = args[0].split(" ", 2)
+ info(f"{method} {path} {args[1]}")
+
+ server = HTTPServer((host, port), Handler)
+ return server
+
+def reload(window, file, tree_config, webview_config):
+ global current_url
+ try:
+ md_relpath = path.relpath(file, tree_config.markdown)
+ dest = path.join(tree_config.dest, md_relpath)
+ if dest.endswith(".md"):
+ dest = dest.removesuffix(".md") + ".html"
+ current_url = path.relpath(dest, tree_config.dest)
+ compile_md_to_html(file, dest, tree_config)
+ info(f"Reloading: {current_url}")
+ if current_url:
+ window.load_url(f"http://{webview_config.host}:{webview_config.port}/{current_url}")
+ except Exception as e:
+ window.load_url(f"data:text/html,{html_fatal(e, f'Failed to reload {file}')}")
+
+def watch_files(window, tree_config, webview_config, files: list[str]):
+ last_reload = {}
+ class ReloadHandler(FileSystemEventHandler):
+ def on_modified(self, event):
+ reload_path = event.src_path
+ if event.is_directory :
+ if path.exists(path.join(event.src_path, "index.html")):
+ reload_path = path.join(event.src_path, "index.html")
+ else:
+ return
+ now = time.time()
+ last = last_reload.get(reload_path, 0)
+ if now - last < 0.3:
+ return
+ last_reload[reload_path] = now
+ reload(window, reload_path, tree_config, webview_config)
+
+
+ observer = Observer()
+ handler = ReloadHandler()
+ for file in files:
+ observer.schedule(
+ handler,
+ path=file,
+ recursive=True
+ )
+ observer.start()
+
+def run(project_path):
+ try:
+ project_path = project_path if project_path else getcwd()
+ find_project_from_path(project_path)
+ webview_config = load_webview_config(project_path)
+ host = webview_config.host
+ port = webview_config.port
+ tree_config = load_tree_config(project_path)
+ routes = {
+ "url_path" : {
+ "html": webview_config.html_path,
+ "static": webview_config.static_path
+ },
+ "fs_path" : {
+ "html": tree_config.dest,
+ "static": tree_config.static
+ },
+ }
+
+ server = http_server(host, port, routes)
+ Thread(target=server.serve_forever, daemon=True).start()
+
+ window = webview.create_window("Merodi", url=f"http://{host}:{port}/")
+ watch_files(window, tree_config, webview_config, [ tree_config.markdown, tree_config.static, tree_config.plugins ])
+ webview.start()
+
+
+ except KeyboardInterrupt:
+ info("Exiting...")
+ exit(0)
+ except Exception as e:
+ fatal(e, f"Webview failed: {e}")