merodi
A markdown-based static site generator.
git clone https://git.pi66.xyz/merodi
About | Files | Commits | Refs | License
refactor: rewrite merodi in Go with Lua plugin system
Author: pi66
Date: 2026-09-09 13:33
commit: af5e08f3
Stat
| M | .gitignore | 4 | +1 | -3 |
| M | README.md | 26 | +12 | -14 |
| A | examples/config.lua | 13 | +13 | -0 |
| A | examples/extensions.lua | 12 | +12 | -0 |
| A | examples/hooks.lua | 36 | +36 | -0 |
| A | examples/http.lua | 40 | +40 | -0 |
| A | examples/jinja.lua | 8 | +8 | -0 |
| A | examples/log.lua | 3 | +3 | -0 |
| A | examples/main.lua | 9 | +9 | -0 |
| A | go.mod | 22 | +22 | -0 |
| A | go.sum | 62 | +62 | -0 |
| D | pyproject.toml | 28 | +0 | -28 |
| M | src/__init__.py | 0 | +0 | -0 |
| D | src/api.py | 35 | +0 | -35 |
| D | src/build.py | 224 | +0 | -224 |
| D | src/config.py | 55 | +0 | -55 |
| A | src/config/main.go | 113 | +113 | -0 |
| A | src/core/build/compile/jinja.go | 66 | +66 | -0 |
| A | src/core/build/compile/main.go | 110 | +110 | -0 |
| A | src/core/build/main.go | 68 | +68 | -0 |
| A | src/core/lua/build.go | 51 | +51 | -0 |
| A | src/core/lua/config.go | 31 | +31 | -0 |
| A | src/core/lua/converter.go | 52 | +52 | -0 |
| A | src/core/lua/extensions.go | 38 | +38 | -0 |
| A | src/core/lua/hook.go | 47 | +47 | -0 |
| A | src/core/lua/http.go | 129 | +129 | -0 |
| A | src/core/lua/jinja.go | 27 | +27 | -0 |
| A | src/core/lua/log.go | 36 | +36 | -0 |
| A | src/core/lua/main.go | 48 | +48 | -0 |
| A | src/core/main.go | 42 | +42 | -0 |
| A | src/core/serve/main.go | 59 | +59 | -0 |
| A | src/core/setup.go | 27 | +27 | -0 |
| A | src/core/state/main.go | 12 | +12 | -0 |
| D | src/errors.py | 81 | +0 | -81 |
| D | src/fileops.py | 27 | +0 | -27 |
| D | src/hash.py | 82 | +0 | -82 |
| D | src/hooks.py | 67 | +0 | -67 |
| A | src/init/bootstrap.go | 49 | +49 | -0 |
| A | src/init/constants.go | 24 | +24 | -0 |
| A | src/init/main.go | 45 | +45 | -0 |
| D | src/init_project.py | 109 | +0 | -109 |
| D | src/log.py | 33 | +0 | -33 |
| A | src/main.go | 37 | +37 | -0 |
| D | src/main.py | 73 | +0 | -73 |
| D | src/modules.py | 40 | +0 | -40 |
| A | src/project/main.go | 23 | +23 | -0 |
| D | src/settings.py | 2 | +0 | -2 |
| D | src/templates.py | 38 | +0 | -38 |
| A | src/utils/log/main.go | 61 | +61 | -0 |
| A | src/utils/main.go | 45 | +45 | -0 |
| A | src/utils/path/main.go | 15 | +15 | -0 |
| D | src/watcher.py | 107 | +0 | -107 |
| D | src/webviewer.py | 133 | +0 | -133 |
Diff
diff --git a/.gitignore b/.gitignore
index 3cf827a..d0e3154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1 @@
-__pycache__/
-*.py[oc]
-*.egg-info
+./merodi
diff --git a/README.md b/README.md
index 994310a..2ac2575 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
# Merodi
-A markdown-based static site generator built with Python. Write pages in markdown, style them with Jinja2 templates, and get a ready-to-publish website.
+A Markdown-based static site generator built with Go. Write pages in Markdown, style them with Jinja2 templates, extend it with Lua plugins, and build a ready-to-publish website.
-## Install
...
Show all
Show less
diff --git a/.gitignore b/.gitignore
index 3cf827a..d0e3154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1 @@
-__pycache__/
-*.py[oc]
-*.egg-info
+./merodi
diff --git a/README.md b/README.md
index 994310a..2ac2575 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
# Merodi
-A markdown-based static site generator built with Python. Write pages in markdown, style them with Jinja2 templates, and get a ready-to-publish website.
+A Markdown-based static site generator built with Go. Write pages in Markdown, style them with Jinja2 templates, extend it with Lua plugins, and build a ready-to-publish website.
-## Install
...
diff --git a/.gitignore b/.gitignore
index 3cf827a..d0e3154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1 @@
-__pycache__/
-*.py[oc]
-*.egg-info
+./merodi
diff --git a/README.md b/README.md
index 994310a..2ac2575 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,16 @@
# Merodi
-A markdown-based static site generator built with Python. Write pages in markdown, style them with Jinja2 templates, and get a ready-to-publish website.
+A Markdown-based static site generator built with Go. Write pages in Markdown, style them with Jinja2 templates, extend it with Lua plugins, and build a ready-to-publish website.
-## Install
+## Installation
```bash
-pip install .
-# or using pipx
-pipx install .
+make # Build Merodi
+make install # Install Merodi
+make uninstall # Remove the installed binary
```
-## Quick start
+## Quick Start
```bash
merodi init my-site
@@ -18,16 +18,14 @@ cd my-site
merodi build
```
-Open `src/dest/index.html`, or run `merodi webview` for a live preview.
-
Basics
-- Pages are written in Markdown under `src/md/`
-- Templates use Jinja2 and live in `src/templates/`
-- Static assets go in `src/static/`
-- Project settings live in `config.toml`
-- Custom functions can be added in `src/plugins.py` and used directly in templates
+* Project settings are stored in `config.toml`.
+* Pages are written in Markdown and stored in the directory specified in `config.toml`.
+* See the `examples/` directory for more information.
Status
-Merodi is still under active development (pre-1.0.0). Commands, config, and template behavior may change. Full documentation will be published once the project reaches **1.0.0**.
+Merodi is still under active development and is currently pre-1.0.0. Commands, configuration, and template behavior may change as the project evolves.
+
+Full documentation will be published once Merodi reaches a stable release.
diff --git a/examples/config.lua b/examples/config.lua
new file mode 100644
index 0000000..a361a14
--- /dev/null
+++ b/examples/config.lua
@@ -0,0 +1,13 @@
+-- Read-only values from config.toml.
+
+-- Project
+merodi.config.project.name -- string: project name
+merodi.config.project.version -- string: project version
+merodi.config.project.description -- string: project description
+
+-- Tree
+merodi.config.tree.markdown -- string: path to the Markdown source folder
+merodi.config.tree.templates -- string: path to the templates folder
+merodi.config.tree.plugins -- string: path to the plugins folder
+merodi.config.tree.draft_dest -- string: output folder used in draft mode
+merodi.config.tree.release_dest -- string: output folder used in release mode
diff --git a/examples/extensions.lua b/examples/extensions.lua
new file mode 100644
index 0000000..027d654
--- /dev/null
+++ b/examples/extensions.lua
@@ -0,0 +1,12 @@
+-- Markdown extensions.
+-- Add an extension here to enable it during parsing.
+
+merodi.extensions.CJK -- handling of Chinese/Japanese/Korean text spacing
+merodi.extensions.DefinitionList -- support for `Term\n: Definition` lists
+merodi.extensions.Footnote -- support for [^1] style footnotes
+merodi.extensions.GFM -- GitHub-Flavored (tables, strikethrough, autolinks, etc... )
+merodi.extensions.Linkify -- converts bare URLs/emails into clickable links
+merodi.extensions.Strikethrough -- support for struck text
+merodi.extensions.Table -- support for markdown tables
+merodi.extensions.TaskList -- support for - [ ] / - [x] checkboxes
+merodi.extensions.Typographer -- turns straight quotes and dashes into typographic ones ("smart quotes")
diff --git a/examples/hooks.lua b/examples/hooks.lua
new file mode 100644
index 0000000..66fcf52
--- /dev/null
+++ b/examples/hooks.lua
@@ -0,0 +1,36 @@
+-- Hooks let your plugin run code at different stages of the build.
+-- Hook functions don't take arguments or return anything. Use merodi.build.* to
+
+merodi.hook("before_read", function() end) -- runs before the Markdown file is read
+merodi.hook("read_md", function() end) -- runs after the raw Markdown has been read
+merodi.hook("html_content", function() end) -- runs after the Markdown is converted to HTML
+merodi.hook("apply_html_jinja", function() end) -- runs after Jinja templates are applied
+merodi.hook("before_write", function() end) -- runs before the final HTML is written
+merodi.hook("after_write", function() end) -- runs after the file has been written
+
+-- merodi.abort(reason) stops the build for the current file only.
+-- It does not stop the rest of the project from being built.
+-- Use it only inside a hook. Calling it outside a hook has no effect.
+-- The reason is shown as a warning in the build output.
+
+merodi.abort("reason string")
+
+merodi.build.mode()
+-- Returns a string: "release" or "draft" depending on how the build was run
+
+merodi.build.content.get()
+-- Returns the current content (raw markdown, HTML, or final output,
+-- depending on which hook stage you're in).
+
+merodi.build.content.set("new content")
+-- Replaces the current content with the string you provide. Takes effect for
+-- whatever the next pipeline stage does with it.
+
+merodi.build.md_path.get()
+-- Returns the source markdown file's path
+
+merodi.build.html_path.get()
+-- Returns the destination HTML file's path
+
+merodi.build.html_path.set("/some/path.html")
+-- Overrides the destination path the final HTML will be written to.
diff --git a/examples/http.lua b/examples/http.lua
new file mode 100644
index 0000000..888d270
--- /dev/null
+++ b/examples/http.lua
@@ -0,0 +1,40 @@
+-- HTTP are available only with `merodi serve`
+-- Use routes to handle requests and return responses
+
+merodi.http.route("/path", function()
+ return {
+ mime = "text/plain",
+ value = "response",
+ status = 200
+ }
+end)
+
+-- Registers a handler for a URL path.
+-- The handler returns a response table:
+-- mime (string) - response Content-Type
+-- value (string) - response body
+-- status (number) - HTTP status code
+-- Defaults: mime = "text/plain", status = 200.
+
+merodi.http.method()
+-- Returns the current request method, such as "GET" or "POST".
+-- Only available inside a route.
+
+merodi.http.request.body()
+-- Returns the request body as a string.
+
+merodi.http.request.remote_addr()
+-- Returns the client's IP address.
+
+-- Server settings
+merodi.http.settings.host.set("localhost")
+-- Sets the host the server listens on.
+
+merodi.http.settings.port.set("8866")
+-- Sets the port the server listens on.
+
+merodi.http.settings.host.get()
+-- Returns the configured host.
+
+merodi.http.settings.port.get()
+-- Returns the configured port.
diff --git a/examples/jinja.lua b/examples/jinja.lua
new file mode 100644
index 0000000..f793fbd
--- /dev/null
+++ b/examples/jinja.lua
@@ -0,0 +1,8 @@
+-- Variables available in Jinja templates as {{ key }}.
+
+merodi.jinja.set("key", "value")
+-- Adds a variable that can be used in templates as {{ key }}.
+-- Values can be strings, numbers, booleans, or tables.
+
+merodi.jinja.get("key")
+-- Returns the value stored for "key", or nil if it hasn't been set.
diff --git a/examples/log.lua b/examples/log.lua
new file mode 100644
index 0000000..b91022f
--- /dev/null
+++ b/examples/log.lua
@@ -0,0 +1,3 @@
+merodi.log.info("info message") -- prints a normal informational message
+merodi.log.warn("warning message") -- prints a warning without stopping the build
+merodi.log.die("fatal message") -- prints the message and stops the entire program
diff --git a/examples/main.lua b/examples/main.lua
new file mode 100644
index 0000000..2e3ab87
--- /dev/null
+++ b/examples/main.lua
@@ -0,0 +1,9 @@
+-- Entry point for your plugin
+-- Load the features you want to use
+
+require("hooks")
+require("http")
+require("jinja")
+require("config")
+require("log")
+require("extensions")
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..653f45f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,22 @@
+module merodi
+
+go 1.26.5
+
+require (
+ github.com/BurntSushi/toml v1.6.0
+ github.com/nikolalohinski/gonja/v2 v2.9.0
+ github.com/yuin/goldmark v1.8.6
+ github.com/yuin/gopher-lua v1.1.2
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..4b69dab
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,62 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/nikolalohinski/gonja/v2 v2.9.0 h1:QICtNWj0siM3PF5xUjVod/l+C9XnGfI+wrfSIBGKQ6o=
+github.com/nikolalohinski/gonja/v2 v2.9.0/go.mod h1:UIzXPVuOsr5h7dZ5DUbqk3/Z7oFA/NLGQGMjqT4L2aU=
+github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
+github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
+github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
+github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
+golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/pyproject.toml b/pyproject.toml
deleted file mode 100644
index 3ae61f3..0000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,28 +0,0 @@
-[project]
-name = "merodi"
-version = "0.4.1"
-description = "A markdown-based static site generator."
-readme = "README.md"
-requires-python = ">=3.11"
-dependencies = [
- "markdown",
- "jinja2",
- "pywebview",
- "watchdog",
- "PyGObject",
- "pymdown-extensions",
- "latex2mathml",
- "pygments",
- "ifaddr",
-]
-
-[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
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api.py b/src/api.py
deleted file mode 100644
index a76eac0..0000000
--- a/src/api.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""Bridge between merodi core and user plugins"""
-
-class GlobalStore:
- def __init__(self):
- self._data = {}
-
- def get(self, key, default=None):
- return self._data.get(key, default)
-
- def set(self, key, value):
- self._data[key] = value
-
- def has(self, key):
- return key in self._data
-
- def delete(self, key):
- self._data.pop(key, None)
-
- def clear(self):
- self._data.clear()
-
-class API:
- def __init__(self):
- self.globals = GlobalStore()
- self.log = None
- self.config = None
- self.mode = "draft"
-
-api = API()
-
-def expose(name):
- def decorator(fn):
- setattr(api, name, fn)
- return fn
- return decorator
diff --git a/src/build.py b/src/build.py
deleted file mode 100644
index 7d65604..0000000
--- a/src/build.py
+++ /dev/null
@@ -1,224 +0,0 @@
-import re, sys
-import importlib.util
-import latex2mathml.converter
-
-from .hooks import hook, hook_call, reset_hooks
-from .hash import handle_hash_sync, clear_all_hashes
-from .api import expose, api
-
-from jinja2 import Environment, FileSystemLoader
-from markdown import markdown
-from markdown.extensions.attr_list import AttrListTreeprocessor
-from os import chdir, getcwd, makedirs, path, walk, readlink
-
-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, info, warn, progress
-from . import log
-
-api.log = log
-
-AttrListTreeprocessor.BASE_RE = r'[:?[ ]*([^]\n ][^\n]*)[ ]*]'
-AttrListTreeprocessor.BLOCK_RE = re.compile(r'\n[ ]*{}[ ]*$'.format(AttrListTreeprocessor.BASE_RE))
-AttrListTreeprocessor.HEADER_RE = re.compile(r'[ ]+{}[ ]*$'.format(AttrListTreeprocessor.BASE_RE))
-AttrListTreeprocessor.INLINE_RE = re.compile(r'^{}'.format(AttrListTreeprocessor.BASE_RE))
-
-replace_filters = [
- ("<p>{%" , "{%" ),
- ("%}</p>", "%}" ),
-]
-
-def load_plugins(config):
- reset_hooks()
- module_dir = path.abspath(config.tree.plugins)
- sys.path.insert(0, module_dir)
- try:
- main_path = path.join(module_dir, "main.py")
- if not path.isfile(main_path):
- warn("can not find `main.py` on plugins directory")
- return {}
- for name in list(sys.modules):
- mod_file = getattr(sys.modules[name], "__file__", "") or ""
- if mod_file.startswith(module_dir):
- del sys.modules[name]
- spec = importlib.util.spec_from_file_location("plugins", main_path)
- if spec is None or spec.loader is None:
- raise RuntimeError("Failed to create plugin module spec.")
- module = importlib.util.module_from_spec(spec)
- module.api = api
- module.hook = hook
- spec.loader.exec_module(module)
- hook_call("on_plugins_before_export", config)
- exports = {n: v for n, v in vars(module).items() if not n.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
- finally:
- sys.path.pop(0)
-
-
-@expose("html_filter")
-def html_filter(html_content):
- html = []
- for line in html_content.splitlines():
- for fltr in replace_filters:
- line = line.replace(*fltr)
- html.append(line)
- result = "\n".join(html)
- result = re.sub(r'(}}\s*)[[^]]*]', r'\1', result)
- return result
-
-def render_math(md_content):
- def convert(m):
- return latex2mathml.converter.convert(m.group(1))
- md_content = re.sub(r'$$(.+?)$$', convert, md_content, flags=re.DOTALL)
- md_content = re.sub(r'$(.+?)$', convert, md_content)
- return md_content
-
-@expose("jinja_handler")
-def jinja_handler(config, html_content, plugins=None):
- try:
- if config:
- templates = hook_call("on_jinja_template_dir", config.tree.templates) or config.tree.templates
- env = Environment(loader=FileSystemLoader(templates))
- else:
- env = Environment()
- template = env.from_string(html_content)
- result = template.render(**plugins) if plugins else template.render()
- return hook_call("on_jinja_template_renderer", result) or result
- except Exception as e:
- hook_call("on_jinja_error", e)
- return html_fatal(e, f"Template error")
-
-@expose("save_html")
-def save_html(html_content, html_dest):
- html_dest = path.abspath(html_dest)
- 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 = config.extras.highlight if config else "noclasses"
- highlight = hook_call("on_highlight_config", highlight) or highlight
- return {
- "use_pygments": True,
- "linenums": True,
- "linenums_style": "table",
- "noclasses": highlight != "noclasses",
- **({} if highlight == "noclasses" else {"pygments_style": highlight}),
- }
-
-@expose("md_to_html")
-def md_to_html(config, md_content):
- math_rendered = render_math(md_content)
- math_rendered = hook_call("on_math_renderer", math_rendered) or math_rendered
- raw_html = markdown(
- math_rendered,
- extensions=[
- "extra", "md_in_html",
- "pymdownx.betterem", "pymdownx.critic", "pymdownx.details",
- "pymdownx.highlight", "pymdownx.inlinehilite", "pymdownx.keys",
- "pymdownx.mark", "pymdownx.superfences", "pymdownx.tabbed", "pymdownx.tilde",
- ],
- extension_configs={"pymdownx.highlight": process_highlighting(config)},
- )
- raw_html = hook_call("on_md_to_html", raw_html) or raw_html
- filtered = hook_call("on_html_filter", html_filter(raw_html)) or html_filter(raw_html)
- return filtered
-
-
-@expose("compile_page")
-def compile_page(md_content, html_dest=None, config=None, plugins=None):
- html = jinja_handler(config, md_to_html(config, md_content), plugins)
- html = hook_call("on_page_rendered", html) or html
- if html_dest is None:
- return html
- save_html(html, html_dest)
- return True
-
-def read_md_content(md_file):
- content = read_file(md_file)
- return hook_call("on_page_read", md_file, content) or content
-
-@expose("compile_file")
-def compile_file(md_file, html_dest, config=None, plugins=None, force=False):
- hook_call("on_compile_start", md_file)
- if config and not force:
- if handle_hash_sync(config, md_file) is None and path.exists(html_dest):
- hook_call("on_page_skip", md_file)
- info(f"Skipping {GRAY(md_file)}...")
- return None
- result = compile_page(read_md_content(md_file), html_dest, config, plugins)
- info(f"Building {GRAY(md_file)}...")
- hook_call("on_page_built", md_file, html_dest)
- return result
-
-def walk_and_build(config, plugins, dest, force_all=False):
- md_path = config.tree.markdown
- hook_call("on_walk_start", config)
- files = [path.join(p, f) for p, _, fs in walk(md_path) for f in fs]
- for md_file in progress(files, "Building"):
- 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(dest, md_relpath.removesuffix(".md") + ".html")
- source = readlink(md_file) if path.islink(md_file) else md_file
- compile_file(source, html_dest, config, plugins, force=force_all)
- hook_call("on_walk_end", config)
-
-def build_all(config, plugins, dest):
- clear_all_hashes(config)
- walk_and_build(config, plugins, dest, force_all=True)
-
-def validate_build(config, plugins):
- errors = []
- md_path = config.tree.markdown
- for parent, _, filenames in walk(md_path):
- for filename in filenames:
- md_file = path.join(parent, filename)
- if is_dotfile(path.relpath(md_file, md_path)):
- continue
- source = readlink(md_file) if path.islink(md_file) else md_file
- try:
- compile_page(read_md_content(source), config=config, plugins=plugins)
- except Exception as e:
- errors.append(f"{path.relpath(md_file, md_path)}: {e}")
- if errors:
- for e in errors:
- warn(e)
- raise Exception(f"Validation failed with {len(errors)} error(s)")
- info("Validation passed.")
- exit(0)
-
-def build_if_file(project_path, file):
- if project_path:
- raise ValueError("Specify either a project path or a file path, not both.")
- if len(file) != 2:
- raise ValueError("A file path must include exactly a source and a destination.")
- html_dest = path.abspath(file[1])
- if path.isdir(html_dest):
- html_dest = path.join(html_dest,"index.html")
-
- compile_file(file[0], html_dest, force=True)
-
-def build(mode, project_path, file, validate=False, force=False):
- try:
- if file:
- return build_if_file(project_path, file)
-
- project_path = project_path or getcwd()
- find_project_from_path(project_path)
- chdir(project_path)
- config = load_config()
- plugins = load_plugins(config)
- dest = config.tree.draft_dest if mode == "draft" else config.tree.release_dest
- hook_call("on_build_start", config)
- if validate:
- validate_build(config, plugins)
- walk_and_build(config, plugins, dest, force_all=force)
- hook_call("on_build_end", config)
- except Exception as e:
- fatal(e, f"Build failed: {e}")
diff --git a/src/config.py b/src/config.py
deleted file mode 100644
index 0ec63a8..0000000
--- a/src/config.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from os import path
-from os.path import exists
-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
-from .api import api
-
-
-def find_project_from_path(project_path):
- 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_project_config(c) -> Project:
- return Project(name=c["project"]["name"], version=c["project"]["version"], description=c["project"]["description"])
-
-def load_tree_config(c) -> Tree:
- t = c["tree"]
- markdown, static, templates = t["markdown"], t["static"], t["templates"]
- draft_dest, release_dest, plugins = t["draft_dest"], t["release_dest"], t["plugins"]
- for name, p in [("markdown", markdown), ("static", static), ("templates", templates), ("plugins", plugins)]:
- if not exists(p):
- raise FileNotFoundError(f"{name} directory does not exist: {p}")
- return Tree(markdown=markdown, static=static, templates=templates, draft_dest=draft_dest, release_dest=release_dest, plugins=plugins)
-
-def load_webview_config(c) -> Webview:
- w = c["webview"]
- return Webview(host=w["host"], port=w["port"], dev_tools=w["dev_tools"], html_path=w["html_path"], static_path=w["static_path"])
-
-def load_extras_config(c) -> Extras:
- return Extras(highlight=c["extras"]["highlight"])
-
-def load_cache_config(c) -> Cache:
- return Cache(hash=c["cache"]["hash"])
-
-def load_globals_config(c):
- api.globals._data.update(c.get("globals", {}))
-
-def load_config() -> Config:
- raw = read_file("config.toml")
- c = loads(raw)
- load_globals_config(c)
- config = Config(
- project=load_project_config(c),
- tree=load_tree_config(c),
- webview=load_webview_config(c),
- extras=load_extras_config(c),
- cache=load_cache_config(c),
- )
- config = hook_call("on_config_load", config) or config
- api.config = config
- return config
diff --git a/src/config/main.go b/src/config/main.go
new file mode 100644
index 0000000..674e596
--- /dev/null
+++ b/src/config/main.go
@@ -0,0 +1,113 @@
+package config
+
+import (
+ "merodi/src/utils"
+ "os"
+ "path/filepath"
+
+ "github.com/BurntSushi/toml"
+)
+
+type Mode int
+
+const ConfigFileName = "config.toml"
+const (
+ None Mode = iota
+ Release
+ Draft
+)
+
+type Project struct {
+ Name string `toml:"name"`
+ Version string `toml:"version"`
+ Description string `toml:"description"`
+}
+
+type Tree struct {
+ Markdown string `toml:"markdown"`
+ Templates string `toml:"templates"`
+ Plugins string `toml:"plugins"`
+ DraftDest string `toml:"draft_dest"`
+ ReleaseDest string `toml:"release_dest"`
+}
+
+type Http struct {
+ Host string `toml:"host"`
+ Port string `toml:"port"`
+}
+
+type Data struct {
+ Project Project `toml:"project"`
+ Tree Tree `toml:"tree"`
+ Http Http `toml:"http"`
+}
+
+type Config struct {
+ Data Data
+ ProjectDir string
+}
+
+func (self *Config) DefaultData() {
+ self.Data = Data{
+ Project: Project{
+ Name: "potato",
+ Version: "0.1.0",
+ Description: "Add your description here",
+ },
+ Tree: Tree{
+ Markdown: "src/md",
+ Templates: "src/templates",
+ Plugins: "src/plugins",
+ DraftDest: "build/draft",
+ ReleaseDest: "build/release",
+ },
+ Http: Http{
+ Host: "localhost",
+ Port: "8000",
+ },
+ }
+}
+
+func ModeToString(mode Mode) string {
+ switch mode {
+ case Release:
+ return "release"
+ case Draft:
+ return "Draft"
+ default:
+ return "None"
+ }
+}
+
+func (self *Config) ConfigPath() string {
+ return filepath.Join(self.ProjectDir, ConfigFileName)
+}
+
+func (self *Config) Init() error {
+
+ if err := os.MkdirAll(self.ProjectDir, 0755); err != nil {
+ return err
+ }
+ f, err := os.Create(self.ConfigPath())
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ self.DefaultData()
+ return toml.NewEncoder(f).Encode(self.Data)
+}
+
+func (self *Config) Read() error {
+ _, err := toml.DecodeFile(self.ConfigPath(), &self.Data)
+ return err
+}
+
+func (self *Config) Write() (err error) {
+ defer utils.Handle(&err)
+ f, err := os.Create(self.ConfigPath())
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ return toml.NewEncoder(f).Encode(self.Data)
+}
diff --git a/src/core/build/compile/jinja.go b/src/core/build/compile/jinja.go
new file mode 100644
index 0000000..6859023
--- /dev/null
+++ b/src/core/build/compile/jinja.go
@@ -0,0 +1,66 @@
+package compile
+
+import (
+ "strings"
+
+ "github.com/nikolalohinski/gonja/v2"
+ "github.com/nikolalohinski/gonja/v2/config"
+ "github.com/nikolalohinski/gonja/v2/exec"
+ "github.com/nikolalohinski/gonja/v2/loaders"
+ "github.com/yuin/goldmark/ast"
+)
+
+type JinjaEngine struct {
+ Loader loaders.Loader
+ Config *config.Config
+ Data map[string]any
+}
+
+func (self *Compile) InitJinjaEngine() error {
+ loader, err := loaders.NewFileSystemLoader(self.State.Config.Tree.Templates)
+ if err != nil {
+ return err
+ }
+ self.Jinja.Loader = loader
+ self.Jinja.Config = gonja.DefaultConfig
+ self.Jinja.Data = self.State.Lua.Jinja
+ return nil
+}
+func (self *JinjaEngine) CleanJinja(source []byte, doc ast.Node) ast.Node {
+ child := doc.FirstChild()
+ for child != nil {
+ next := child.NextSibling()
+ if paragraph, ok := child.(*ast.Paragraph); ok {
+ lines := paragraph.Lines()
+ for i := 0; i < lines.Len(); i++ {
+ segment := lines.At(i)
+ if strings.HasPrefix(strings.ReplaceAll(string(segment.Value(source)), " ", ""), "{%") {
+ raw := ast.NewRawHTML()
+ raw.Segments.Append(segment)
+ doc.InsertBefore(doc, child, raw)
+ } else {
+ t := ast.NewText()
+ t.Segment = segment
+ p := ast.NewParagraph()
+ p.AppendChild(p, t)
+ doc.InsertBefore(doc, child, p)
+ }
+ }
+ doc.RemoveChild(doc, child)
+ }
+ child = next
+ }
+ return doc
+}
+func (self *JinjaEngine) JinjaHandler(html_content *string) error {
+ shiftedLoader, err := loaders.NewShiftedLoader("root", strings.NewReader(*html_content), self.Loader)
+ if err != nil {
+ return err
+ }
+ tpl, err := exec.NewTemplate("root", self.Config, shiftedLoader, gonja.DefaultEnvironment)
+ if err != nil {
+ return err
+ }
+ *html_content, err = tpl.ExecuteToString(exec.NewContext(self.Data))
+ return err
+}
diff --git a/src/core/build/compile/main.go b/src/core/build/compile/main.go
new file mode 100644
index 0000000..41b062b
--- /dev/null
+++ b/src/core/build/compile/main.go
@@ -0,0 +1,110 @@
+package compile
+
+import (
+ "bytes"
+ "merodi/src/config"
+ "merodi/src/core/state"
+ "merodi/src/utils"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/renderer/html"
+ "github.com/yuin/goldmark/text"
+)
+
+type Compile struct {
+ State *state.State
+ Mode config.Mode
+ Jinja JinjaEngine
+}
+
+var (
+ handle = utils.Handle
+ check = utils.Check
+)
+
+func (self *Compile) ReadMD(mdfile string) error {
+ content, err := os.ReadFile(mdfile)
+ self.State.Lua.Build.Content = string(content)
+ return err
+}
+
+func (self *Compile) MdToHtml(md string) error {
+ var raw_html bytes.Buffer
+ mdBytes := []byte(md)
+ gm := goldmark.New(
+ goldmark.WithExtensions(self.State.Lua.Extensions...),
+ goldmark.WithRendererOptions(html.WithUnsafe()),
+ )
+ doc := gm.Parser().Parse(text.NewReader(mdBytes))
+ doc = self.Jinja.CleanJinja(mdBytes, doc)
+ err := gm.Renderer().Render(&raw_html, mdBytes, doc)
+ self.State.Lua.Build.Content = raw_html.String()
+
+ return err
+}
+
+func (self *Compile) resolveHTMLPath(mdPath string) (string, error) {
+ absMarkdown, err := filepath.Abs(self.State.Config.Tree.Markdown)
+ if err != nil {
+ return "", err
+ }
+ absMdPath, err := filepath.Abs(mdPath)
+ if err != nil {
+ return "", err
+ }
+ mdRelPath, err := filepath.Rel(absMarkdown, absMdPath)
+ if err != nil {
+ return "", err
+ }
+ destDir := self.State.Config.Tree.DraftDest
+ if self.Mode == config.Release {
+ destDir = self.State.Config.Tree.ReleaseDest
+ }
+
+ return filepath.Abs(
+ filepath.Join(
+ destDir,
+ strings.TrimSuffix(mdRelPath, ".md")+".html",
+ ),
+ )
+}
+
+func (self *Compile) HtmlToFile(htmlPath string) error {
+ if err := os.MkdirAll(filepath.Dir(htmlPath), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(htmlPath, []byte(self.State.Lua.Build.Content), 0644)
+}
+
+func (self *Compile) ResolvePaths(mdfile *string, htmlfile *string) (err error) {
+ *mdfile = filepath.Join(self.State.ProjectDir, *mdfile)
+ *htmlfile, err = self.resolveHTMLPath(*mdfile)
+ return err
+}
+
+func (self *Compile) Run(file string) (err error) {
+ defer handle(&err)
+ var mdfile = file
+ var htmlfile string
+ check(self.ResolvePaths(&mdfile, &htmlfile))
+ self.State.Lua.Build.HTMLPath = htmlfile
+ self.State.Lua.Build.MDPath = mdfile
+ check(self.State.Lua.RunHook("before_read"))
+ check(self.ReadMD(mdfile))
+ check(self.State.Lua.RunHook("read_md"))
+ check(self.MdToHtml(self.State.Lua.Build.Content))
+ check(self.State.Lua.RunHook("html_content"))
+ check(self.InitJinjaEngine())
+ check(self.Jinja.JinjaHandler(&self.State.Lua.Build.Content))
+ check(self.State.Lua.RunHook("apply_html_jinja"))
+ check(self.State.Lua.RunHook("before_write"))
+ check(self.HtmlToFile(htmlfile))
+ check(self.State.Lua.RunHook("after_write"))
+ self.State.Lua.Build.Content = ""
+ self.State.Lua.Build.HTMLPath = ""
+ self.State.Lua.Build.MDPath = ""
+ return nil
+}
diff --git a/src/core/build/main.go b/src/core/build/main.go
new file mode 100644
index 0000000..623065b
--- /dev/null
+++ b/src/core/build/main.go
@@ -0,0 +1,68 @@
+package build
+
+import (
+ "io/fs"
+ "merodi/src/config"
+ "merodi/src/core/build/compile"
+ . "merodi/src/core/state"
+ "merodi/src/utils"
+ "path/filepath"
+)
+
+type Build struct {
+ state *State
+ mode config.Mode
+ compile *compile.Compile
+}
+
+var (
+ handle = utils.Handle
+ check = utils.Check
+)
+
+func (build *Build) SetBuildMode(args *[]string) {
+ if utils.PopString(args, "--release") != "" {
+ build.mode = config.Release
+ return
+ }
+
+ build.mode = config.Draft
+}
+
+func (build *Build) WalkAndBuild() (err error) {
+ return filepath.WalkDir(build.state.Config.Tree.Markdown, func(
+ path string,
+ entry fs.DirEntry,
+ err error,
+ ) error {
+ if err != nil {
+ return err
+ }
+
+ if !entry.Type().IsRegular() && entry.Type()&fs.ModeSymlink == 0 {
+ return nil
+ }
+
+ err = build.compile.Run(path)
+ if err == utils.ErrAborted {
+ return nil
+ }
+
+ return err
+ })
+}
+
+func (build *Build) Run(state *State, args *[]string) (err error) {
+ defer handle(&err)
+
+ build.SetBuildMode(args)
+
+ build.compile = &compile.Compile{}
+ build.state = state
+ build.compile.State = build.state
+ build.state.Lua.Build.Mode = build.mode
+
+ check(build.WalkAndBuild())
+
+ return nil
+}
diff --git a/src/core/lua/build.go b/src/core/lua/build.go
new file mode 100644
index 0000000..46e138f
--- /dev/null
+++ b/src/core/lua/build.go
@@ -0,0 +1,51 @@
+package lua
+
+import (
+ glua "github.com/yuin/gopher-lua"
+ "merodi/src/config"
+)
+
+type Build struct {
+ MDPath string
+ HTMLPath string
+ Content string
+ Mode config.Mode
+}
+
+func (self *Lua) registerBuild() {
+ dataTable := self.Context.NewTable()
+ dataTable.RawSetString("mode", self.Context.NewFunction(func(L *glua.LState) int {
+ L.Push(glua.LString(config.ModeToString(self.Build.Mode)))
+ return 1
+ }))
+
+ dataTable.RawSetString("content", self.makeGetSetTable(
+ func() string { return self.Build.Content },
+ func(v string) { self.Build.Content = v },
+ ))
+ dataTable.RawSetString("md_path", self.makeGetSetTable(
+ func() string { return self.Build.MDPath },
+ func(v string) { self.Build.MDPath = v },
+ ))
+ dataTable.RawSetString("html_path", self.makeGetSetTable(
+ func() string { return self.Build.HTMLPath },
+ func(v string) { self.Build.HTMLPath = v },
+ ))
+
+ self.Merodi.RawSetString("build", dataTable)
+}
+
+func (self *Lua) makeGetSetTable(get func() string, set func(string)) *glua.LTable {
+ t := self.Context.NewTable()
+ self.Context.SetFuncs(t, map[string]glua.LGFunction{
+ "get": func(L *glua.LState) int {
+ L.Push(glua.LString(get()))
+ return 1
+ },
+ "set": func(L *glua.LState) int {
+ set(L.ToString(1))
+ return 0
+ },
+ })
+ return t
+}
diff --git a/src/core/lua/config.go b/src/core/lua/config.go
new file mode 100644
index 0000000..4402970
--- /dev/null
+++ b/src/core/lua/config.go
@@ -0,0 +1,31 @@
+package lua
+
+import (
+ lua "github.com/yuin/gopher-lua"
+)
+
+func (self *Lua) registerConfigTree() *lua.LTable {
+ tree := self.Context.NewTable()
+ tree.RawSetString("markdown", lua.LString(self.Config.Tree.Markdown))
+ tree.RawSetString("templates", lua.LString(self.Config.Tree.Templates))
+ tree.RawSetString("draft_dest", lua.LString(self.Config.Tree.DraftDest))
+ tree.RawSetString("release_dest", lua.LString(self.Config.Tree.ReleaseDest))
+ return tree
+}
+
+func (self *Lua) registerConfigProject() *lua.LTable {
+ projectTable := self.Context.NewTable()
+ projectTable.RawSetString("name", lua.LString(self.Config.Project.Name))
+ projectTable.RawSetString("version", lua.LString(self.Config.Project.Version))
+ projectTable.RawSetString("description", lua.LString(self.Config.Project.Description))
+ return projectTable
+}
+
+func (self *Lua) registerConfig() {
+ configTable := self.Context.NewTable()
+
+ configTable.RawSetString("tree", self.registerConfigTree())
+ configTable.RawSetString("project", self.registerConfigProject())
+
+ self.Merodi.RawSetString("config", configTable)
+}
diff --git a/src/core/lua/converter.go b/src/core/lua/converter.go
new file mode 100644
index 0000000..e95c0ef
--- /dev/null
+++ b/src/core/lua/converter.go
@@ -0,0 +1,52 @@
+package lua
+
+import (
+ lua "github.com/yuin/gopher-lua"
+)
+
+func gluaToGo(v lua.LValue) any {
+ switch v.Type() {
+ case lua.LTString:
+ return v.String()
+ case lua.LTNumber:
+ return float64(v.(lua.LNumber))
+ case lua.LTBool:
+ return bool(v.(lua.LBool))
+ case lua.LTTable:
+ return gluaTableToGo(v.(*lua.LTable))
+ default:
+ return v.String()
+ }
+}
+
+func gluaTableToGo(t *lua.LTable) any {
+ maxN := t.Len()
+ if maxN > 0 {
+ arr := make([]any, 0, maxN)
+ for i := 1; i <= maxN; i++ {
+ arr = append(arr, gluaToGo(t.RawGetInt(i)))
+ }
+ return arr
+ }
+
+ m := make(map[string]any)
+ t.ForEach(func(k, v lua.LValue) {
+ m[k.String()] = gluaToGo(v)
+ })
+ return m
+}
+
+func goToGlua(v any) lua.LValue {
+ switch val := v.(type) {
+ case string:
+ return lua.LString(val)
+ case float64:
+ return lua.LNumber(val)
+ case bool:
+ return lua.LBool(val)
+ case nil:
+ return lua.LNil
+ default:
+ return lua.LNil
+ }
+}
diff --git a/src/core/lua/extensions.go b/src/core/lua/extensions.go
new file mode 100644
index 0000000..d96833b
--- /dev/null
+++ b/src/core/lua/extensions.go
@@ -0,0 +1,38 @@
+package lua
+
+import (
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ glua "github.com/yuin/gopher-lua"
+)
+
+var extensionsMap = map[string]goldmark.Extender{
+ "Table": extension.Table,
+ "Strikethrough": extension.Strikethrough,
+ "Linkify": extension.Linkify,
+ "TaskList": extension.TaskList,
+ "GFM": extension.GFM,
+ "DefinitionList": extension.DefinitionList,
+ "Footnote": extension.Footnote,
+ "Typographer": extension.Typographer,
+ "CJK": extension.CJK,
+}
+
+func (self *Lua) registerExtensions() {
+ extensionTable := self.Context.NewTable()
+ meta := self.Context.NewTable()
+ self.Context.SetField(meta, "__index", self.Context.NewFunction(
+ func(L *glua.LState) int {
+ name := L.CheckString(2)
+ ext, ok := extensionsMap[name]
+ if !ok {
+ L.RaiseError("unknown extension: %s", name)
+ return 0
+ }
+ self.Extensions = append(self.Extensions, ext)
+ return 0
+ },
+ ))
+ extensionTable.Metatable = meta
+ self.Merodi.RawSetString("extensions", extensionTable)
+}
diff --git a/src/core/lua/hook.go b/src/core/lua/hook.go
new file mode 100644
index 0000000..fe1da7b
--- /dev/null
+++ b/src/core/lua/hook.go
@@ -0,0 +1,47 @@
+package lua
+
+import (
+ "merodi/src/utils"
+ "merodi/src/utils/log"
+
+ lua "github.com/yuin/gopher-lua"
+)
+
+func (self *Lua) registerHook(L *lua.LState) int {
+ stage := L.ToString(1)
+ fn := L.ToFunction(2)
+ self.Hooks[stage] = fn
+ return 0
+}
+
+func (self *Lua) luaAbort(L *lua.LState) int {
+ log.Warn(L.ToString(1))
+ self.Aborted = true
+ panic(utils.ErrAborted)
+}
+
+func (self *Lua) registerHooks() {
+ self.Context.SetFuncs(self.Merodi, map[string]lua.LGFunction{
+ "hook": self.registerHook,
+ "abort": self.luaAbort,
+ })
+}
+
+func (self *Lua) RunHook(stage string) (err error) {
+ fn, ok := self.Hooks[stage]
+ if !ok {
+ return nil
+ }
+
+ self.Aborted = false
+ err = self.Context.CallByParam(lua.P{
+ Fn: fn,
+ NRet: 0,
+ Protect: true,
+ })
+ if self.Aborted {
+ return utils.ErrAborted
+ }
+
+ return err
+}
diff --git a/src/core/lua/http.go b/src/core/lua/http.go
new file mode 100644
index 0000000..fe43e05
--- /dev/null
+++ b/src/core/lua/http.go
@@ -0,0 +1,129 @@
+package lua
+
+import (
+ "merodi/src/utils/log"
+
+ glua "github.com/yuin/gopher-lua"
+)
+
+type Request struct {
+ RemoteAddr string
+ Body string
+ Method string
+}
+
+type Response struct {
+ Mime string
+ Body string
+ Status int
+}
+
+type Settings struct {
+ Host string
+ Port string
+}
+
+type Http struct {
+ Settings Settings
+ Table *glua.LTable
+ Route map[string]*glua.LFunction
+}
+
+func (self *Lua) registerHttp() {
+ self.Http.Table = self.Context.NewTable()
+
+ self.Http.Route = make(map[string]*glua.LFunction)
+ self.httpSettings()
+ self.httpRoute()
+ self.Merodi.RawSetString("http", self.Http.Table)
+}
+
+func (self *Lua) httpSettings() {
+ settingsTable := self.Context.NewTable()
+
+ settingsTable.RawSetString("host", self.makeGetSetTable(
+ func() string { return self.Http.Settings.Host },
+ func(v string) { self.Http.Settings.Host = v },
+ ))
+
+ settingsTable.RawSetString("port", self.makeGetSetTable(
+ func() string { return self.Http.Settings.Port },
+ func(v string) { self.Http.Settings.Port = v },
+ ))
+
+ self.Http.Table.RawSetString("settings", settingsTable)
+}
+
+func (self *Lua) httpRequest(req Request) {
+ requestTable := self.Context.NewTable()
+
+ self.Context.SetFuncs(requestTable, map[string]glua.LGFunction{
+ "body": func(L *glua.LState) int {
+ L.Push(glua.LString(req.Body))
+ return 1
+ },
+ "remote_addr": func(L *glua.LState) int {
+ L.Push(glua.LString(req.RemoteAddr))
+ return 1
+ },
+ "method": func(L *glua.LState) int {
+ L.Push(glua.LString(req.Method))
+ return 1
+ },
+ })
+
+ self.Http.Table.RawSetString("request", requestTable)
+}
+
+func (self *Lua) httpRoute() {
+ self.Http.Table.RawSetString("route", self.Context.NewFunction(func(L *glua.LState) int {
+ path := L.CheckString(1)
+ fn := L.CheckFunction(2)
+ self.Http.Route[path] = fn
+ return 0
+ }))
+}
+
+func (self *Lua) RunRoute(path string, req Request) (Response, error) {
+ fn, ok := self.Http.Route[path]
+ if !ok {
+ return Response{Mime: "text/plain", Body: "this page does not exists", Status: 404}, nil
+ }
+
+ self.httpRequest(req)
+ err := self.Context.CallByParam(glua.P{
+ Fn: fn,
+ NRet: 1,
+ Protect: true,
+ })
+
+ if err != nil {
+ log.Warn(log.Title("HTTP"), "route: %s", err)
+ return Response{}, err
+ }
+
+ ret := self.Context.Get(-1)
+ self.Context.Pop(1)
+
+ resp := Response{
+ Mime: "text/plain",
+ Body: "",
+ Status: 200,
+ }
+
+ if t, ok := ret.(*glua.LTable); ok {
+ if v := t.RawGetString("mime"); v != glua.LNil {
+ resp.Mime = v.String()
+ }
+ if v := t.RawGetString("body"); v != glua.LNil {
+ resp.Body = v.String()
+ }
+ if v := t.RawGetString("status"); v != glua.LNil {
+ if n, ok := v.(glua.LNumber); ok {
+ resp.Status = int(n)
+ }
+ }
+ }
+
+ return resp, nil
+}
diff --git a/src/core/lua/jinja.go b/src/core/lua/jinja.go
new file mode 100644
index 0000000..60a334a
--- /dev/null
+++ b/src/core/lua/jinja.go
@@ -0,0 +1,27 @@
+package lua
+
+import (
+ lua "github.com/yuin/gopher-lua"
+)
+
+func (self *Lua) registerJinja() {
+ jinja := self.Context.NewTable()
+ self.Context.SetFuncs(jinja, map[string]lua.LGFunction{
+ "set": self.luaJinjaSet,
+ "get": self.luaJinjaGet,
+ })
+ self.Merodi.RawSetString("jinja", jinja)
+}
+
+func (self *Lua) luaJinjaSet(L *lua.LState) int {
+ key := L.ToString(1)
+ value := gluaToGo(L.Get(2))
+ self.Jinja[key] = value
+ return 0
+}
+
+func (self *Lua) luaJinjaGet(L *lua.LState) int {
+ key := L.ToString(1)
+ L.Push(goToGlua(self.Jinja[key]))
+ return 1
+}
diff --git a/src/core/lua/log.go b/src/core/lua/log.go
new file mode 100644
index 0000000..4dd7d8c
--- /dev/null
+++ b/src/core/lua/log.go
@@ -0,0 +1,36 @@
+package lua
+
+import (
+ "merodi/src/utils/log"
+
+ lua "github.com/yuin/gopher-lua"
+)
+
+func (self *Lua) luaLogDie(L *lua.LState) int {
+ msg := L.CheckString(1)
+ log.Die(msg)
+ return 0
+}
+
+func (self *Lua) luaLogWarn(L *lua.LState) int {
+ msg := L.CheckString(1)
+ log.Warn(msg)
+ return 0
+}
+
+func (self *Lua) luaLogInfo(L *lua.LState) int {
+ msg := L.CheckString(1)
+ log.Info("%s", msg)
+ return 0
+}
+
+func (self *Lua) registerLog() {
+ logTable := self.Context.NewTable()
+ self.Context.SetFuncs(logTable, map[string]lua.LGFunction{
+ "info": self.luaLogInfo,
+ "warn": self.luaLogWarn,
+ "die": self.luaLogDie,
+ })
+
+ self.Merodi.RawSetString("log", logTable)
+}
diff --git a/src/core/lua/main.go b/src/core/lua/main.go
new file mode 100644
index 0000000..51a7874
--- /dev/null
+++ b/src/core/lua/main.go
@@ -0,0 +1,48 @@
+package lua
+
+import (
+ "merodi/src/config"
+ "path/filepath"
+
+ "github.com/yuin/goldmark"
+ glua "github.com/yuin/gopher-lua"
+)
+
+type Lua struct {
+ Context *glua.LState
+ Config *config.Data
+
+ Hooks map[string]*glua.LFunction
+ Jinja map[string]any
+ Build *Build
+ Http *Http
+ Extensions []goldmark.Extender
+
+ Merodi *glua.LTable
+ Aborted bool
+}
+
+func (self *Lua) InitLua(cfg *config.Data) error {
+ self.Config = cfg
+ self.Hooks = make(map[string]*glua.LFunction)
+ self.Jinja = make(map[string]any)
+ self.Context = glua.NewState()
+ self.Http = &Http{}
+ self.Build = &Build{}
+ self.Aborted = false
+
+ self.registerMerodi()
+ return self.Context.DoFile(filepath.Join(cfg.Tree.Plugins, "main.lua"))
+}
+
+func (self *Lua) registerMerodi() {
+ self.Merodi = self.Context.NewTable()
+ self.registerHooks()
+ self.registerConfig()
+ self.registerJinja()
+ self.registerBuild()
+ self.registerHttp()
+ self.registerExtensions()
+ self.registerLog()
+ self.Context.SetGlobal("merodi", self.Merodi)
+}
diff --git a/src/core/main.go b/src/core/main.go
new file mode 100644
index 0000000..515ecb4
--- /dev/null
+++ b/src/core/main.go
@@ -0,0 +1,42 @@
+package core
+
+import (
+ "merodi/src/core/build"
+ "merodi/src/core/lua"
+ "merodi/src/core/serve"
+ "merodi/src/core/state"
+ "merodi/src/utils"
+)
+
+var (
+ handle = utils.Handle
+ check = utils.Check
+)
+
+type Core state.State
+type Action interface {
+ Run(state *state.State, args *[]string) error
+}
+
+var Actions = map[string]Action{
+ "build": &build.Build{},
+ "serve": &serve.Serve{},
+}
+
+func Run(act string, args *[]string) (err error) {
+ defer handle(&err)
+ self := Core{Lua: &lua.Lua{}}
+ action, ok := Actions[act]
+ if !ok {
+ utils.PrintHelp()
+ return
+ }
+ check(self.GoToProjectDir(args))
+ check(self.LoadConfig())
+
+ check(self.Lua.InitLua(&self.Config))
+ defer self.Lua.Context.Close()
+
+ check(action.Run((*state.State)(&self), args))
+ return nil
+}
diff --git a/src/core/serve/main.go b/src/core/serve/main.go
new file mode 100644
index 0000000..b461871
--- /dev/null
+++ b/src/core/serve/main.go
@@ -0,0 +1,59 @@
+package serve
+
+import (
+ "fmt"
+ "io"
+ "merodi/src/core/lua"
+ "merodi/src/core/state"
+ "merodi/src/utils"
+ "merodi/src/utils/log"
+ "net/http"
+)
+
+var (
+ handle = utils.Handle
+ check = utils.Check
+)
+
+type Serve struct {
+ State *state.State
+}
+
+func (self *Serve) handler(w http.ResponseWriter, r *http.Request) {
+ defer r.Body.Close()
+ r.Body = http.MaxBytesReader(w, r.Body, 20<<20)
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.Warn(log.Title("HTTP"), "%s", err)
+ return
+ }
+ requests := lua.Request{}
+ requests.Method = r.Method
+ requests.Body = string(body)
+ requests.RemoteAddr = r.RemoteAddr
+ response, err := self.State.Lua.RunRoute(r.URL.Path, requests)
+ if err != nil {
+ log.Warn(log.Title("HTTP"), "%s", err)
+ return
+ }
+ w.Header().Set("Content-Type", response.Mime)
+ w.WriteHeader(response.Status)
+ _, err = w.Write([]byte(response.Body))
+ if err != nil {
+ log.Warn(log.Title("HTTP"), "%s", err)
+ }
+}
+func (self *Serve) StartHTTP() (err error) {
+ defer handle(&err)
+ host := self.State.Lua.Http.Settings.Host
+ port := self.State.Lua.Http.Settings.Port
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", self.handler)
+ return http.ListenAndServe(fmt.Sprintf("%s:%s", host, port), mux)
+}
+func (self *Serve) Run(state *state.State, args *[]string) (err error) {
+ defer handle(&err)
+ self.State = state
+ check(self.StartHTTP())
+ return err
+}
diff --git a/src/core/setup.go b/src/core/setup.go
new file mode 100644
index 0000000..a81e2d7
--- /dev/null
+++ b/src/core/setup.go
@@ -0,0 +1,27 @@
+package core
+
+import (
+ "merodi/src/config"
+ "merodi/src/project"
+ "os"
+)
+
+func (self *Core) GoToProjectDir(args *[]string) error {
+ dir, err := project.SetProjectDir(args)
+
+ if err != nil {
+ return err
+ }
+ self.ProjectDir = dir
+ return os.Chdir(dir)
+}
+
+func (self *Core) LoadConfig() error {
+ var cfg config.Config
+ cfg.ProjectDir = self.ProjectDir
+ if err := cfg.Read(); err != nil {
+ return err
+ }
+ self.Config = cfg.Data
+ return nil
+}
diff --git a/src/core/state/main.go b/src/core/state/main.go
new file mode 100644
index 0000000..c3bd30a
--- /dev/null
+++ b/src/core/state/main.go
@@ -0,0 +1,12 @@
+package state
+
+import (
+ . "merodi/src/config"
+ "merodi/src/core/lua"
+)
+
+type State struct {
+ ProjectDir string
+ Config Data
+ Lua *lua.Lua
+}
diff --git a/src/errors.py b/src/errors.py
deleted file mode 100644
index 6d863b9..0000000
--- a/src/errors.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from os.path import basename
-from html import escape
-import traceback
-
-from .log import *
-from . import settings
-from .api import api
-
-def get_user_frame(exc):
- 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] if tb else None
-
-def get_error_line(exc):
- from jinja2 import TemplateSyntaxError
- if isinstance(exc, TemplateSyntaxError) and exc.source:
- lines = exc.source.splitlines()
- n = exc.lineno or 1
- if n <= len(lines):
- return lines[n - 1].strip()
- if hasattr(exc, 'source') and exc.source:
- lines = exc.source.splitlines()
- n = getattr(exc, 'lineno', 1) or 1
- if n <= len(lines):
- return lines[n - 1].strip()
- frame = get_user_frame(exc)
- return frame.line.strip() if frame and frame.line else None
-
-def fatal(exc, message):
- frame = get_user_frame(exc)
- source_line = get_error_line(exc)
- detail = str(exc.message) if hasattr(exc, 'message') else str(exc)
-
- print(f"{RED('!')} {RED(type(exc).__name__)}")
- if frame:
- print(f"{BLUE(frame.name + '()')} {GRAY('—')} {basename(frame.filename)}:{YELLOW(str(frame.lineno))}")
- if detail:
- print(f"{GRAY('│')} {detail}")
- if source_line:
- print(f"{GRAY('│')} {source_line}\n")
- die(message)
-
-def html_fatal(exc, message):
- if api.mode == "release":
- fatal(exc, message)
-
- frame = get_user_frame(exc)
- source_line = get_error_line(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))
-
- if frame:
- location = f"""<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>"""
- else:
- location = """<div style="background: #2a2a2a; border-radius: 6px; overflow: hidden;">
- <div style="background: #333; padding: 0.5rem 1rem;">
- <span style="color: #888;">no source location available</span>
- </div>
- </div>"""
-
- 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>
- {location}
- </div>
- </body>
- </html>"""
diff --git a/src/fileops.py b/src/fileops.py
deleted file mode 100644
index d426346..0000000
--- a/src/fileops.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from os import makedirs, path, sep
-from .modules import Tree
-
-def is_dotfile(file_path):
- return any(part.startswith(".") for part in path.normpath(file_path).split(sep))
-
-def write_file(file_path, file_content):
- with open(file_path, "w") as f:
- f.write(file_content)
-
-def read_file(file_path):
- with open(file_path, "r") as f:
- return f.read()
-
-def resolve_tree_paths(project_dir, tree):
- return Tree(
- markdown=path.join(project_dir, tree.markdown),
- static=path.join(project_dir, tree.static),
- templates=path.join(project_dir, tree.templates),
- draft_dest=path.join(project_dir, tree.draft_dest),
- release_dest=path.join(project_dir, tree.release_dest),
- plugins=path.join(project_dir, tree.plugins),
- )
-
-def create_tree_dirs(tree):
- for d in [tree.markdown, tree.static, tree.templates, tree.draft_dest, tree.release_dest, tree.plugins]:
- makedirs(d, exist_ok=True)
diff --git a/src/hash.py b/src/hash.py
deleted file mode 100644
index 6d1094f..0000000
--- a/src/hash.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import hashlib
-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
-
-
-def read_hash_file(config):
- global HASH_FILE_CONTENT
- if HASH_FILE_CONTENT is None:
- try:
- with open(config.cache.hash, "r") as f:
- HASH_FILE_CONTENT = json.load(f)
- except (FileNotFoundError, json.JSONDecodeError):
- HASH_FILE_CONTENT = {}
-
-
-def write_hash_file(config):
- global HASH_FILE_CONTENT
- write_file(config.cache.hash, json.dumps(HASH_FILE_CONTENT, indent=2))
-
-def clear_all_hashes(config):
- global HASH_FILE_CONTENT
- HASH_FILE_CONTENT = {}
- write_hash_file(config)
-
-def clear_file_hash(md_path):
- global HASH_FILE_CONTENT
- if HASH_FILE_CONTENT is not None and md_path in HASH_FILE_CONTENT:
- del HASH_FILE_CONTENT[md_path]
-
-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(config, md_path):
- global HASH_FILE_CONTENT
- try:
- hash_dir = config.cache.hash
- if config is None:
- return
- if not (path.exists(hash_dir) and path.isfile(hash_dir)):
- write_file(hash_dir, "{}")
-
- read_hash_file(config)
-
- if HASH_FILE_CONTENT is None:
- HASH_FILE_CONTENT = {}
-
- 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:
- return None
-
- 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:
- warn(f"Hashing failed: {e}")
diff --git a/src/hooks.py b/src/hooks.py
deleted file mode 100644
index 7700d33..0000000
--- a/src/hooks.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from .log import GRAY, warn
-
-DEFAULT_PRIORITY = 100
-
-hooks = {
- "on_build_end" : [],
- "on_build_start" : [],
- "on_compile_start" : [],
- "on_config_load" : [],
- "on_end" : [],
- "on_file_changed" : [],
- "on_hash_check" : [],
- "on_hash_written" : [],
- "on_highlight_config" : [],
- "on_html_filter" : [],
- "on_jinja_error" : [],
- "on_jinja_template_dir" : [],
- "on_jinja_template_renderer": [],
- "on_math_renderer" : [],
- "on_md_to_html" : [],
- "on_page_built" : [],
- "on_page_read" : [],
- "on_page_rendered" : [],
- "on_page_skip" : [],
- "on_page_written" : [],
- "on_plugin_changed" : [],
- "on_plugins_before_export" : [],
- "on_plugins_loaded" : [],
- "on_reload_error" : [],
- "on_start" : [],
- "on_walk_end" : [],
- "on_walk_file" : [],
- "on_walk_start" : [],
- "on_watch_start" : [],
- "on_watch_stop" : [],
-}
-
-def hook(hook_name: str, priority: int = DEFAULT_PRIORITY):
- if hook_name not in hooks:
- warn(f"Skipping hook `{GRAY(hook_name)}`: hook does not exist")
- return lambda fn: fn
-
- def hook_fn(fn):
- hooks[hook_name].append((priority, fn))
- hooks[hook_name].sort(key=lambda x: x[0])
- return fn
- return hook_fn
-
-def hook_call(hook_name, *args, **kwargs):
- subscribers = hooks.get(hook_name, [])
- if not subscribers:
- return None
- result = None
- for _priority, fn in subscribers:
- try:
- result = fn(*args, **kwargs)
- except Exception as e:
- warn(f"Hook '{hook_name}' failed in {fn.__name__}: {e}")
- return result
-
-def reset_hooks():
- for key in hooks:
- hooks[key] = []
-
-def remove_hook(hook_name, fn):
- if hook_name in hooks:
- hooks[hook_name] = [(p, f) for p, f in hooks[hook_name] if f is not fn]
diff --git a/src/init/bootstrap.go b/src/init/bootstrap.go
new file mode 100644
index 0000000..1df5229
--- /dev/null
+++ b/src/init/bootstrap.go
@@ -0,0 +1,49 @@
+package init
+
+import (
+ "merodi/src/config"
+ "os"
+ "path/filepath"
+)
+
+func (init *Init) CreateDirectories(tree config.Tree) error {
+ for _, dir := range []string{
+ tree.Markdown,
+ tree.Templates,
+ tree.Plugins,
+ } {
+ path := filepath.Join(init.ProjectDir, dir)
+
+ if err := os.MkdirAll(path, 0755); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (init *Init) CreateFiles(tree config.Tree) error {
+ files := map[string]string{
+ filepath.Join(init.ProjectDir, tree.Markdown, "index.md"): MarkdownContent,
+ filepath.Join(init.ProjectDir, tree.Templates, "layout.html"): HTMLContent,
+ filepath.Join(init.ProjectDir, tree.Plugins, "main.lua"): PluginsContent,
+ }
+
+ for path, content := range files {
+ if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (self *Init) Bootstrap() error {
+ err := self.CreateDirectories(self.cfg)
+ if err != nil {
+ return err
+ }
+
+ err = self.CreateFiles(self.cfg)
+ return err
+}
diff --git a/src/init/constants.go b/src/init/constants.go
new file mode 100644
index 0000000..90c0a52
--- /dev/null
+++ b/src/init/constants.go
@@ -0,0 +1,24 @@
+package init
+
+const (
+ MarkdownContent = `
+{% set page_title = "Merodi" %}
+{% set metadata_desc = "A markdown-based static site generator." %}
+
+{% block body %}
+# Hello, Alice
+{% endblock %}`
+
+ HTMLContent = `<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="description" content="{{ metadata_desc }}">
+ <title>{{ page_title }}</title>
+ </head>
+ <body>
+ {% block body %}{% endblock %}
+ </body>
+</html>`
+
+ PluginsContent = ``
+)
diff --git a/src/init/main.go b/src/init/main.go
new file mode 100644
index 0000000..0d0b7da
--- /dev/null
+++ b/src/init/main.go
@@ -0,0 +1,45 @@
+package init
+
+import (
+ "fmt"
+ "merodi/src/config"
+ "merodi/src/project"
+ "merodi/src/utils"
+ "merodi/src/utils/log"
+)
+
+type Init struct {
+ ProjectDir string
+ cfg config.Tree
+}
+
+var (
+ handle = utils.Handle
+ check = utils.Check
+)
+
+func (init *Init) GetProjectDir(args *[]string) error {
+ var err error
+ init.ProjectDir, err = project.SetProjectDir(args)
+ if project.IsProjectExists(init.ProjectDir) {
+ err = fmt.Errorf("project already initialized: `config.toml` already exists")
+ }
+ return err
+}
+
+func (init *Init) InitConfig() error {
+ cfg := config.Config{}
+ cfg.ProjectDir = init.ProjectDir
+ cfg.Init()
+ init.cfg = cfg.Data.Tree
+ return cfg.Write()
+}
+
+func (init Init) Run(args *[]string) (err error) {
+ defer handle(&err)
+ check(init.GetProjectDir(args))
+ check(init.InitConfig())
+ check(init.Bootstrap())
+ log.Info("Project initialized.")
+ return nil
+}
diff --git a/src/init_project.py b/src/init_project.py
deleted file mode 100644
index 2ac7973..0000000
--- a/src/init_project.py
+++ /dev/null
@@ -1,109 +0,0 @@
-from os import path, getcwd, makedirs
-
-from .modules import Cache, Config, Project, Tree, Webview, Extras
-from .log import *
-from .errors import fatal
-from .templates import *
-from .fileops import *
-
-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",
- draft_dest = "build/draft",
- release_dest = "build/release",
- plugins = "src/plugins",
- ),
- webview = Webview(
- host = "localhost",
- port = 8866,
- dev_tools = False,
- html_path = "/",
- static_path = "/static"
- ),
- extras = Extras(
- highlight = "monokai"
- ),
- cache = Cache(
- hash = ".hash.cache"
- )
- )
-
-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}"
-draft_dest = "{config.tree.draft_dest}"
-release_dest = "{config.tree.release_dest}"
-plugins = "{config.tree.plugins}"
-
-[webview]
-host = "{config.webview.host}"
-port = {config.webview.port}
-dev_tools = {str(config.webview.dev_tools).lower()}
-html_path = "{config.webview.html_path}"
-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:
- """raise FileExistsError when config file exists"""
- if path.exists(file_path):
- raise Exception("Project already initialized: `config.toml` already exists")
-
-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 )
-
- plugins_file = path.join(tree.plugins, "main.py")
- write_file(plugins_file,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
deleted file mode 100644
index 49cad02..0000000
--- a/src/log.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from . import settings
-
-def info(message: str, title="INFO", **kwarg):
- print(f"{BLUE(f'[{title}]:')} {message}", **kwarg, flush=True)
-
-def warn(message: str, title="WARN", **kwarg):
- print(f"{YELLOW(f'[{title}]:')} {message}", **kwarg, flush=True)
-
-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 progress(iterable, label="Progress"):
- total = len(iterable)
- for i, item in enumerate(iterable, 1):
- print(f"{BLUE(f'[{label}]')}: [{i}/{total}]", end="\r", flush=True)
- yield item
-
-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.go b/src/main.go
new file mode 100644
index 0000000..f6e6167
--- /dev/null
+++ b/src/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+ "merodi/src/core"
+ . "merodi/src/init"
+ "merodi/src/utils"
+ "merodi/src/utils/log"
+ "os"
+)
+
+const VERSION = "0.5.0"
+
+func main() {
+ args := os.Args[1:]
+ if len(args) == 0 {
+ utils.PrintHelp()
+ return
+ }
+ pos := utils.Pop(&args, 0)
+
+ var err error
+ switch pos {
+ case "init", "new":
+ init := Init{}
+ err = init.Run(&args)
+
+ case "version":
+ log.Info(log.Title("Version"), "Merodi %s", VERSION)
+
+ default:
+ err = core.Run(pos, &args)
+ }
+
+ if err != nil {
+ log.Die(err.Error())
+ }
+}
diff --git a/src/main.py b/src/main.py
deleted file mode 100755
index 7249b78..0000000
--- a/src/main.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import sys
-sys.dont_write_bytecode = True
-
-import atexit
-from argparse import ArgumentParser
-from os import environ
-from .log import *
-from . import settings
-from .hooks import hook_call
-
-def main():
- common = ArgumentParser(add_help=False)
- common.add_argument("--verbose", action="store_true")
- common.add_argument("--no-color", action="store_true")
-
- parser = ArgumentParser()
- parser.add_argument("--version", action="store_true")
- sub_parser = parser.add_subparsers(dest="command")
-
- 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="?")
-
- webview_parser = sub_parser.add_parser("watch", parents=[common])
- webview_parser.add_argument("path", nargs="?")
-
- build_parser = sub_parser.add_parser("build", parents=[common])
- build_mode = build_parser.add_mutually_exclusive_group()
- build_mode.add_argument("--draft", action="store_true", default=True)
- build_mode.add_argument("--release", action="store_true")
- build_parser.add_argument("--validate", action="store_true")
- build_parser.add_argument("--force", action="store_true")
- build_parser.add_argument("path", nargs="?")
- build_parser.add_argument("--file", nargs=2, metavar=("SRC", "DEST"))
-
- args = parser.parse_args()
-
- if args.version:
- from importlib.metadata import version
- info(version("merodi"), title="VERSION")
- return
-
- if args.command is None:
- parser.print_help()
- return
-
- 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"])
-
- hook_call("on_start")
-
- if args.command == "init":
- from .init_project import init
- init(project_path = args.path)
-
- elif args.command == "build":
- from .api import api
- mode = "release" if args.release else "draft"
- api.mode = mode
- from .build import build
- build(mode = mode, project_path = args.path, file=args.file, validate = args.validate, force = args.force)
-
- elif args.command == "webview":
- from .webviewer import run
- run(args.path)
-
- elif args.command == "watch":
- from .watcher import run_watcher
- run_watcher(args.path)
-
- atexit.register(lambda: hook_call("on_end"))
diff --git a/src/modules.py b/src/modules.py
deleted file mode 100644
index 8267fc1..0000000
--- a/src/modules.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from dataclasses import dataclass
-
-@dataclass
-class Project:
- name: str
- version: str
- description: str
-
-@dataclass
-class Tree:
- markdown: str
- static: str
- templates: str
- draft_dest: str
- release_dest: str
- plugins: str
-
-@dataclass
-class Webview:
- host: str
- port: int
- dev_tools: str
- html_path: str
- static_path: str
-
-@dataclass
-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/project/main.go b/src/project/main.go
new file mode 100644
index 0000000..8651a1e
--- /dev/null
+++ b/src/project/main.go
@@ -0,0 +1,23 @@
+package project
+
+import (
+ "merodi/src/utils"
+ "merodi/src/utils/path"
+ "os"
+ "path/filepath"
+)
+
+func SetProjectDir(args *[]string) (string, error) {
+ projectPath := path.Getwd()
+ if len(*args) > 0 {
+ projectPath = utils.Pop(args, 0)
+ }
+ return filepath.Abs(projectPath)
+}
+
+func IsProjectExists(path string) bool {
+ configPath := filepath.Join(path, "config.toml")
+
+ _, err := os.Stat(configPath)
+ return err == nil
+}
diff --git a/src/settings.py b/src/settings.py
deleted file mode 100644
index c02319b..0000000
--- a/src/settings.py
+++ /dev/null
@@ -1,2 +0,0 @@
-VERBOSE = False
-NO_COLOR = False
diff --git a/src/templates.py b/src/templates.py
deleted file mode 100644
index c722192..0000000
--- a/src/templates.py
+++ /dev/null
@@ -1,38 +0,0 @@
-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/utils/log/main.go b/src/utils/log/main.go
new file mode 100644
index 0000000..173eec1
--- /dev/null
+++ b/src/utils/log/main.go
@@ -0,0 +1,61 @@
+package log
+
+import (
+ "fmt"
+ "io"
+ "os"
+)
+
+var file io.Writer = os.Stdout
+
+type Title string
+
+func Info(args ...any) {
+ title := "INFO"
+
+ if t, ok := args[0].(Title); ok {
+ title = string(t)
+ args = args[1:]
+ }
+
+ msg := fmt.Sprintf(args[0].(string), args[1:]...)
+ fmt.Fprintf(file, "[%s]: %s\n", blue(title), msg)
+}
+
+func Warn(args ...any) {
+ title := "WARN"
+
+ if t, ok := args[0].(Title); ok {
+ title = string(t)
+ args = args[1:]
+ }
+
+ msg := fmt.Sprintf(args[0].(string), args[1:]...)
+ fmt.Fprintf(file, "[%s]: %s\n", yellow(title), msg)
+}
+
+func Die(args ...any) {
+ title := "ERROR"
+
+ if t, ok := args[0].(Title); ok {
+ title = string(t)
+ args = args[1:]
+ }
+
+ msg := fmt.Sprintf(args[0].(string), args[1:]...)
+ fmt.Fprintf(file, "[%s]: %s\n", red(title), msg)
+
+ os.Exit(1)
+}
+
+func blue(msg string) string {
+ return fmt.Sprintf("\033[34m%s\033[0m", msg)
+}
+
+func yellow(msg string) string {
+ return fmt.Sprintf("\033[33m%s\033[0m", msg)
+}
+
+func red(msg string) string {
+ return fmt.Sprintf("\033[31m%s\033[0m", msg)
+}
diff --git a/src/utils/main.go b/src/utils/main.go
new file mode 100644
index 0000000..26780d1
--- /dev/null
+++ b/src/utils/main.go
@@ -0,0 +1,45 @@
+package utils
+
+import (
+ "errors"
+ "merodi/src/utils/log"
+)
+
+var ErrAborted = errors.New("build aborted")
+
+func Handle(err *error) {
+ if r := recover(); r != nil {
+ if e, ok := r.(error); ok {
+ *err = e
+ } else {
+ panic(r)
+ }
+ }
+}
+
+func Check(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
+
+func PrintHelp() {
+ log.Info(log.Title("Usage"), ": merodi {init,build,serve,version} <path> ...")
+}
+
+func Pop(args *[]string, idx int) string {
+ value := (*args)[idx]
+ *args = append((*args)[:idx], (*args)[idx+1:]...)
+ return value
+}
+
+func PopString(args *[]string, value string) string {
+ for i, arg := range *args {
+ if arg == value {
+ *args = append((*args)[:i], (*args)[i+1:]...)
+ return value
+ }
+ }
+
+ return ""
+}
diff --git a/src/utils/path/main.go b/src/utils/path/main.go
new file mode 100644
index 0000000..db034e5
--- /dev/null
+++ b/src/utils/path/main.go
@@ -0,0 +1,15 @@
+package path
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func Getwd() string {
+ dir, err := os.Getwd()
+ if err != nil {
+ return filepath.Clean(".")
+ }
+
+ return dir
+}
diff --git a/src/watcher.py b/src/watcher.py
deleted file mode 100644
index ecdf72e..0000000
--- a/src/watcher.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from os import chdir, getcwd, path
-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, build_all
-from .log import warn
-from .errors import fatal
-
-current_page = None
-_plugins = None
-
-def page_to_url(filesystem_path, config):
- rel = path.relpath(filesystem_path, config.tree.markdown)
- return path.splitext(rel)[0] + ".html"
-
-def is_template_or_plugin(file_path, config):
- return (file_path.startswith(config.tree.templates) or
- file_path.startswith(config.tree.plugins))
-
-def single_file_reload(changed_path, config):
- global current_page
- try:
- if changed_path.endswith(".md"):
- current_page = changed_path
- if current_page is None:
- return None
- html = page_to_url(current_page, config)
- dest = path.join(config.tree.draft_dest, html)
- if compile_file(current_page, dest, config, _plugins) is None:
- return None
- return html
- except Exception as e:
- hook_call("on_reload_error", e)
- warn(str(e))
-
-def full_reload(config, plugins):
- global current_page
- try:
- build_all(config, plugins, config.tree.draft_dest)
- return page_to_url(current_page, config) if current_page else None
- except Exception as e:
- hook_call("on_reload_error", e)
- warn(str(e))
-
-def watch_files(config, reload_func=None):
- global _plugins
- last_reload = {}
- _plugins = load_plugins(config)
-
- class ReloadHandler(FileSystemEventHandler):
- def on_modified(self, event):
- global _plugins
- try:
- p = event.src_path
- if event.is_directory:
- return
- if p.startswith(config.tree.static):
- if reload_func:
- reload_func("")
- return
- if is_template_or_plugin(p, config):
- if p.startswith(config.tree.plugins):
- _plugins = load_plugins(config)
- hook_call("on_plugin_changed", p)
- file = full_reload(config, _plugins)
- if reload_func and file:
- reload_func(file)
- return
- now = time.time()
- if now - last_reload.get(p, 0) < 0.3:
- return
- last_reload[p] = now
- hook_call("on_file_changed", p, config)
- file = single_file_reload(p, config)
- if reload_func and file:
- reload_func(file)
- except Exception as e:
- hook_call("on_reload_error", e)
- warn(str(e))
-
- observer = Observer()
- handler = ReloadHandler()
- for dir_path in [config.tree.markdown, config.tree.templates, config.tree.plugins, config.tree.static]:
- observer.schedule(handler, path=dir_path, recursive=True)
- hook_call("on_watch_start", config)
- return observer
-
-def run_watcher(project_path):
- try:
- project_path = project_path or getcwd()
- find_project_from_path(project_path)
- chdir(project_path)
- config = load_config()
- observer = watch_files(config)
- observer.start()
- try:
- while True:
- time.sleep(1)
- except KeyboardInterrupt:
- observer.stop()
- hook_call("on_watch_stop", config)
- observer.join()
- except Exception as e:
- fatal(e, f"Watch failed: {e}")
diff --git a/src/webviewer.py b/src/webviewer.py
deleted file mode 100644
index dc0be86..0000000
--- a/src/webviewer.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from os import chdir, getcwd, path
-from http.server import HTTPServer, SimpleHTTPRequestHandler
-from threading import Thread
-import webview
-
-from src import settings
-from src.build import load_plugins
-
-from .config import find_project_from_path, load_config
-from .errors import fatal, html_fatal
-from .watcher import watch_files
-from .log import info, warn
-import ifaddr
-import socket
-
-_ip_cache: list[str] = []
-
-import ifaddr
-
-def resolve_ips(host: str, port: int) -> list[str]:
- global _ip_cache
- if host != "0.0.0.0":
- _ip_cache = [host]
- elif not _ip_cache:
- _ip_cache = [
- ip_str
- for adapter in ifaddr.get_adapters()
- for ip in adapter.ips
- if ip.is_IPv4
- and (ip_str := str(ip.ip))
- and not ip_str.startswith("127.")
- and not ip_str.startswith("169.254.")
- ] or ["127.0.0.1"]
-
- return _ip_cache
-
-def http_server(host, port, routes):
- class Handler(SimpleHTTPRequestHandler):
- def translate_path(self, url_path: str) -> str:
- url = routes["url_path"]
- fs = routes["fs_path"]
-
- if url_path.startswith(url["static"]):
- rel = url_path.removeprefix(url["static"]).lstrip("/")
- resolved = path.join(fs["static"], rel)
-
- elif url_path.startswith(url["html"]):
- rel = url_path.removeprefix(url["html"]).lstrip("/")
- resolved = path.join(fs["html"], rel)
-
- else:
- rel = url_path.lstrip("/")
- resolved = path.join(fs["html"], rel)
-
- if not path.exists(resolved) and path.exists(resolved + ".html"):
- resolved += ".html"
-
- if path.isdir(resolved):
- index = path.join(resolved, "index.html")
- if path.exists(index):
- resolved = index
-
- return resolved
-
- def send_error(self, code, message=None, explain=None):
- 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):
- try:
- method, path, _ = args[0].split(" ", 2)
- info(f"{method} {path} {args[1]}", title="HTTP")
- except (ValueError, IndexError):
- info(format % args)
-
- server = HTTPServer((host, port), Handler)
- return server
-
-def reload_webview(window, url, host, port):
- try:
- window.load_url(f"http://{host}:{port}/{url}")
- except Exception as e:
- window.load_url(f"data:text/html,{html_fatal(e, f'Failed to reload {url}')}")
-
-def run(project_path):
- try:
- global _ip_cache
- project_path = project_path if project_path else getcwd()
- find_project_from_path(project_path)
- chdir(project_path)
- config = load_config()
- host = config.webview.host
- port = config.webview.port
- routes = {
- "url_path": {
- "html": config.webview.html_path,
- "static": config.webview.static_path,
- },
- "fs_path": {
- "html": config.tree.draft_dest,
- "static": config.tree.static,
- },
- }
-
- server = http_server(host, port, routes)
- server_thread = Thread(target=server.serve_forever, daemon=True)
- server_thread.start()
- _ip_cache = resolve_ips(host, port)
- info(f"HTTP SERVER STARTS ON:")
- for ip in _ip_cache:
- info(f" IP: http://{ip}:{port}")
- print()
-
- window = webview.create_window("Merodi", url=f"http://{_ip_cache[0]}:{port}/")
- observer = watch_files(
- config,
- lambda path: reload_webview(window, path, _ip_cache[0], port),
- )
- observer.start()
- webview.start(debug=config.webview.dev_tools in ["true", 1])
-
-
- except KeyboardInterrupt:
- info("Exiting...")
- exit(0)
- except Exception as e:
- fatal(e, f"Webview failed: {e}")