20 Commits

Author SHA1 Message Date
e36f5bc579 Merge pull request 'rewrite' (#1) from rewrite into main
Reviewed-on: #1
2026-01-19 16:17:56 -05:00
1e15e20175 add brewfile 2026-01-19 16:17:31 -05:00
81ab5b252b fix back on archive 2026-01-16 16:46:46 -05:00
746da2e21f report bug 2026-01-16 16:11:19 -05:00
e097d428b3 testing script and source 2026-01-16 16:08:18 -05:00
7e3af14059 rewrite server module 2026-01-16 16:08:10 -05:00
bfb5725880 update example 2026-01-16 16:07:54 -05:00
1ee8d2d71d remove site.lua 2026-01-16 16:07:46 -05:00
cf59c6d021 move COPYING to LICENSE 2026-01-16 16:07:33 -05:00
de69397aa5 start rewrite 2026-01-15 12:23:13 -05:00
2c2dc57453 WIP broken stage 2026-01-06 15:40:30 -05:00
85bd564164 release 0.3.0 2026-01-04 16:27:49 -05:00
0a0b1fa8c3 large changes 2026-01-04 16:27:39 -05:00
608a083861 patch: fix the back option for archives 2026-01-03 16:51:16 -05:00
50a45b6a82 update archive example 2026-01-03 16:50:33 -05:00
19752a0c89 change logging format 2026-01-03 09:53:07 -05:00
5192919645 update gitignore 2026-01-02 10:49:04 -05:00
f763f57001 rewrite Dockerfile 2026-01-02 10:06:27 -05:00
629fd06be0 exit beta 2026-01-01 23:06:30 -05:00
bedcfe781d updated logging 2026-01-01 23:05:29 -05:00
68 changed files with 797 additions and 2313 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
fes fes
*.tar.gz
/stuff/

2
Brewfile Normal file
View File

@@ -0,0 +1,2 @@
# build deps
brew "go@1.25"

View File

@@ -4,24 +4,17 @@ WORKDIR /src
RUN apk add --no-cache git build-base RUN apk add --no-cache git build-base
COPY go.mod go.sum ./
RUN go mod download
COPY . . COPY . .
ENV CGO_ENABLED=0 RUN make
ENV GOOS=linux
ENV GOARCH=amd64
RUN go build -ldflags="-X fes/modules/version.gitCommit=$(git rev-parse --short HEAD) -s -w" -o fes FROM alpine:3.19
FROM scratch COPY --from=builder /src/fes /usr/local/bin/fes
COPY --from=builder /src/fes /fes
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 3000
ENTRYPOINT ["/fes"] ENTRYPOINT ["/usr/local/bin/fes"]
CMD ["run", "/app"] CMD ["run", "/app"]

View File

4
go.mod
View File

@@ -11,11 +11,7 @@ require (
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

9
go.sum
View File

@@ -1,5 +1,3 @@
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
@@ -13,10 +11,6 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -24,6 +18,3 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,4 +1,5 @@
local std = require("lib.std") local std = require("lib.std")
local symbol = require("lib.symbol")
local M = {} local M = {}
M.__index = M M.__index = M
@@ -318,11 +319,30 @@ em, i { font-style: italic; }
return setmetatable(self, M) return setmetatable(self, M)
end end
function M:custom(str) function M:g(str)
table.insert(self.parts, str) table.insert(self.parts, str)
return self return self
end end
function M:extend(name, tbl)
if type(name) ~= "string" then
error("First argument to extend must be a string (namespace name)")
end
if type(tbl) ~= "table" then
error("Second argument to extend must be a table of functions")
end
self[name] = {}
for k, v in pairs(tbl) do
if type(v) ~= "function" then
error("Extension values must be functions, got " .. type(v) .. " for key " .. k)
end
self[name][k] = function(...)
return v(self, ...)
end
end
return self
end
for name, func in pairs(std) do for name, func in pairs(std) do
if type(func) == "function" then if type(func) == "function" then
M[name] = function(self, ...) M[name] = function(self, ...)
@@ -340,9 +360,11 @@ function M:build()
header = header:gsub( header = header:gsub(
"{{FAVICON}}", "{{FAVICON}}",
favicon_html favicon_html
or [[<link rel="icon" href="data:image/svg+xml,<svg xmlns=%%22http://www.w3.org/2000/svg%%22 viewBox=%%220 0 100 100%%22><text y=%%22.9em%%22 font-size=%%2290%%22>🔥</text></svg>">]] or
[[<link rel="icon" href="data:image/svg+xml,<svg xmlns=%%22http://www.w3.org/2000/svg%%22 viewBox=%%220 0 100 100%%22><text y=%%22.9em%%22 font-size=%%2290%%22>🔥</text></svg>">]]
) )
local footer = self.footer:gsub("{{COPYRIGHT}}", self.copyright or "&#169; The Copyright Holder") local footer = self.footer:gsub("{{COPYRIGHT}}",
self.copyright or symbol.legal.copyright .. "The Copyright Holder")
return header .. table.concat(self.parts, "\n") .. footer return header .. table.concat(self.parts, "\n") .. footer
end end

View File

@@ -1,155 +1,163 @@
local M = {} local M = {}
function M.fes_version() function M.element(tag, attrs, content)
local fes_mod = package.loaded.fes local out = { "<", tag }
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() if attrs then
local fes_mod = package.loaded.fes for k, v in pairs(attrs) do
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.version then if v ~= false and v ~= nil then
return fes_mod.config.site.version if v == true then
out[#out + 1] = " " .. k
else
out[#out + 1] = " " .. k .. "=\"" .. tostring(v) .. "\""
end
end
end
end end
return ""
if content == nil then
out[#out + 1] = " />"
return table.concat(out)
end
out[#out + 1] = ">"
out[#out + 1] = tostring(content)
out[#out + 1] = "</"
out[#out + 1] = tag
out[#out + 1] = ">"
return table.concat(out)
end end
function M.a(link, str) function M.a(link, str)
link = link or "https://example.com" link = link or "https://example.com"
str = str or link str = str or link
return '<a href="' .. link .. '">' .. str .. "</a>" return M.element("a", { href = link }, str)
end
function M.download(link, str, downloadName)
link = link or "."
str = str or link
return M.element("a", { href = link, download = downloadName }, str)
end end
function M.ha(link, str) function M.ha(link, str)
link = link or "https://example.com" link = link or "https://example.com"
str = str or link str = str or link
return '<a class="hidden" href="' .. link .. '">' .. str .. "</a>" return M.element("a", { href = link, class = "hidden" }, str)
end end
function M.external(link, str) function M.external(link, str)
return '<a target="_blank" href="' .. link .. '">' .. str .. "</a>" return M.element("a", { href = link, target = "_blank" }, str)
end end
function M.note(str) function M.note(str)
return '<div class="note">' .. str .. "</div>" return M.element("div", { class = "note" }, str)
end end
function M.muted(str) function M.muted(str)
return '<div class="muted">' .. str .. "</div>" return M.element("div", { class = "muted" }, str)
end end
function M.callout(str) function M.callout(str)
return '<div class="callout">' .. str .. "</div>" return M.element("div", { class = "callout" }, str)
end end
function M.h1(str) function M.h1(str)
return "<h1>" .. (str or "") .. "</h1>" return M.element("h1", nil, str or "")
end end
function M.h2(str) function M.h2(str)
return "<h2>" .. (str or "") .. "</h2>" return M.element("h2", nil, str or "")
end end
function M.h3(str) function M.h3(str)
return "<h3>" .. (str or "") .. "</h3>" return M.element("h3", nil, str or "")
end end
function M.h4(str) function M.h4(str)
return "<h4>" .. (str or "") .. "</h4>" return M.element("h4", nil, str or "")
end end
function M.h5(str) function M.h5(str)
return "<h5>" .. (str or "") .. "</h5>" return M.element("h5", nil, str or "")
end end
function M.h6(str) function M.h6(str)
return "<h6>" .. (str or "") .. "</h6>" return M.element("h6", nil, str or "")
end end
function M.p(str) function M.p(str)
return "<p>" .. (str or "") .. "</p>" return M.element("p", nil, str or "")
end end
function M.pre(str) function M.pre(str)
return "<pre>" .. (str or "") .. "</pre>" return M.element("pre", nil, str or "")
end end
function M.code(str) function M.code(str)
return "<pre><code>" .. (str or "") .. "</code></pre>" return M.element("pre", nil, M.element("code", nil, str or ""))
end end
function M.ul(items) function M.ul(items)
items = items or {} items = items or {}
local html = "<ul>" local out = {}
for _, item in ipairs(items) do for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>" out[#out + 1] = M.element("li", nil, item)
end end
html = html .. "</ul>" return M.element("ul", nil, table.concat(out))
return html
end end
function M.ol(items) function M.ol(items)
items = items or {} items = items or {}
local html = "<ol>" local out = {}
for _, item in ipairs(items) do for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>" out[#out + 1] = M.element("li", nil, item)
end end
html = html .. "</ol>" return M.element("ol", nil, table.concat(out))
return html
end end
function M.tl(items) function M.tl(items)
items = items or {} items = items or {}
local html = '<ul class="tl">' local out = {}
for _, item in ipairs(items) do for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>" out[#out + 1] = M.element("li", nil, item)
end end
html = html .. "</ul>" return M.element("ul", { class = "tl" }, table.concat(out))
return html
end end
function M.blockquote(str) function M.blockquote(str)
return "<blockquote>" .. (str or "") .. "</blockquote>" return M.element("blockquote", nil, str or "")
end end
function M.hr() function M.hr()
return "<hr>" return M.element("hr")
end end
function M.img(src, alt) function M.img(src, alt)
src = src or "" return M.element("img", { src = src or "", alt = alt or "" })
alt = alt or ""
return '<img src="' .. src .. '" alt="' .. alt .. '">'
end end
function M.strong(str) function M.strong(str)
return "<strong>" .. (str or "") .. "</strong>" return M.element("strong", nil, str or "")
end end
function M.em(str) function M.em(str)
return "<em>" .. (str or "") .. "</em>" return M.element("em", nil, str or "")
end end
function M.br() function M.br()
return "<br>" return M.element("br")
end end
function M.div(content, class) function M.div(content, class)
content = content or "" return M.element("div", class and { class = class } or nil, content or "")
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<div" .. class_attr .. ">" .. content .. "</div>"
end end
function M.span(content, class) function M.span(content, class)
content = content or "" return M.element("span", class and { class = class } or nil, content or "")
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<span" .. class_attr .. ">" .. content .. "</span>"
end end
-- HTML escaping utility
function M.escape(str) function M.escape(str)
str = tostring(str or "") str = tostring(str or "")
str = str:gsub("&", "&amp;") str = str:gsub("&", "&amp;")
@@ -160,55 +168,28 @@ function M.escape(str)
return str return str
end end
-- Get site name from config
function M.site_name()
local fes_mod = package.loaded.fes
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.name then
return fes_mod.config.site.name
end
return ""
end
-- Get site title from config
function M.site_title()
local fes_mod = package.loaded.fes
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.title then
return fes_mod.config.site.title
end
return ""
end
-- Get site authors from config
function M.site_authors()
local fes_mod = package.loaded.fes
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.authors then
return fes_mod.config.site.authors
end
return {}
end
function M.highlight(str) function M.highlight(str)
return '<span class="highlight">' .. (str or "") .. "</span>" return M.element("span", { class = "highlight" }, str or "")
end end
function M.banner(str) function M.banner(str)
return '<div class="banner">' .. (str or "") .. "</div>" return M.element("div", { class = "banner" }, str or "")
end end
function M.center(str) function M.center(str)
return '<div class="center">' .. (str or "") .. "</div>" return M.element("div", { class = "center" }, str or "")
end end
function M.nav(link, str) function M.nav(link, str)
link = link or "example.com" link = link or "example.com"
str = str or link str = str or link
return '<a class="nav" href="' .. link .. '">' .. str .. "</a>" return M.element("a", { href = link, class = "nav" }, str)
end end
function M.rl(r, l) function M.rl(r, l)
r = r or "" return
l = l or "" M.element("span", { class = "left" }, r or "") ..
return string.format('<span class="left">%s</span><span class="right">%s</span>', r, l) M.element("span", { class = "right" }, l or "")
end end
return M return M

View File

@@ -1,7 +1,75 @@
local M = {} local M = {}
M.copyright = "&#169;" local function get(s)
M.registered_trademark = "&#174;" return "&" .. (s or "") .. ";"
M.trademark = "&#8482;" end
M.legal = {
copyright = get("copy"),
registered_trademark = get("reg"),
trademark = get("trade"),
}
M.currency = {
euro = get("euro"),
pound = get("pound"),
yen = get("yen"),
cent = get("cent"),
dollar = "$",
}
M.math = {
plus_minus = get("plusmn"),
multiply = get("times"),
divide = get("divide"),
not_equal = get("ne"),
less_equal = get("le"),
greater_equal = get("ge"),
infinity = get("infin"),
approx = get("asymp"),
}
M.arrows = {
left = get("larr"),
right = get("rarr"),
up = get("uarr"),
down = get("darr"),
left_right = get("harr"),
}
M.punctuation = {
left_double_quote = get("ldquo"),
right_double_quote = get("rdquo"),
left_single_quote = get("lsquo"),
right_single_quote = get("rsquo"),
ellipsis = get("hellip"),
em_dash = get("mdash"),
en_dash = get("ndash"),
}
M.whitespace = {
non_breaking = get("nbsp"),
thin = get("thinsp"),
}
M.symbols = {
degree = get("deg"),
micro = get("micro"),
section = get("sect"),
paragraph = get("para"),
check = get("check"),
cross = get("cross"),
bullet = get("bull"),
middle_dot = get("middot"),
broken_bar = get("brvbar"),
}
M.html = {
less_than = get("lt"),
greater_than = get("gt"),
ampersand = get("amp"),
double_quote = get("quot"),
single_quote = get("apos"),
}
return M return M

View File

@@ -3,12 +3,33 @@ local symbol = require("lib.symbol")
local M = {} local M = {}
function M.cc(tbl) function M.cc(tbl, sep)
return table.concat(tbl) return table.concat(tbl, sep or "")
end
function M.year(y)
return y or os.date("%Y")
end end
function M.copyright(link, holder) function M.copyright(link, holder)
return symbol.copyright .. " " .. std.external(link, holder) return symbol.legal.copyright .. " " .. std.external(link, holder)
end
function M.license(name)
return symbol.legal.registered .. " " .. name
end
function M.ls(dir)
local p = io.popen('ls -A -1 -- ' .. string.format('%q', dir))
if not p then
return nil
end
local t = {}
for line in p:lines() do
t[#t + 1] = line
end
p:close()
return t
end end
return M return M

36
main.go
View File

@@ -2,10 +2,10 @@ package main
import ( import (
"embed" "embed"
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"runtime"
"github.com/fatih/color" "github.com/fatih/color"
@@ -13,6 +13,7 @@ import (
"fes/modules/doc" "fes/modules/doc"
"fes/modules/new" "fes/modules/new"
"fes/modules/server" "fes/modules/server"
"fes/modules/ui"
"fes/modules/version" "fes/modules/version"
) )
@@ -29,18 +30,21 @@ func init() {
config.Docker = flag.Bool("docker", false, "Create a docker project") config.Docker = flag.Bool("docker", false, "Create a docker project")
config.Lib = lib config.Lib = lib
config.Doc = documentation config.Doc = documentation
config.Verbose = flag.Bool("verbose", false, "Enable verbose logging")
} }
func main() { func main() {
var m runtime.MemStats
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] <command> <project_dir>\n", os.Args[0]) fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] <command> <project_dir>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:") fmt.Fprintln(flag.CommandLine.Output(), "Commands:")
fmt.Fprintf(os.Stderr, " new <project_dir> Create a new project") fmt.Fprintln(flag.CommandLine.Output(), " new <project_dir> Create a new project")
fmt.Fprintf(os.Stderr, " doc Open documentation") fmt.Fprintln(flag.CommandLine.Output(), " doc Open documentation")
fmt.Fprintf(os.Stderr, " run <project_dir> Start the server") fmt.Fprintln(flag.CommandLine.Output(), " run <project_dir> Start the server")
fmt.Fprintf(os.Stderr, "Options:") fmt.Fprintln(flag.CommandLine.Output(), "Options:")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "For bug reports, contact a developer and describe the issue. Provide the output of the `-V1` flag.") fmt.Fprintln(flag.CommandLine.Output(), "For bug reports, contact a developer and describe the issue. Provide the output of the `-V1` flag.")
} }
showVersion := flag.Bool("version", false, "Show version and exit") showVersion := flag.Bool("version", false, "Show version and exit")
@@ -89,16 +93,16 @@ func main() {
os.Exit(1) os.Exit(1)
} }
case "run": case "run":
if _, err := server.Start(dir); err != nil { if *config.Port == 3000 {
if errors.Is(err, os.ErrNotExist) { ui.WARNING("Using default port, this may lead to conflicts with other services")
fmt.Fprintf(os.Stderr, "%s does not exist\n", dir)
fmt.Fprintf(os.Stderr, "Try: fes new %s\n", dir)
os.Exit(1)
} else {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
} }
ui.Log("Fes is starting")
ui.Log("Fes version=%s, commit=%s, just started", version.VERSION, version.GetCommit())
runtime.ReadMemStats(&m)
ui.Log("FRE memory usage when created %v Mb", m.TotalAlloc/1024/1024)
server.Start(dir)
default: default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd) fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
flag.Usage() flag.Usage()

View File

@@ -11,6 +11,7 @@ var Port *int
var Color *bool var Color *bool
var Static *bool var Static *bool
var Docker *bool var Docker *bool
var Verbose *bool
type AppConfig struct { type AppConfig struct {
App struct { App struct {

View File

@@ -118,11 +118,12 @@ Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).`, "$$", "`"),
ui.Hint("you can run this with `fes run %s`", dir) ui.Hint("you can run this with `fes run %s`", dir)
fmt.Println("Created new Fes project at", func() string { fmt.Println("Created new Fes project at", func () string {
if res, err := filepath.Abs(dir); err == nil { if cwd, err := os.Getwd(); err != nil {
return res return dir
} else {
return cwd
} }
return dir
}()) }())
return nil return nil

109
modules/server/archive.go Normal file
View File

@@ -0,0 +1,109 @@
package server
import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"text/template"
"time"
)
/* this indexes and generate the page for viewing the archive directory */
func generateArchiveIndex(fsPath string, urlPath string) (string, error) {
info, err := os.Stat(fsPath)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory")
}
ents, err := os.ReadDir(fsPath)
if err != nil {
return "", err
}
type entryInfo struct {
name string
isDir bool
href string
size int64
mod time.Time
}
var list []entryInfo
for _, e := range ents {
n := e.Name()
full := filepath.Join(fsPath, n)
st, err := os.Stat(full)
if err != nil {
continue
}
isd := st.IsDir()
displayName := n
if isd {
displayName = n + "/"
}
href := path.Join(urlPath, n)
if isd && !strings.HasSuffix(href, "/") {
href = href + "/"
}
size := int64(-1)
if !isd {
size = st.Size()
}
list = append(list, entryInfo{name: displayName, isDir: isd, href: href, size: size, mod: st.ModTime()})
}
sort.Slice(list, func(i, j int) bool {
if list[i].isDir != list[j].isDir {
return list[i].isDir
}
return strings.ToLower(list[i].name) < strings.ToLower(list[j].name)
})
urlPath = strings.TrimPrefix(urlPath, "/archive")
var b strings.Builder
b.WriteString("<html>\n<head><title>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</title></head>\n<body>\n<h1>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</h1><hr><pre>")
b.WriteString("<a href=\"../\">../</a>\n")
nameCol := 50
for _, ei := range list {
escapedName := template.HTMLEscapeString(ei.name)
dateStr := ei.mod.Local().Format("02-Jan-2006 15:04")
var sizeStr string
if ei.isDir {
sizeStr = "-"
} else {
sizeStr = fmt.Sprintf("%d", ei.size)
}
spaces := 1
if len(escapedName) < nameCol {
spaces = nameCol - len(escapedName)
}
line := `<a href="` + template.HTMLEscapeString(ei.href) + `">` + escapedName + `</a>` + strings.Repeat(" ", spaces) + dateStr + strings.Repeat(" ", 19-len(sizeStr)) + sizeStr + "\n"
b.WriteString(line)
}
b.WriteString("</pre><hr></body>\n</html>")
return b.String(), nil
}
/* helper to read the archive files */
func readArchive(w http.ResponseWriter, route string) error {
fsPath := "." + route
if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
if page, err := generateArchiveIndex(fsPath, route); err == nil {
w.Write([]byte(page))
return nil
} else {
return err
}
}
return nil
}

80
modules/server/dirs.go Normal file
View File

@@ -0,0 +1,80 @@
package server
import (
"fes/modules/ui"
"fmt"
"os"
"path/filepath"
"strings"
)
/* performs relavent handling based on the directory passaed
*
* Special directories
* - www/ <= contains lua routes.
* - static/ <= static content accessable at /static/path or /static/dir/path.
* - include/ <= globally accessable lua functions, cannot directly access "fes" right now.
* - archive/ <= contains user facing files such as archives or dists.
*
*/
func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error {
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
nextBase := joinBase(base, entry.Name())
subEntries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", path, err)
}
if err := handleDir(subEntries, path, routes, nextBase, isStatic); err != nil {
return err
}
continue
}
route := joinBase(base, entry.Name())
if !isStatic && strings.HasSuffix(entry.Name(), ".lua") {
name := strings.TrimSuffix(entry.Name(), ".lua")
if name == "index" {
routes[basePath(base)] = path
routes[route] = path
continue
}
route = joinBase(base, name)
} else if !isStatic && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
if name == "index" {
routes[basePath(base)] = path
routes[route] = path
continue
}
route = joinBase(base, name)
}
routes[route] = path
}
return nil
}
/* helper to load all special directories */
func loadDirs() map[string]string {
routes := make(map[string]string)
if entries, err := os.ReadDir("www"); err == nil {
if err := handleDir(entries, "www", routes, "", false); err != nil {
ui.Warning("failed to handle www directory", err)
}
}
if entries, err := os.ReadDir("static"); err == nil {
if err := handleDir(entries, "static", routes, "/static", true); err != nil {
ui.Warning("failed to handle static directory", err)
}
}
if entries, err := os.ReadDir("archive"); err == nil {
if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
ui.Warning("failed to handle archive directory", err)
}
}
return routes
}

164
modules/server/render.go Normal file
View File

@@ -0,0 +1,164 @@
package server
import (
"fes/modules/config"
"io/fs"
"os"
"path/filepath"
"strings"
lua "github.com/yuin/gopher-lua"
)
/* this is the request data we pass over the bus to the application, via the fes.bus interface */
type reqData struct {
path string
params map[string]string
}
/* returns a string of rendered html */
func render(luapath string, requestData reqData) ([]byte, error) {
L := lua.NewState()
defer L.Close()
if lib, err := fs.ReadDir(config.Lib, "lib"); err == nil {
for _, de := range lib {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
continue
}
path := filepath.Join("lib", de.Name())
if data, err := config.Lib.ReadFile(path); err != nil {
continue
} else {
L.DoString(string(data))
}
}
}
preloadLuaModule := func(name, path string) {
L.PreloadModule(name, func(L *lua.LState) int {
fileData, err := config.Lib.ReadFile(path)
if err != nil {
panic(err)
}
if err := L.DoString(string(fileData)); err != nil {
panic(err)
}
L.Push(L.Get(-1))
return 1
})
}
preloadLuaModule("lib.std", "lib/std.lua")
preloadLuaModule("lib.symbol", "lib/symbol.lua")
preloadLuaModule("lib.util", "lib/util.lua")
L.PreloadModule("fes", func(L *lua.LState) int {
mod := L.NewTable()
libModules := []string{}
if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil {
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
}
libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua"))
}
}
for _, modName := range libModules {
path := filepath.Join("lib", modName+".lua")
fileData, err := config.Lib.ReadFile(path)
if err != nil {
continue
}
if err := L.DoString(string(fileData)); err != nil {
continue
}
val := L.Get(-1)
L.Pop(1)
tbl, ok := val.(*lua.LTable)
if !ok || tbl == nil {
tbl = L.NewTable()
}
if modName == "fes" {
tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) })
} else {
mod.RawSetString(modName, tbl)
}
}
mod.RawSetString("app", func() *lua.LTable {
app := L.NewTable()
includeDir := "include"
includes, err := os.ReadDir(includeDir)
if err != nil {
return app // load no includes
}
for _, de := range includes {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
continue
}
base := strings.TrimSuffix(de.Name(), ".lua")
path := filepath.Join(includeDir, de.Name())
if _, err := os.Stat(path); err != nil {
continue
} else if err := L.DoFile(path); err != nil {
continue
}
val := L.Get(-1)
L.Pop(1)
tbl, ok := val.(*lua.LTable)
if !ok || tbl == nil {
tbl = L.NewTable()
}
app.RawSetString(base, tbl)
}
return app
}())
bus := L.NewTable()
bus.RawSetString("url", lua.LString(requestData.path))
params := L.NewTable()
for k, v := range requestData.params {
params.RawSetString(k, lua.LString(v))
}
bus.RawSetString("params", params)
mod.RawSetString("bus", bus)
mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(markdownToHTML(L.ToString(1))))
return 1
}))
L.Push(mod)
return 1
})
if err := L.DoFile(luapath); err != nil {
return []byte(""), err
}
if L.GetTop() == 0 {
return []byte(""), nil
}
L.SetGlobal("__fes_result", L.Get(-1))
if err := L.DoString("return tostring(__fes_result)"); err != nil {
L.GetGlobal("__fes_result")
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}

