diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95e3014 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +fes diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec2e862 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Fes + +A lightweight static site generator built with Go and Lua. Write your websites in Lua and generate beautiful HTML with a modern dark theme. + +## Features + +- 🚀 **Simple & Fast**: Minimal setup, fast development workflow +- 🎨 **Beautiful Default Theme**: Modern dark theme with clean typography +- 📝 **Lua-Powered**: Write your site logic in Lua for flexibility +- ⚙️ **TOML Configuration**: Simple configuration via `Fes.toml` +- 🔧 **Easy CLI**: Two simple commands to get started + +## Installation + +### Prerequisites + +- Go 1.25.4 or later +- Git (for author name detection) + +### Build from Source + +```bash +git clone +cd fes +go build -o fes +``` + +## Usage + +### Create a New Project + +```bash +fes new +``` + +This creates a new project directory with: +- `www/index.lua` - Your main Lua file +- `Fes.toml` - Project configuration + +### Run Development Server + +```bash +fes run [-p ] +``` + +Starts a local development server (default port: 3000). + +Example: +```bash +fes run my-site -p 8080 +``` + +## Project Structure + +``` +my-site/ +├── Fes.toml # Project configuration +└── www/ + └── index.lua # Main Lua file +``` + +## Configuration + +Edit `Fes.toml` to configure your site: + +```toml +[site] +name = "My Site" +version = "0.0.1" +authors = ["Your Name"] + +[fes] +version = "1.0.0" +CUSTOM_CSS = "" # Optional custom CSS +``` + +## Writing Your Site + +Your `www/index.lua` file should use the Fes API to build your site: + +```lua +local fes = require("fes") +local site = fes.site_builder() + +site:h1("Hello, World!") +site:h2("Welcome to Fes") +site:custom("

This is a custom HTML paragraph.

