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


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

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'&#91;:?[ ]*([^&#93;\n ][^\n]*)[ ]*&#93;' -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*)&#91;[^&#93;]*&#93;', 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}")

// Created by Pi66

// Copyright ©Pi66 2026