View File

@@ -1,427 +1,25 @@
package server package server
import ( import (
"context"
"fes/modules/config" "fes/modules/config"
"fes/modules/ui" "fes/modules/ui"
"fmt" "fmt"
"html/template" "log"
"io/fs"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
"github.com/pelletier/go-toml/v2"
lua "github.com/yuin/gopher-lua"
) )
/* this is the request data we pass over the bus to the application, via the fes.bus interface */ var routes map[string]string
type reqData struct {
path string
params map[string]string
}
/* performs relavent handling based on the directory passaed
*
* Special directories
* - www/ <= contains lua routes.
* - static/ <= static content accessable at /static/path or /static/dir/path.
* - include/ <= globally accessable lua functions, cannot directly access "fes" right now.
* - archive/ <= contains user facing files such as archives or dists.
*
*/
func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error {
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
nextBase := joinBase(base, entry.Name())
subEntries, err := os.ReadDir(path)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", path, err)
}
if err := handleDir(subEntries, path, routes, nextBase, isStatic); err != nil {
return err
}
continue
}
route := joinBase(base, entry.Name())
if !isStatic && strings.HasSuffix(entry.Name(), ".lua") {
name := strings.TrimSuffix(entry.Name(), ".lua")
if name == "index" {
routes[basePath(base)] = path
routes[route] = path
continue
}
route = joinBase(base, name)
} else if !isStatic && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
if name == "index" {
routes[basePath(base)] = path
routes[route] = path
continue
}
route = joinBase(base, name)
}
routes[route] = path
}
return nil
}
// TODO(vx-clutch): this should not be a function
func loadIncludeModules(L *lua.LState, includeDir string) *lua.LTable {
app := L.NewTable()
ents, err := os.ReadDir(includeDir)
if err != nil {
return app
}
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
}
base := strings.TrimSuffix(e.Name(), ".lua")
path := filepath.Join(includeDir, e.Name())
if _, err := os.Stat(path); err != nil {
tbl := L.NewTable()
tbl.RawSetString("error", lua.LString(fmt.Sprintf("file not found: %s", path)))
app.RawSetString(base, tbl)
continue
}
if err := L.DoFile(path); err != nil {
tbl := L.NewTable()
tbl.RawSetString("error", lua.LString(err.Error()))
app.RawSetString(base, tbl)
continue
}
val := L.Get(-1)
L.Pop(1)
tbl, ok := val.(*lua.LTable)
if !ok || tbl == nil {
tbl = L.NewTable()
}
app.RawSetString(base, tbl)
}
return app
}
/* renders the given lua route */
func renderRoute(entry string, cfg *config.AppConfig, requestData reqData) ([]byte, error) {
L := lua.NewState()
defer L.Close()
libFiles, err := fs.ReadDir(config.Lib, "lib")
if err == nil {
for _, de := range libFiles {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
continue
}
path := filepath.Join("lib", de.Name())
fileData, err := config.Lib.ReadFile(path)
if err != nil {
continue
}
L.DoString(string(fileData))
}
}
preloadLuaModule := func(name, path string) {
L.PreloadModule(name, func(L *lua.LState) int {
fileData, err := config.Lib.ReadFile(path)
if err != nil {
panic(err)
}
if err := L.DoString(string(fileData)); err != nil {
panic(err)
}
L.Push(L.Get(-1))
return 1
})
}
preloadLuaModule("lib.std", "lib/std.lua")
preloadLuaModule("lib.symbol", "lib/symbol.lua")
preloadLuaModule("lib.util", "lib/util.lua")
L.PreloadModule("fes", func(L *lua.LState) int {
mod := L.NewTable()
libModules := []string{}
if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil {
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
}
libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua"))
}
}
for _, modName := range libModules {
path := filepath.Join("lib", modName+".lua")
fileData, err := config.Lib.ReadFile(path)
if err != nil {
continue
}
if err := L.DoString(string(fileData)); err != nil {
continue
}
val := L.Get(-1)
L.Pop(1)
tbl, ok := val.(*lua.LTable)
if !ok || tbl == nil {
tbl = L.NewTable()
}
if modName == "fes" {
tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) })
} else {
mod.RawSetString(modName, tbl)
}
}
mod.RawSetString("app", loadIncludeModules(L, filepath.Join(".", "include")))
if cfg != nil {
site := L.NewTable()
site.RawSetString("version", lua.LString(cfg.App.Version))
site.RawSetString("name", lua.LString(cfg.App.Name))
authors := L.NewTable()
for i, a := range cfg.App.Authors {
authors.RawSetInt(i+1, lua.LString(a))
}
site.RawSetString("authors", authors)
mod.RawSetString("site", site)
}
bus := L.NewTable()
bus.RawSetString("url", lua.LString(requestData.path))
params := L.NewTable()
for k, v := range requestData.params {
params.RawSetString(k, lua.LString(v))
}
bus.RawSetString("params", params)
mod.RawSetString("bus", bus)
mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(markdownToHTML(L.ToString(1))))
return 1
}))
L.Push(mod)
return 1
})
if err := L.DoFile(entry); err != nil {
return []byte(""), err
}
if L.GetTop() == 0 {
return []byte(""), nil
}
L.SetGlobal("__fes_result", L.Get(-1))
if err := L.DoString("return tostring(__fes_result)"); err != nil {
L.GetGlobal("__fes_result")
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}
/* this indexes and generate the page for viewing the archive directory */
func generateArchiveIndex(fsPath string, urlPath string) (string, error) {
info, err := os.Stat(fsPath)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory")
}
ents, err := os.ReadDir(fsPath)
if err != nil {
return "", err
}
type entryInfo struct {
name string
isDir bool
href string
size int64
mod time.Time
}
var list []entryInfo
for _, e := range ents {
n := e.Name()
full := filepath.Join(fsPath, n)
st, err := os.Stat(full)
if err != nil {
continue
}
isd := st.IsDir()
displayName := n
if isd {
displayName = n + "/"
}
href := path.Join(urlPath, n)
if isd && !strings.HasSuffix(href, "/") {
href = href + "/"
}
size := int64(-1)
if !isd {
size = st.Size()
}
list = append(list, entryInfo{name: displayName, isDir: isd, href: href, size: size, mod: st.ModTime()})
}
sort.Slice(list, func(i, j int) bool {
if list[i].isDir != list[j].isDir {
return list[i].isDir
}
return strings.ToLower(list[i].name) < strings.ToLower(list[j].name)
})
urlPath = basePath(strings.TrimPrefix(urlPath, "/archive"))
var b strings.Builder
b.WriteString("<html>\n<head><title>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</title></head>\n<body>\n<h1>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</h1><hr><pre>")
if urlPath != "/archive" && urlPath != "/archive/" {
up := path.Dir(urlPath)
if up == "." {
up = "/archive"
}
if !strings.HasSuffix(up, "/") {
up = "/archive" + filepath.Dir(up) + "/"
}
b.WriteString(`<a href="` + template.HTMLEscapeString(up) + `">../</a>` + "\n")
} else {
b.WriteString(`<a href="../">../</a>` + "\n")
}
nameCol := 50
for _, ei := range list {
escapedName := template.HTMLEscapeString(ei.name)
dateStr := ei.mod.Local().Format("02-Jan-2006 15:04")
var sizeStr string
if ei.isDir {
sizeStr = "-"
} else {
sizeStr = fmt.Sprintf("%d", ei.size)
}
spaces := 1
if len(escapedName) < nameCol {
spaces = nameCol - len(escapedName)
}
line := `<a href="` + template.HTMLEscapeString(ei.href) + `">` + escapedName + `</a>` + strings.Repeat(" ", spaces) + dateStr + strings.Repeat(" ", 19-len(sizeStr)) + sizeStr + "\n"
b.WriteString(line)
}
b.WriteString("</pre><hr></body>\n</html>")
return b.String(), nil
}
/* generates the data for the not found page. Checks for user-defined source in this order
* 404.lua => 404.md => 404.html => default.
*/
func generateNotFoundData(cfg *config.AppConfig) []byte {
notFoundData := []byte(`
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>fes</center>
</body>
</html>
`)
if _, err := os.Stat(filepath.Join("www", "404.lua")); err == nil {
if nf, err := renderRoute("www/404.lua", cfg, reqData{}); err == nil {
notFoundData = nf
}
} else if _, err := os.Stat("www/404.md"); err == nil {
if buf, err := os.ReadFile("www/404.html"); err == nil {
notFoundData = []byte(markdownToHTML(string(buf)))
}
} else if _, err := os.Stat("www/404.html"); err == nil {
if buf, err := os.ReadFile("www/404.html"); err == nil {
notFoundData = buf
}
}
return notFoundData
}
/* helper to load all special directories */
func loadDirs() map[string]string {
routes := make(map[string]string)
if entries, err := os.ReadDir("www"); err == nil {
if err := handleDir(entries, "www", routes, "", false); err != nil {
ui.Warning("failed to handle www directory", err)
}
}
if entries, err := os.ReadDir("static"); err == nil {
if err := handleDir(entries, "static", routes, "/static", true); err != nil {
ui.Warning("failed to handle static directory", err)
}
}
if entries, err := os.ReadDir("archive"); err == nil {
if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
ui.Warning("failed to handle archive directory", err)
}
}
return routes
}
/* helper to parse the Fes.toml and generate config */
func parseConfig() config.AppConfig {
tomlDocument, err := os.ReadFile("Fes.toml")
if err != nil {
ui.Error("failed to read Fes.toml", err)
os.Exit(1)
}
docStr := fixMalformedToml(string(tomlDocument))
var cfg config.AppConfig
if err := toml.Unmarshal([]byte(docStr), &cfg); err != nil {
ui.Warning("failed to parse Fes.toml", err)
cfg.App.Authors = []string{"unknown"}
cfg.App.Name = "unknown"
cfg.App.Version = "unknown"
}
return cfg
}
/* helper to read the archive files */
func readArchive(w http.ResponseWriter, route string) error {
fsPath := "." + route
if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
if page, err := generateArchiveIndex(fsPath, route); err == nil {
w.Write([]byte(page))
return nil
} else {
return err
}
}
return nil
}
/* start the Fes server */
func Start(dir string) (*http.Server, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, fmt.Errorf("directory does not exist: %s", dir)
}
func Start(dir string) {
if err := os.Chdir(dir); err != nil { if err := os.Chdir(dir); err != nil {
return nil, fmt.Errorf("failed to change directory to %s: %w", dir, err) ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err)
} }
cfg := parseConfig() ui.Log("running root=%s, port=%d.", filepath.Clean(dir), *config.Port)
notFoundData := generateNotFoundData(&cfg)
routes := loadDirs() routes := loadDirs()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -442,7 +40,7 @@ func Start(dir string) (*http.Server, error) {
err = readArchive(w, route) err = readArchive(w, route)
} else { } else {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte(notFoundData)) w.Write([]byte("not found :("))
} }
return return
} }
@@ -456,7 +54,7 @@ func Start(dir string) (*http.Server, error) {
var data []byte var data []byte
if strings.HasSuffix(route, ".lua") { if strings.HasSuffix(route, ".lua") {
data, err = renderRoute(route, &cfg, reqData{path: r.URL.Path, params: params}) data, err = render(route, reqData{path: r.URL.Path, params: params})
} else if strings.HasSuffix(route, ".md") { } else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route) data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data))) data = []byte(markdownToHTML(string(data)))
@@ -471,24 +69,7 @@ func Start(dir string) (*http.Server, error) {
w.Write(data) w.Write(data)
}) })
ui.Log("Server initialized")
fmt.Printf("Server is running on http://localhost:%d\n", *config.Port) log.Fatal(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil))
srv := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%d", *config.Port),
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
ui.Error("Server failed: %v", err)
}
}()
return srv, nil
}
func Stop(srv *http.Server) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(ctx)
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,36 +4,8 @@ import (
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html" "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser" "github.com/gomarkdown/markdown/parser"
"regexp"
"strings"
) )
func joinBase(base, name string) string {
if base == "" {
return "/" + name
}
return base + "/" + name
}
func basePath(base string) string {
if base == "" || base == "." {
return "/"
}
return base
}
func fixMalformedToml(content string) string {
re := regexp.MustCompile(`(?m)^(\s*\w+\s*=\s*)$`)
return re.ReplaceAllStringFunc(content, func(match string) string {
parts := strings.Split(strings.TrimSpace(match), "=")
if len(parts) == 2 && strings.TrimSpace(parts[1]) == "" {
key := strings.TrimSpace(parts[0])
return key + " = \"\""
}
return match
})
}
func markdownToHTML(mdText string) string { func markdownToHTML(mdText string) string {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions) p := parser.NewWithExtensions(extensions)
@@ -43,3 +15,17 @@ func markdownToHTML(mdText string) string {
renderer := html.NewRenderer(opts) renderer := html.NewRenderer(opts)
return string(markdown.Render(doc, renderer)) return string(markdown.Render(doc, renderer))
} }
func basePath(base string) string {
if base == "" || base == "." {
return "/"
}
return base
}
func joinBase(base, name string) string {
if base == "" {
return "/" + name
}
return base + "/" + name
}