") + +return site +``` + +### Available Methods + +- `site:h1(text)` - Heading 1 +- `site:h2(text)` - Heading 2 +- `site:h3(text)` - Heading 3 +- `site:h4(text)` - Heading 4 +- `site:h5(text)` - Heading 5 +- `site:h6(text)` - Heading 6 +- `site:custom(html)` - Insert custom HTML +- `site:version()` - Get site version from config + +### Standard Library + +Access version information via `fes.std`: + +```lua +local fes = require("fes") +local site = fes.site_builder() + +site:h2("Fes version: " .. fes.std.fes_version()) +site:h2("Site version: " .. fes.std.site_version()) + +return site +``` + +## Example + +See the `test/` directory for a complete example project. + +## License + +ISC License + +Copyright (c) 2025 fSD + +See `COPYING` for full license text. diff --git a/core/builtin.lua b/core/builtin.lua new file mode 100644 index 0000000..02d71e4 --- /dev/null +++ b/core/builtin.lua @@ -0,0 +1,172 @@ +local M = {} +M.__index = M + +function M.site_builder(header, footer) + local config = {} + local site_config = {} + + local fes_mod = package.loaded.fes + if fes_mod and fes_mod.config then + config = fes_mod.config + if config.site then + site_config = config.site + end + end + + local self = { + version = site_config.version or "", + header = header or [[ + + + + +
+]], + footer = footer or [[ +
+ + +]], + parts = {} + } + return setmetatable(self, M) +end + +function M:custom(str) + table.insert(self.parts, str) + return self +end + +function M:h1(str) + str = str or "" + table.insert(self.parts, "

" .. str .. "

") + return self +end + +function M:h2(str) + str = str or "" + table.insert(self.parts, "

" .. str .. "

") + return self +end + +function M:h3(str) + str = str or "" + table.insert(self.parts, "

" .. str .. "

") + return self +end + +function M:h4(str) + str = str or "" + table.insert(self.parts, "

" .. str .. "

") + return self +end + +function M:h5(str) + str = str or "" + table.insert(self.parts, "
" .. str .. "
") + return self +end + +function M:h6(str) + str = str or "" + table.insert(self.parts, "
" .. str .. "
") + return self +end + +function M:version() + return self.version +end + +function M:build() + return self.header .. table.concat(self.parts) .. self.footer +end + +M.__tostring = function(self) + return self:build() +end + +return M diff --git a/core/std.lua b/core/std.lua new file mode 100644 index 0000000..e70f604 --- /dev/null +++ b/core/std.lua @@ -0,0 +1,19 @@ +local M = {} + +function M.fes_version() + local fes_mod = package.loaded.fes + if fes_mod and fes_mod.config and fes_mod.config.fes and fes_mod.config.fes.version then + return fes_mod.config.fes.version + end + return "" +end + +function M.site_version() + local fes_mod = package.loaded.fes + if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.version then + return fes_mod.config.site.version + end + return "" +end + +return M diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5630c52 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module fes + +go 1.25.4 + +require ( + github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386 // indirect + github.com/gomarkdown/mdtohtml v0.0.0-20240124153210-d773061d1585 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc06d4a --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/gomarkdown/mdtohtml v0.0.0-20240124153210-d773061d1585/go.mod h1:6grYm5/uY15CwgBBqwA3+o/cAzaxssckznJ0B35ouBY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..87dac4d --- /dev/null +++ b/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "os/exec" + "os/user" + "path/filepath" + "regexp" + "strings" + + "github.com/pelletier/go-toml/v2" + lua "github.com/yuin/gopher-lua" +) + +const version = "1.0.0" + +type MyConfig struct { + Site struct { + Name string `toml:"name"` + Version string `toml:"version"` + Authors []string `toml:"authors"` + } `toml:"site"` + Fes struct { + Version string `toml:"version"` + CUSTOM_CSS string `toml:"CUSTOM_CSS,omitempty"` + } `toml:"fes"` +} + +var port = flag.Int("p", 3000, "set the server port") + +func loadLua(luaDir string, entry string, cfg *MyConfig) (string, error) { + L := lua.NewState() + defer L.Close() + + L.PreloadModule("fes", func(L *lua.LState) int { + mod := L.NewTable() + wd, _ := os.Getwd() + corePath := filepath.Join(wd, "core") + files, _ := os.ReadDir(corePath) + for _, f := range files { + if f.IsDir() || filepath.Ext(f.Name()) != ".lua" { + continue + } + modName := f.Name()[:len(f.Name())-len(".lua")] + if err := L.DoFile(filepath.Join(corePath, f.Name())); err != nil { + fmt.Println("error loading", f.Name(), ":", err) + continue + } + val := L.Get(-1) + L.Pop(1) + tbl, ok := val.(*lua.LTable) + if !ok { + t := L.NewTable() + t.RawSetString("value", val) + tbl = t + } + if modName == "builtin" { + tbl.ForEach(func(key, value lua.LValue) { + mod.RawSet(key, value) + }) + } else { + mod.RawSetString(modName, tbl) + } + } + // Pass config to Lua + if cfg != nil { + configTable := L.NewTable() + siteTable := L.NewTable() + siteTable.RawSetString("version", lua.LString(cfg.Site.Version)) + siteTable.RawSetString("name", lua.LString(cfg.Site.Name)) + authorsTable := L.NewTable() + for i, author := range cfg.Site.Authors { + authorsTable.RawSetInt(i+1, lua.LString(author)) + } + siteTable.RawSetString("authors", authorsTable) + configTable.RawSetString("site", siteTable) + fesTable := L.NewTable() + fesTable.RawSetString("version", lua.LString(cfg.Fes.Version)) + configTable.RawSetString("fes", fesTable) + mod.RawSetString("config", configTable) + } + L.Push(mod) + return 1 + }) + + if err := L.DoFile(entry); err != nil { + return "", err + } + + top := L.GetTop() + if top == 0 { + fmt.Println("warning: no return value from Lua file") + return "", nil + } + + resultVal := L.Get(-1) + L.SetGlobal("__fes_result", resultVal) + if err := L.DoString("return tostring(__fes_result)"); err != nil { + L.GetGlobal("__fes_result") + if s := L.ToString(-1); s != "" { + return s, nil + } + return "", nil + } + if s := L.ToString(-1); s != "" { + return s, nil + } + return "", nil +} + +func getName() string { + out, err := exec.Command("git", "config", "user.name").Output() + if err == nil { + s := strings.TrimSpace(string(out)) + if s != "" { + return s + } + } + u, err := user.Current() + if err == nil && u.Username != "" { + return u.Username + } + return "" +} + +func newProject(dir string) error { + if err := os.MkdirAll(filepath.Join(dir, "www"), 0755); err != nil { + return err + } + indexLua := filepath.Join(dir, "www", "index.lua") + if _, err := os.Stat(indexLua); os.IsNotExist(err) { + content := `local fes = require("fes") +local site = fes.site_builder() + +site:h1("Hello, World!") + +return site +` + if err := os.WriteFile(indexLua, []byte(content), 0644); err != nil { + return err + } + } + indexFes := filepath.Join(dir, "Fes.toml") + if _, err := os.Stat(indexFes); os.IsNotExist(err) { + content := fmt.Sprintf(`[site] + +name = "%s" +version = "0.0.1" +authors = ["%s"] + +[fes] +version = "%s" +CUSTOM_CSS = +`, dir, getName(), version) + if err := os.WriteFile(indexFes, []byte(content), 0644); err != nil { + return err + } + } + fmt.Println("Created new project at", dir) + return nil +} + +func fixMalformedToml(content string) string { + // Fix lines like "CUSTOM_CSS =" (with no value) to "CUSTOM_CSS = \"\"" + re := regexp.MustCompile(`(?m)^(\s*\w+\s*=\s*)$`) + return re.ReplaceAllStringFunc(content, func(match string) string { + // Extract the key name + parts := strings.Split(strings.TrimSpace(match), "=") + if len(parts) == 2 && strings.TrimSpace(parts[1]) == "" { + key := strings.TrimSpace(parts[0]) + return key + " = \"\"" + } + return match + }) +} + +func startServer(dir string) error { + doc, err := os.ReadFile(filepath.Join(dir, "Fes.toml")) + if err != nil { + return err + } + + // Fix malformed TOML before parsing + docStr := fixMalformedToml(string(doc)) + + var cfg MyConfig + err = toml.Unmarshal([]byte(docStr), &cfg) + if err != nil { + return fmt.Errorf("failed to parse Fes.toml: %w", err) + } + + luaPath := filepath.Join(dir, "www", "index.lua") + data, err := loadLua(dir, luaPath, &cfg) + if err != nil { + return err + } + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(data)) + }) + fmt.Printf("App running at:\n - Local: http://localhost:%d/\n", *port) + return http.ListenAndServe(fmt.Sprintf(":%d", *port), nil) +} + +func main() { + flag.Parse() + if len(os.Args) < 3 { + fmt.Println("Usage: fes ") + fmt.Println("Commands: new, serve") + os.Exit(1) + } + + cmd := os.Args[1] + dir := os.Args[2] + + switch cmd { + case "new": + if err := newProject(dir); err != nil { + panic(err) + } + case "run": + if err := startServer(dir); err != nil { + panic(err) + } + default: + fmt.Println("Unknown command:", cmd) + os.Exit(1) + } +} diff --git a/test/Fes.toml b/test/Fes.toml new file mode 100644 index 0000000..3698755 --- /dev/null +++ b/test/Fes.toml @@ -0,0 +1,9 @@ +[site] + +name = "test" +version = "0.0.1" +authors = ["vx-clutch"] + +[fes] +version = "1.0.0" +CUSTOM_CSS = diff --git a/test/www/index.lua b/test/www/index.lua new file mode 100644 index 0000000..5f01205 --- /dev/null +++ b/test/www/index.lua @@ -0,0 +1,8 @@ +local fes = require("fes") +local site = fes.site_builder() + +site:h1("Hello, Sam!") +site:h2(fes.std.fes_version()) +site:h2(fes.std.site_version()) + +return site