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


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
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}")

// Created by Pi66

// Copyright ©Pi66 2026