View File

@@ -4,75 +4,114 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"fes/modules/config" "fes/modules/config"
"fes/modules/version"
"github.com/fatih/color" "github.com/fatih/color"
) )
const ( const (
hint_color = 0xbda02a hintColor = 0xbda02a
) )
/* print out the current path (route) and relevant error */ func formatTimestamp() string {
func Path(path string, err error) { return time.Now().Format("02 Jan 2006 15:04")
path = strings.TrimPrefix(path, "/") }
if path == "" { func logMessage(prefix string, msg string, args ...any) {
path = "(null)" formatted := fmt.Sprintf(msg, args...)
} if prefix == "" {
fmt.Printf("%s * %s\n", formatTimestamp(), formatted)
fmt.Printf(" > %s ", path)
if err == nil {
OK("ok")
return
} else if errors.Is(err, config.ErrRouteMiss) {
WARN(config.ErrRouteMiss.Error())
} else { } else {
ERROR("bad") fmt.Printf("%s * %s: %s\n", formatTimestamp(), prefix, formatted)
} }
} }
/* print general system warning */ // Generic log
func Warning(msg string, err error) error { func Log(msg string, args ...any) {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.MagentaString("warning"), err) logMessage("", msg, args...)
return err
} }
/* print general system error */ // OK message (green)
func Error(msg string, err error) error { func OK(msg string, args ...any) {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.RedString("error"), err) formatted := fmt.Sprintf(msg, args...)
return err color.Green("%s * %s\n", formatTimestamp(), formatted)
} }
/* print fatality and panic */ // Warning message (magenta)
func Fatal(msg string, err error) error { func WARN(msg string, args ...any) {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.RedString("fatal"), err) formatted := fmt.Sprintf(msg, args...)
panic(err) color.Magenta("%s # %s\n", formatTimestamp(), formatted)
} }
/* print a useful hint to the user */ // Warning message (magenta)
func Hint(format string, args ...any) { func WARNING(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.Magenta("%s # WARNING %s\n", formatTimestamp(), formatted)
}
// Error message (red)
func ERROR(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.Red("%s ! %s\n", formatTimestamp(), formatted)
}
// Fatal message and panic
func FATAL(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.Red("%s % %s\n", formatTimestamp(), formatted)
panic(formatted)
}
// Hint message (custom color)
func Hint(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.RGB(func(hex int) (r, g, b int) { color.RGB(func(hex int) (r, g, b int) {
r = (hex >> 16) & 0xFF r = (hex >> 16) & 0xFF
g = (hex >> 8) & 0xFF g = (hex >> 8) & 0xFF
b = hex & 0xFF b = hex & 0xFF
return return
}(hint_color)).Printf("hint: %s\n", fmt.Sprintf(format, args...)) }(hintColor)).Printf("hint: %s\n", formatted)
} }
/* print message using the ok status color */ // Path logging: prints route and status
func OK(msg string) { func Path(path string, err error) {
color.Green(msg) path = strings.TrimPrefix(path, "/")
if path == "" {
path = "(null)"
}
if err == nil {
OK("Route: %s - ok", path)
} else if errors.Is(err, config.ErrRouteMiss) {
WARN("Route: %s - %s", path, config.ErrRouteMiss.Error())
} else {
ERROR("Route: %s - bad", path)
}
} }
/* print message using the warning status color */ // System warning with prefix
func WARN(msg string) { func Warning(msg string, err error) error {
color.Magenta(msg) WARN("%s: %v", msg, err)
return err
} }
/* print message using the error status color */ // System error with prefix
func ERROR(msg string) { func Error(msg string, err error) error {
color.Red(msg) ERROR("%s: %v", msg, err)
return err
}
// Fatal system error
func Fatal(msg string, err error) error {
FATAL("%s: %v", msg, err)
return err
}
// Log on Verbose
func LogVerbose(msg string, args ...any) {
if *config.Verbose {
Log(msg, args...)
}
} }

View File

@@ -9,7 +9,7 @@ var gitCommit string = "devel"
const PROGRAM_NAME string = "fes" const PROGRAM_NAME string = "fes"
const PROGRAM_NAME_LONG string = "fes/fSD" const PROGRAM_NAME_LONG string = "fes/fSD"
const VERSION string = "beta" const VERSION string = "1.0.1"
func Version() { func Version() {
fmt.Printf("%s version %s\n", PROGRAM_NAME_LONG, VERSION) fmt.Printf("%s version %s\n", PROGRAM_NAME_LONG, VERSION)
@@ -20,3 +20,7 @@ func FullVersion() {
fmt.Printf("%s+%s\n", VERSION, gitCommit) fmt.Printf("%s+%s\n", VERSION, gitCommit)
os.Exit(0) os.Exit(0)
} }
func GetCommit() string {
return gitCommit
}

88
scripts/test_all Executable file
View File

@@ -0,0 +1,88 @@
#! /bin/sh
# Runs Fes on all projects in test/
scriptversion="1"
#
#
# Copyright (C) 2025-2026 fSD
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
me=$0
version="$me/fSD v$scriptversion
Copyright (C) 2025-2026 fSD.
This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law."
usage="Usage: $me [OPTION]... <arg1> <arg2>
Runs Fes on all projects in test/
Options:
--help print this help and exit
--version output version information"
say() {
if [ -z "$q" ]; then
echo "$me: $*"
fi
}
lsay() {
if [ -z "$q" ]; then
echo " => $*"
fi
}
check_dep() {
printf 'checking for %s... ' "$1"
if command -v "$1" > /dev/null; then
echo "yes"
else
echo "no"
exit 1
fi
}
while [ $# -gt 0 ]; do
case $1 in
--help) echo "$usage"; exit 0 ;;
--version) echo "$version"; exit 0 ;;
-*) echo "$me: Unknown option '$1'." >&2; exit 1 ;;
esac
done
if [ ! -d "modules" ]; then
echo "$me: error: must be run in project root." >&2
exit 1
fi
if [ ! -f "fes" ]; then
echo "$me: error: run 'make'." >&2
exit 1
fi
for dir in test/*; do
./fes run "$dir" >/dev/null 2>&1 &
pid=$!
printf "test '%s'" "$dir"
printf "\nPress [Enter] to move to start the next..."
read -r unused
if kill -0 $pid 2>/dev/null; then
kill $pid
fi
done
echo "done"
kill $$
# BUGS
#
# doesn't kill the last test

View File

@@ -0,0 +1,22 @@
Pinnipeds [2] are the seals and their relatives, a group of semi-aquatic marine
mammals. The Pinnipedia is in the Order Carnivora. There are three seal
families: Odobenidae (walruses), Otariidae (eared seals, including sea lions
and fur seals), and Phocidae (true seals).[3]
Seals are sleek-bodied and barrel-shaped. Their bodies are well adapted to the
aquatic habitat where they spend most of their lives. Pinnipeds have flippers
for hands, big bulky bodies, doggish faces, and big eyes. Unlike cetaceans,
pinnipeds have their noses on their faces, and each nostril of the nose closes
when the pinniped goes underwater. Like cetaceans, pinnipeds have a thick layer
of blubber (fat) just under their skin: this blubber keeps them warm in cold
waters and keeps them fed during times when food is not easily found. When they
cannot find food, they live off the fat in the blubber.
Pinnipeds are carnivorous. This means they eat only meat (such as fish or
squid) and not plants. However, almost all pinnipeds can be eaten by polar
bears, sharks and killer whales.
Seals are often trained in zoos or aquariums to put on shows. However, in
Sweden, it is illegal to train a seal to balance a ball on its nose.[4]
From [Pinniped Wikipedia](https://simple.wikipedia.org/wiki/Pinniped)

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,5 +0,0 @@
[app]
name = "archive"
version = "0.0.1"
authors = ["fSD"]

View File

@@ -1,33 +0,0 @@
# archive
```
fes new archive
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,8 +0,0 @@
local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "vx-clutch")
site:h1("Hello, World!")
return site

View File

@@ -1,5 +0,0 @@
[app]
name = "lua-basic"
version = "0.0.1"
authors = ["fSD"]

View File

@@ -1,33 +0,0 @@
# lua-basic
```
fes new lua-basic
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

View File

@@ -1,8 +0,0 @@
local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "vx-clutch")
site:h1("Hello, World!")
return site

View File

@@ -1,5 +0,0 @@
[app]
name = "lua-error"
version = "0.0.1"
authors = ["fSD"]

View File

@@ -1,33 +0,0 @@
# lua-error
```
fes new lua-error
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

View File

@@ -1,6 +0,0 @@
local fes = require("fes")
local site = fes.fes()
Hello, World!
return site

View File

@@ -1,5 +0,0 @@
[app]
name = "lua-include"
version = "0.0.1"
authors = ["fSD"]

View File

@@ -1,33 +0,0 @@
# lua-include
```
fes new lua-include
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

View File

@@ -1,7 +0,0 @@
local test = {}
test.render = function(std)
return std.h2("Hello, World!")
end
return test

View File

@@ -1,10 +0,0 @@
local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "vx-clutch")
site:h1("Hello, World!")
site:note(fes.app.test.render(fes.std))
return site

View File

@@ -1,5 +0,0 @@
[app]
name = "markdown"
version = "0.0.1"
authors = ["vx-clutch"]

View File

@@ -1,33 +0,0 @@
# markdown
```
fes new markdown
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

View File

@@ -1,310 +0,0 @@
# Markdown: Syntax
* [Overview](#overview)
* [Philosophy](#philosophy)
* [Inline HTML](#html)
* [Automatic Escaping for Special Characters](#autoescape)
* [Block Elements](#block)
* [Paragraphs and Line Breaks](#p)
* [Headers](#header)
* [Blockquotes](#blockquote)
* [Lists](#list)
* [Code Blocks](#precode)
* [Horizontal Rules](#hr)
* [Span Elements](#span)
* [Links](#link)
* [Emphasis](#em)
* [Code](#code)
* [Images](#img)
* [Miscellaneous](#misc)
* [Backslash Escapes](#backslash)
* [Automatic Links](#autolink)
**Note:** This document is itself written using Markdown; you
can [see the source for it by adding '.text' to the URL](/projects/markdown/syntax.text).
----
## Overview
### Philosophy
Markdown is intended to be as easy-to-read and easy-to-write as is feasible.
Readability, however, is emphasized above all else. A Markdown-formatted
document should be publishable as-is, as plain text, without looking
like it's been marked up with tags or formatting instructions. While
Markdown's syntax has been influenced by several existing text-to-HTML
filters -- including [Setext](http://docutils.sourceforge.net/mirror/setext.html), [atx](http://www.aaronsw.com/2002/atx/), [Textile](http://textism.com/tools/textile/), [reStructuredText](http://docutils.sourceforge.net/rst.html),
[Grutatext](http://www.triptico.com/software/grutatxt.html), and [EtText](http://ettext.taint.org/doc/) -- the single biggest source of
inspiration for Markdown's syntax is the format of plain text email.
## Block Elements
### Paragraphs and Line Breaks
A paragraph is simply one or more consecutive lines of text, separated
by one or more blank lines. (A blank line is any line that looks like a
blank line -- a line containing nothing but spaces or tabs is considered
blank.) Normal paragraphs should not be indented with spaces or tabs.
The implication of the "one or more consecutive lines of text" rule is
that Markdown supports "hard-wrapped" text paragraphs. This differs
significantly from most other text-to-HTML formatters (including Movable
Type's "Convert Line Breaks" option) which translate every line break
character in a paragraph into a `<br />` tag.
When you *do* want to insert a `<br />` break tag using Markdown, you
end a line with two or more spaces, then type return.
### Headers
Markdown supports two styles of headers, [Setext] [1] and [atx] [2].
Optionally, you may "close" atx-style headers. This is purely
cosmetic -- you can use this if you think it looks better. The
closing hashes don't even need to match the number of hashes
used to open the header. (The number of opening hashes
determines the header level.)
### Blockquotes
Markdown uses email-style `>` characters for blockquoting. If you're
familiar with quoting passages of text in an email message, then you
know how to create a blockquote in Markdown. It looks best if you hard
wrap the text and put a `>` before every line:
> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
> consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
> Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
>
> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
> id sem consectetuer libero luctus adipiscing.
Markdown allows you to be lazy and only put the `>` before the first
line of a hard-wrapped paragraph:
> This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet,
consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus.
Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus.
> Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse
id sem consectetuer libero luctus adipiscing.
Blockquotes can be nested (i.e. a blockquote-in-a-blockquote) by
adding additional levels of `>`:
> This is the first level of quoting.
>
> > This is nested blockquote.
>
> Back to the first level.
Blockquotes can contain other Markdown elements, including headers, lists,
and code blocks:
> ## This is a header.
>
> 1. This is the first list item.
> 2. This is the second list item.
>
> Here's some example code:
>
> return shell_exec("echo $input | $markdown_script");
Any decent text editor should make email-style quoting easy. For
example, with BBEdit, you can make a selection and choose Increase
Quote Level from the Text menu.
### Lists
Markdown supports ordered (numbered) and unordered (bulleted) lists.
Unordered lists use asterisks, pluses, and hyphens -- interchangably
-- as list markers:
* Red
* Green
* Blue
is equivalent to:
+ Red
+ Green
+ Blue
and:
- Red
- Green
- Blue
Ordered lists use numbers followed by periods:
1. Bird
2. McHale
3. Parish
It's important to note that the actual numbers you use to mark the
list have no effect on the HTML output Markdown produces. The HTML
Markdown produces from the above list is:
If you instead wrote the list in Markdown like this:
1. Bird
1. McHale
1. Parish
or even:
3. Bird
1. McHale
8. Parish
you'd get the exact same HTML output. The point is, if you want to,
you can use ordinal numbers in your ordered Markdown lists, so that
the numbers in your source match the numbers in your published HTML.
But if you want to be lazy, you don't have to.
To make lists look nice, you can wrap items with hanging indents:
* Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi,
viverra nec, fringilla in, laoreet vitae, risus.
* Donec sit amet nisl. Aliquam semper ipsum sit amet velit.
Suspendisse id sem consectetuer libero luctus adipiscing.
But if you want to be lazy, you don't have to:
* Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi,
viverra nec, fringilla in, laoreet vitae, risus.
* Donec sit amet nisl. Aliquam semper ipsum sit amet velit.
Suspendisse id sem consectetuer libero luctus adipiscing.
List items may consist of multiple paragraphs. Each subsequent
paragraph in a list item must be indented by either 4 spaces
or one tab:
1. This is a list item with two paragraphs. Lorem ipsum dolor
sit amet, consectetuer adipiscing elit. Aliquam hendrerit
mi posuere lectus.
Vestibulum enim wisi, viverra nec, fringilla in, laoreet
vitae, risus. Donec sit amet nisl. Aliquam semper ipsum
sit amet velit.
2. Suspendisse id sem consectetuer libero luctus adipiscing.
It looks nice if you indent every line of the subsequent
paragraphs, but here again, Markdown will allow you to be
lazy:
* This is a list item with two paragraphs.
This is the second paragraph in the list item. You're
only required to indent the first line. Lorem ipsum dolor
sit amet, consectetuer adipiscing elit.
* Another item in the same list.
To put a blockquote within a list item, the blockquote's `>`
delimiters need to be indented:
* A list item with a blockquote:
> This is a blockquote
> inside a list item.
To put a code block within a list item, the code block needs
to be indented *twice* -- 8 spaces or two tabs:
* A list item with a code block:
<code goes here>
### Code Blocks
Pre-formatted code blocks are used for writing about programming or
markup source code. Rather than forming normal paragraphs, the lines
of a code block are interpreted literally. Markdown wraps a code block
in both `<pre>` and `<code>` tags.
To produce a code block in Markdown, simply indent every line of the
block by at least 4 spaces or 1 tab.
This is a normal paragraph:
This is a code block.
Here is an example of AppleScript:
tell application "Foo"
beep
end tell
A code block continues until it reaches a line that is not indented
(or the end of the article).
Within a code block, ampersands (`&`) and angle brackets (`<` and `>`)
are automatically converted into HTML entities. This makes it very
easy to include example HTML source code using Markdown -- just paste
it and indent it, and Markdown will handle the hassle of encoding the
ampersands and angle brackets. For example, this:
<div class="footer">
&copy; 2004 Foo Corporation
</div>
Regular Markdown syntax is not processed within code blocks. E.g.,
asterisks are just literal asterisks within a code block. This means
it's also easy to use Markdown to write about Markdown's own syntax.
```
tell application "Foo"
beep
end tell
```
## Span Elements
### Links
Markdown supports two style of links: *inline* and *reference*.
In both styles, the link text is delimited by [square brackets].
To create an inline link, use a set of regular parentheses immediately
after the link text's closing square bracket. Inside the parentheses,
put the URL where you want the link to point, along with an *optional*
title for the link, surrounded in quotes. For example:
This is [an example](http://example.com/) inline link.
[This link](http://example.net/) has no title attribute.
### Emphasis
Markdown treats asterisks (`*`) and underscores (`_`) as indicators of
emphasis. Text wrapped with one `*` or `_` will be wrapped with an
HTML `<em>` tag; double `*`'s or `_`'s will be wrapped with an HTML
`<strong>` tag. E.g., this input:
*single asterisks*
_single underscores_
**double asterisks**
__double underscores__
### Code
To indicate a span of code, wrap it with backtick quotes (`` ` ``).
Unlike a pre-formatted code block, a code span indicates code within a
normal paragraph. For example:
Use the `printf()` function.

View File

@@ -1,5 +0,0 @@
[app]
name = "minimal"
version = "0.0.1"
authors = ["fSD"]

View File

@@ -1,33 +0,0 @@
# minimal
```
fes new minimal
```
> **Know what you are doing?** Delete this file. Have fun!
## Project Structure
Inside your Fes project, you'll see the following directories and files:
```
.
├── Fes.toml
├── README.md
└── www
└── index.lua
```
Fes looks for `.lua` files in the `www/` directory. Each file is exposed as a route based on its file name.
## Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `fes run .` | Runs the project at `.` |
## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).

View File

@@ -1 +0,0 @@
return "Hello, World!"