Compare commits

..

59 Commits

Author SHA1 Message Date
1b6d27da99 Update README.md 2026-01-20 13:01:37 -05:00
87a84d6443 Update README.md 2026-01-20 13:01:13 -05:00
fedb515df1 rewrite README 2026-01-20 13:00:55 -05:00
383ba0d831 Merge branch 'rewrite' 2026-01-20 12:48:41 -05:00
af57f1a49d change the not found content 2026-01-20 12:47:58 -05:00
03af8d61c7 format 2026-01-20 12:47:44 -05:00
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
9364df2645 new year bump 2025-12-31 13:09:37 -05:00
4eaead6abc hint for running project 2025-12-31 12:16:59 -05:00
4abf2969ca new hint ui function 2025-12-31 12:16:51 -05:00
1c229f1b3e update examples 2025-12-28 20:17:46 -05:00
5a733b8642 create default site 2025-12-28 17:07:08 -05:00
11ab1630be maint: annotate source code 2025-12-28 16:39:33 -05:00
5fabd0233d fix: print usage to err instead of out 2025-12-28 16:38:40 -05:00
c43e905729 Merge branch 'new-default-project-creation-structure' 2025-12-28 15:52:26 -05:00
3430141184 new creation format 2025-12-28 15:51:58 -05:00
c5fe2eb7e7 maint: tidy 2025-12-28 14:08:55 -05:00
afd0d9eef4 doc: fix update date 2025-12-27 19:17:19 -05:00
b593aa26f0 doc: update documentation 2025-12-27 19:16:59 -05:00
fb8dc3cb90 fix: use 0.0.0.0 instead of 127.0.0.0 2025-12-27 16:29:55 -05:00
c681e342a0 fix: update docker interface 2025-12-27 12:04:04 -05:00
99e437b42b Docker image first version 2025-12-26 22:03:49 -05:00
56f22bb472 limit philosphy to 80 width 2025-12-26 15:00:27 -05:00
e53cc17025 default stylua formatting 2025-12-26 13:24:30 -05:00
2798cd6553 Update README.md 2025-12-26 10:16:42 -05:00
e4eb7d62e4 large changes 2025-12-26 10:06:23 -05:00
9d7dbc31ca patch: change git tree links to main website ones 2025-12-23 17:14:21 -05:00
489bf60da1 reorganize functions 2025-12-23 17:09:29 -05:00
c62210bbdd fix: archive ../ returns correctly 2025-12-23 16:20:49 -05:00
46def95dd9 update README to be more consistant 2025-12-22 21:15:49 -05:00
d2a832f451 add light-dark modes based on browser settings 2025-12-19 16:06:53 -05:00
fbcd2d8f06 refactor Start 2025-12-19 11:56:13 -05:00
311870683e fixes 2025-12-16 21:31:53 -05:00
e2c6f15e5b start beta versioning 2025-12-16 18:41:38 -05:00
10da72a1f6 doc: patch 2025-12-14 19:43:10 -05:00
522cbdece8 Merge pull request 'doc: first' (#4) from doc into main
Reviewed-on: #4
2025-12-14 19:41:43 -05:00
1427d0d780 doc: first 2025-12-14 19:40:55 -05:00
3cfc9b4aed Merge pull request 'rich-ui' (#3) from rich-ui into main
Reviewed-on: #3
2025-12-14 11:55:27 -05:00
2ff43cb8df restructure Start 2025-12-14 11:54:50 -05:00
f0e1f52ae2 factor out logging 2025-12-14 10:19:25 -05:00
65 changed files with 3275 additions and 1853 deletions

2
.gitignore vendored
View File

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

2
Brewfile Normal file
View File

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

View File

@@ -1,9 +1,20 @@
FROM alpine:3.20
FROM golang:1.25-alpine AS builder
ARG SITE
WORKDIR /src
RUN echo "https://git.vxserver.dev/api/packages/fSD/alpine/main/fports" >> /etc/apk/repositories
&& apk update \
&& apk add --no-cache fes
RUN apk add --no-cache git build-base
ENTRYPOINT ["fes", "run", ${SITE}]
COPY . .
RUN make
FROM alpine:3.19
COPY --from=builder /src/fes /usr/local/bin/fes
WORKDIR /app
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/fes"]
CMD ["run", "/app"]

View File

@@ -1,6 +1,6 @@
ISC License
Copyright (c) 2025 fSD
Copyright (c) 2025-2026 fSD
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
@@ -12,4 +12,4 @@ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
PERFORMANCE OF THIS SOFTWARE.

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
GO ?= go
.PHONY: build deps lint install
all: build
deps:
$(GO) mod download
build: deps
$(GO) build -ldflags "-X fes/modules/version.gitCommit=$(shell git rev-parse --short HEAD)" -o fes
@echo "Fes is now built to ./fes"
lint:
$(GO) vet ./...
$(GO) fmt ./...
install:
$(GO) install fes

View File

@@ -1,11 +1,13 @@
# Fes
A lightweight, static, and opinionated microframework.
Fes is an embedded Lua microwebframework for create static, extensible
websites. Using file-based routes you can easily strucutre you website just by
moving files around. See [fsdproject.org](https://fsdproject.org) for a
real-world example.
## Usage
This is the `fes new <?>` output
## A Simple Example
```lua
-- project/www/index.lua as created from fes new project
local fes = require("fes")
local site = fes.fes()
@@ -14,32 +16,9 @@ site:h1("Hello, World!")
return site
```
This can be ran with `fes run <project>` where `<project>` is the location of the website.
## Examples
See `examples/` for different features, `canonical/` contains the best practices for this microframework.
## Build
```sh
git clone https://git.vxserver.dev/fSD/fes.git
cd fes
go build .
./fes run examples/hello-world # if this works install
go install fes
```
$ fes run project
```
## Documentation
Run `fes run doc` for the documentation website or goto [docs.vxserver.dev](https://docs.vxserver.dev)
## License
ISC License
Copyright (C) 2025 fSD
See `COPYING`
## Contributing
See [fSD hacking](https://fsdproject.org/hacking) and [fSD community](https://fsdproject.org/community)

View File

@@ -1,143 +0,0 @@
local std = require("core.std")
local M = {}
M.__index = M
function M.fes(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
if site_config.favicon then
site_config.favicon = '<link rel="icon" type="image/x-icon" href="' .. site_config.favicon .. '">'
end
local self = {
version = site_config.version,
title = site_config.title,
copyright = site_config.copyright,
favicon = site_config.favicon,
header = header or [[
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
{{FAVICON}}
<title>{{TITLE}}</title>
<style>
html, body { min-height: 100%; margin: 0; padding: 0; background: #0f1113; color: #e6eef3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.5; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
body { padding: 36px; }
.container { max-width: 830px; margin: 0 auto; }
.container > *:not(.banner) { margin: 28px 0; }
h1, h2, h3, h4, h5, h6 { font-weight: 600; margin: 0 0 12px 0; }
h1 { font-size: 40px; margin-bottom: 20px; font-weight: 700; }
h2 { font-size: 32px; margin: 26px 0 14px; }
h3 { font-size: 26px; margin: 22px 0 12px; }
h4 { font-size: 20px; margin: 18px 0 10px; }
h5 { font-size: 16px; margin: 16px 0 8px; }
h6 { font-size: 14px; margin: 14px 0 6px; color: #9aa6b1; }
p { margin: 14px 0; }
a { color: #68a6ff; text-decoration: none; transition: color .15s ease, text-decoration-color .15s ease; }
.hidden { color: #dfe9ee; text-decoration: none; }
a:hover { text-decoration: underline; }
summary { cursor: pointer; }
details { background: #1a1c20; border: 1px solid rgba(255,255,255,.06); border-radius: 4px; padding: 14px 16px; margin: 16px 0; }
details summary { list-style: none; font-weight: 600; color: #e6eef3; display: flex; align-items: center; }
details summary::-webkit-details-marker { display: none; }
details summary::before { content: "▸"; margin-right: 8px; transition: transform .15s ease; color: #68a6ff; }
details[open] summary::before { transform: rotate(90deg); }
summary::after { content: "Expand"; margin-left: auto; font-size: 13px; color: #9aa6b1; }
details[open] summary::after { content: "Collapse"; }
details > *:not(summary) { margin-top: 12px; }
.note, pre, code { background: #1a1c20; border: 1px solid rgba(255,255,255,.06); }
.note { padding: 20px; border-radius: 4px; background: #1a1c20; border: 1px solid rgba(255,255,255,.06); margin: 28px 0; color: #dfe9ee; }
.note strong { color: #f0f6f8; }
.muted { color: #9aa6b1; }
.lead { font-size: 15px; margin-top: 8px; }
.callout { display: block; margin: 12px 0; }
.small { font-size: 13px; color: #9aa6b1; margin-top: 6px; }
.highlight { font-weight: 700; color: #cde7ff; }
ul, ol { margin: 14px 0; padding-left: 26px; }
.tl { display: grid; grid-template-columns: repeat(auto-fill, 200px); gap: 15px; list-style-type: none; padding: 0; margin: 0; justify-content: start; }
ul.tl li { padding: 10px; width: fit-content; }
li { margin: 6px 0; }
code { padding: 3px 7px; border-radius: 3px; font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; font-size: .9em; color: #cde7ff; }
pre { padding: 20px; border-radius: 4px; margin: 14px 0; overflow-x: auto; font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; font-size: 14px; line-height: 1.6; }
pre code { background: none; border: none; padding: 0; font-size: inherit; }
blockquote { border-left: 3px solid #68a6ff; padding-left: 18px; margin: 14px 0; color: #dfe9ee; font-style: italic; }
hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 26px 0; }
img { max-width: 100%; height: auto; border-radius: 4px; margin: 14px 0; }
table { width: 100%; border-collapse: collapse; margin: 14px 0; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid rgba(255,255,255,.06); }
th { background: #1a1c20; font-weight: 600; color: #f0f6f8; }
tr:hover { background: rgba(255,255,255,.02); }
.divider { margin: 26px 0; height: 1px; background: rgba(255,255,255,.1); }
.section { margin-top: 36px; }
.links { margin: 12px 0; }
.links a { display: inline-block; margin: 0 14px 6px 0; }
strong, b { font-weight: 600; color: #f0f6f8; }
em, i { font-style: italic; }
.center { display: flex; justify-content: center; align-items: center; }
.banner { width: 100%; box-sizing: border-box; text-align: center; background: #1a1c20; padding: 20px; border: 1px solid rgba(255,255,255,.06); border-bottom-right-radius: 8px; border-bottom-left-radius: 8px; color: #e6eef3; margin: -36px 0 28px 0; box-shadow: 0 0.2em 0.6em rgba(0,0,0,.4); }
.nav { margin-left: auto; margin-right: auto; }
.nav a { color: #cde7ff; }
.footer { background: #1a1c20; padding: 20px 0; border-top: 1px solid rgba(255,255,255,.1); font-size: 14px; color: #d4dde3; display: flex; justify-content: center; align-items: center; gap: 24px; margin-top: 28px !important; margin-bottom: 0; }
.left { text-align: left; float: left; }
.right { text-align: right; float: right; }
</style>
</head>
<body>
<div class="container">
]],
footer = footer or [[
<footer class="footer">
<a href="https://git.vxserver.dev/fSD/fes" target="_blank">Fes Powered</a>
<a href="https://www.lua.org/" target="_blank">Lua Powered</a>
<a href="https://git.vxserver.dev/fSD/fes/src/branch/master/COPYING" target="_blank">ISC Licensed</a>
<p>{{COPYRIGHT}}</p>
</footer>
</div>
</body>
</html>
]],
parts = {}
}
return setmetatable(self, M)
end
function M:custom(str)
table.insert(self.parts, str)
return self
end
for name, func in pairs(std) do
if type(func) == "function" then
M[name] = function(self, ...)
local result = func(...)
table.insert(self.parts, result)
return self
end
end
end
function M:build()
local header = self.header
header = header:gsub("{{TITLE}}", self.title or "Document")
local favicon_html = self.favicon and ('<link rel="icon" type="image/x-icon" href="' .. self.favicon .. '">')
header = header:gsub("{{FAVICON}}", 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>">]])
local footer = self.footer:gsub("{{COPYRIGHT}}", self.copyright or "&#169; The Copyright Holder")
return header .. table.concat(self.parts, "\n") .. footer
end
M.__tostring = function(self)
return self:build()
end
return M

View File

@@ -1,752 +0,0 @@
-- Module options:
local always_use_lpeg = false
local register_global_module_table = false
local global_module_name = 'json'
--[==[
David Kolf's JSON module for Lua 5.1 - 5.4
Version 2.8
For the documentation see the corresponding readme.txt or visit
<http://dkolf.de/dkjson-lua/>.
You can contact the author by sending an e-mail to 'david' at the
domain 'dkolf.de'.
Copyright (C) 2010-2024 David Heiko Kolf
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
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.
--]==]
-- global dependencies:
local pairs, type, tostring, tonumber, getmetatable, setmetatable =
pairs, type, tostring, tonumber, getmetatable, setmetatable
local error, require, pcall, select = error, require, pcall, select
local floor, huge = math.floor, math.huge
local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat =
string.rep, string.gsub, string.sub, string.byte, string.char,
string.find, string.len, string.format
local strmatch = string.match
local concat = table.concat
local json = { version = "dkjson 2.8" }
local jsonlpeg = {}
if register_global_module_table then
if always_use_lpeg then
_G[global_module_name] = jsonlpeg
else
_G[global_module_name] = json
end
end
local _ENV = nil -- blocking globals in Lua 5.2 and later
pcall (function()
-- Enable access to blocked metatables.
-- Don't worry, this module doesn't change anything in them.
local debmeta = require "debug".getmetatable
if debmeta then getmetatable = debmeta end
end)
json.null = setmetatable ({}, {
__tojson = function () return "null" end
})
local function isarray (tbl)
local max, n, arraylen = 0, 0, 0
for k,v in pairs (tbl) do
if k == 'n' and type(v) == 'number' then
arraylen = v
if v > max then
max = v
end
else
if type(k) ~= 'number' or k < 1 or floor(k) ~= k then
return false
end
if k > max then
max = k
end
n = n + 1
end
end
if max > 10 and max > arraylen and max > n * 2 then
return false -- don't create an array with too many holes
end
return true, max
end
local escapecodes = {
["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f",
["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t"
}
local function escapeutf8 (uchar)
local value = escapecodes[uchar]
if value then
return value
end
local a, b, c, d = strbyte (uchar, 1, 4)
a, b, c, d = a or 0, b or 0, c or 0, d or 0
if a <= 0x7f then
value = a
elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then
value = (a - 0xc0) * 0x40 + b - 0x80
elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then
value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80
elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then
value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80
else
return ""
end
if value <= 0xffff then
return strformat ("\\u%.4x", value)
elseif value <= 0x10ffff then
-- encode as UTF-16 surrogate pair
value = value - 0x10000
local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400)
return strformat ("\\u%.4x\\u%.4x", highsur, lowsur)
else
return ""
end
end
local function fsub (str, pattern, repl)
-- gsub always builds a new string in a buffer, even when no match
-- exists. First using find should be more efficient when most strings
-- don't contain the pattern.
if strfind (str, pattern) then
return gsub (str, pattern, repl)
else
return str
end
end
local function quotestring (value)
-- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js
value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8)
if strfind (value, "[\194\216\220\225\226\239]") then
value = fsub (value, "\194[\128-\159\173]", escapeutf8)
value = fsub (value, "\216[\128-\132]", escapeutf8)
value = fsub (value, "\220\143", escapeutf8)
value = fsub (value, "\225\158[\180\181]", escapeutf8)
value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8)
value = fsub (value, "\226\129[\160-\175]", escapeutf8)
value = fsub (value, "\239\187\191", escapeutf8)
value = fsub (value, "\239\191[\176-\191]", escapeutf8)
end
return "\"" .. value .. "\""
end
json.quotestring = quotestring
local function replace(str, o, n)
local i, j = strfind (str, o, 1, true)
if i then
return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1)
else
return str
end
end
-- locale independent num2str and str2num functions
local decpoint, numfilter
local function updatedecpoint ()
decpoint = strmatch(tostring(0.5), "([^05+])")
-- build a filter that can be used to remove group separators
numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+"
end
updatedecpoint()
local function num2str (num)
return replace(fsub(tostring(num), numfilter, ""), decpoint, ".")
end
local function str2num (str)
local num = tonumber(replace(str, ".", decpoint))
if not num then
updatedecpoint()
num = tonumber(replace(str, ".", decpoint))
end
return num
end
local function addnewline2 (level, buffer, buflen)
buffer[buflen+1] = "\n"
buffer[buflen+2] = strrep (" ", level)
buflen = buflen + 2
return buflen
end
function json.addnewline (state)
if state.indent then
state.bufferlen = addnewline2 (state.level or 0,
state.buffer, state.bufferlen or #(state.buffer))
end
end
local encode2 -- forward declaration
local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state)
local kt = type (key)
if kt ~= 'string' and kt ~= 'number' then
return nil, "type '" .. kt .. "' is not supported as a key by JSON."
end
if prev then
buflen = buflen + 1
buffer[buflen] = ","
end
if indent then
buflen = addnewline2 (level, buffer, buflen)
end
-- When Lua is compiled with LUA_NOCVTN2S this will fail when
-- numbers are mixed into the keys of the table. JSON keys are always
-- strings, so this would be an implicit conversion too and the failure
-- is intentional.
buffer[buflen+1] = quotestring (key)
buffer[buflen+2] = ":"
return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state)
end
local function appendcustom(res, buffer, state)
local buflen = state.bufferlen
if type (res) == 'string' then
buflen = buflen + 1
buffer[buflen] = res
end
return buflen
end
local function exception(reason, value, state, buffer, buflen, defaultmessage)
defaultmessage = defaultmessage or reason
local handler = state.exception
if not handler then
return nil, defaultmessage
else
state.bufferlen = buflen
local ret, msg = handler (reason, value, state, defaultmessage)
if not ret then return nil, msg or defaultmessage end
return appendcustom(ret, buffer, state)
end
end
function json.encodeexception(reason, value, state, defaultmessage)
return quotestring("<" .. defaultmessage .. ">")
end
encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state)
local valtype = type (value)
local valmeta = getmetatable (value)
valmeta = type (valmeta) == 'table' and valmeta -- only tables
local valtojson = valmeta and valmeta.__tojson
if valtojson then
if tables[value] then
return exception('reference cycle', value, state, buffer, buflen)
end
tables[value] = true
state.bufferlen = buflen
local ret, msg = valtojson (value, state)
if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end
tables[value] = nil
buflen = appendcustom(ret, buffer, state)
elseif value == nil then
buflen = buflen + 1
buffer[buflen] = "null"
elseif valtype == 'number' then
local s
if value ~= value or value >= huge or -value >= huge then
-- This is the behaviour of the original JSON implementation.
s = "null"
else
s = num2str (value)
end
buflen = buflen + 1
buffer[buflen] = s
elseif valtype == 'boolean' then
buflen = buflen + 1
buffer[buflen] = value and "true" or "false"
elseif valtype == 'string' then
buflen = buflen + 1
buffer[buflen] = quotestring (value)
elseif valtype == 'table' then
if tables[value] then
return exception('reference cycle', value, state, buffer, buflen)
end
tables[value] = true
level = level + 1
local isa, n = isarray (value)
if n == 0 and valmeta and valmeta.__jsontype == 'object' then
isa = false
end
local msg
if isa then -- JSON array
buflen = buflen + 1
buffer[buflen] = "["
for i = 1, n do
buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end
if i < n then
buflen = buflen + 1
buffer[buflen] = ","
end
end
buflen = buflen + 1
buffer[buflen] = "]"
else -- JSON object
local prev = false
buflen = buflen + 1
buffer[buflen] = "{"
local order = valmeta and valmeta.__jsonorder or globalorder
if order then
local used = {}
n = #order
for i = 1, n do
local k = order[i]
local v = value[k]
if v ~= nil then
used[k] = true
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end
prev = true -- add a seperator before the next element
end
end
for k,v in pairs (value) do
if not used[k] then
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end
prev = true -- add a seperator before the next element
end
end
else -- unordered
for k,v in pairs (value) do
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end
prev = true -- add a seperator before the next element
end
end
if indent then
buflen = addnewline2 (level - 1, buffer, buflen)
end
buflen = buflen + 1
buffer[buflen] = "}"
end
tables[value] = nil
else
return exception ('unsupported type', value, state, buffer, buflen,
"type '" .. valtype .. "' is not supported by JSON.")
end
return buflen
end
function json.encode (value, state)
state = state or {}
local oldbuffer = state.buffer
local buffer = oldbuffer or {}
state.buffer = buffer
updatedecpoint()
local ret, msg = encode2 (value, state.indent, state.level or 0,
buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state)
if not ret then
error (msg, 2)
elseif oldbuffer == buffer then
state.bufferlen = ret
return true
else
state.bufferlen = nil
state.buffer = nil
return concat (buffer)
end
end
local function loc (str, where)
local line, pos, linepos = 1, 1, 0
while true do
pos = strfind (str, "\n", pos, true)
if pos and pos < where then
line = line + 1
linepos = pos
pos = pos + 1
else
break
end
end
return strformat ("line %d, column %d", line, where - linepos)
end
local function unterminated (str, what, where)
return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where)
end
local function scanwhite (str, pos)
while true do
pos = strfind (str, "%S", pos)
if not pos then return nil end
local sub2 = strsub (str, pos, pos + 1)
if sub2 == "\239\187" and strsub (str, pos + 2, pos + 2) == "\191" then
-- UTF-8 Byte Order Mark
pos = pos + 3
elseif sub2 == "//" then
pos = strfind (str, "[\n\r]", pos + 2)
if not pos then return nil end
elseif sub2 == "/*" then
pos = strfind (str, "*/", pos + 2)
if not pos then return nil end
pos = pos + 2
else
return pos
end
end
end
local escapechars = {
["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f",
["n"] = "\n", ["r"] = "\r", ["t"] = "\t"
}
local function unichar (value)
if value < 0 then
return nil
elseif value <= 0x007f then
return strchar (value)
elseif value <= 0x07ff then
return strchar (0xc0 + floor(value/0x40),
0x80 + (floor(value) % 0x40))
elseif value <= 0xffff then
return strchar (0xe0 + floor(value/0x1000),
0x80 + (floor(value/0x40) % 0x40),
0x80 + (floor(value) % 0x40))
elseif value <= 0x10ffff then
return strchar (0xf0 + floor(value/0x40000),
0x80 + (floor(value/0x1000) % 0x40),
0x80 + (floor(value/0x40) % 0x40),
0x80 + (floor(value) % 0x40))
else
return nil
end
end
local function scanstring (str, pos)
local lastpos = pos + 1
local buffer, n = {}, 0
while true do
local nextpos = strfind (str, "[\"\\]", lastpos)
if not nextpos then
return unterminated (str, "string", pos)
end
if nextpos > lastpos then
n = n + 1
buffer[n] = strsub (str, lastpos, nextpos - 1)
end
if strsub (str, nextpos, nextpos) == "\"" then
lastpos = nextpos + 1
break
else
local escchar = strsub (str, nextpos + 1, nextpos + 1)
local value
if escchar == "u" then
value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16)
if value then
local value2
if 0xD800 <= value and value <= 0xDBff then
-- we have the high surrogate of UTF-16. Check if there is a
-- low surrogate escaped nearby to combine them.
if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then
value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16)
if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then
value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000
else
value2 = nil -- in case it was out of range for a low surrogate
end
end
end
value = value and unichar (value)
if value then
if value2 then
lastpos = nextpos + 12
else
lastpos = nextpos + 6
end
end
end
end
if not value then
value = escapechars[escchar] or escchar
lastpos = nextpos + 2
end
n = n + 1
buffer[n] = value
end
end
if n == 1 then
return buffer[1], lastpos
elseif n > 1 then
return concat (buffer), lastpos
else
return "", lastpos
end
end
local scanvalue -- forward declaration
local function scantable (what, closechar, str, startpos, nullval, objectmeta, arraymeta)
local tbl, n = {}, 0
local pos = startpos + 1
if what == 'object' then
setmetatable (tbl, objectmeta)
else
setmetatable (tbl, arraymeta)
end
while true do
pos = scanwhite (str, pos)
if not pos then return unterminated (str, what, startpos) end
local char = strsub (str, pos, pos)
if char == closechar then
return tbl, pos + 1
end
local val1, err
val1, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta)
if err then return nil, pos, err end
pos = scanwhite (str, pos)
if not pos then return unterminated (str, what, startpos) end
char = strsub (str, pos, pos)
if char == ":" then
if val1 == nil then
return nil, pos, "cannot use nil as table index (at " .. loc (str, pos) .. ")"
end
pos = scanwhite (str, pos + 1)
if not pos then return unterminated (str, what, startpos) end
local val2
val2, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta)
if err then return nil, pos, err end
tbl[val1] = val2
pos = scanwhite (str, pos)
if not pos then return unterminated (str, what, startpos) end
char = strsub (str, pos, pos)
else
n = n + 1
tbl[n] = val1
end
if char == "," then
pos = pos + 1
end
end
end
scanvalue = function (str, pos, nullval, objectmeta, arraymeta)
pos = pos or 1
pos = scanwhite (str, pos)
if not pos then
return nil, strlen (str) + 1, "no valid JSON value (reached the end)"
end
local char = strsub (str, pos, pos)
if char == "{" then
return scantable ('object', "}", str, pos, nullval, objectmeta, arraymeta)
elseif char == "[" then
return scantable ('array', "]", str, pos, nullval, objectmeta, arraymeta)
elseif char == "\"" then
return scanstring (str, pos)
else
local pstart, pend = strfind (str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos)
if pstart then
local number = str2num (strsub (str, pstart, pend))
if number then
return number, pend + 1
end
end
pstart, pend = strfind (str, "^%a%w*", pos)
if pstart then
local name = strsub (str, pstart, pend)
if name == "true" then
return true, pend + 1
elseif name == "false" then
return false, pend + 1
elseif name == "null" then
return nullval, pend + 1
end
end
return nil, pos, "no valid JSON value at " .. loc (str, pos)
end
end
local function optionalmetatables(...)
if select("#", ...) > 0 then
return ...
else
return {__jsontype = 'object'}, {__jsontype = 'array'}
end
end
function json.decode (str, pos, nullval, ...)
local objectmeta, arraymeta = optionalmetatables(...)
return scanvalue (str, pos, nullval, objectmeta, arraymeta)
end
function json.use_lpeg ()
local g = require ("lpeg")
if type(g.version) == 'function' and g.version() == "0.11" then
error "due to a bug in LPeg 0.11, it cannot be used for JSON matching"
end
local pegmatch = g.match
local P, S, R = g.P, g.S, g.R
local function ErrorCall (str, pos, msg, state)
if not state.msg then
state.msg = msg .. " at " .. loc (str, pos)
state.pos = pos
end
return false
end
local function Err (msg)
return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall)
end
local function ErrorUnterminatedCall (str, pos, what, state)
return ErrorCall (str, pos - 1, "unterminated " .. what, state)
end
local SingleLineComment = P"//" * (1 - S"\n\r")^0
local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/"
local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0
local function ErrUnterminated (what)
return g.Cmt (g.Cc (what) * g.Carg (2), ErrorUnterminatedCall)
end
local PlainChar = 1 - S"\"\\\n\r"
local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars
local HexDigit = R("09", "af", "AF")
local function UTF16Surrogate (match, pos, high, low)
high, low = tonumber (high, 16), tonumber (low, 16)
if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then
return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000)
else
return false
end
end
local function UTF16BMP (hex)
return unichar (tonumber (hex, 16))
end
local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit))
local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP
local Char = UnicodeEscape + EscapeSequence + PlainChar
local String = P"\"" * (g.Cs (Char ^ 0) * P"\"" + ErrUnterminated "string")
local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0))
local Fractal = P"." * R"09"^0
local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1
local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num
local Constant = P"true" * g.Cc (true) + P"false" * g.Cc (false) + P"null" * g.Carg (1)
local SimpleValue = Number + String + Constant
local ArrayContent, ObjectContent
-- The functions parsearray and parseobject parse only a single value/pair
-- at a time and store them directly to avoid hitting the LPeg limits.
local function parsearray (str, pos, nullval, state)
local obj, cont
local start = pos
local npos
local t, nt = {}, 0
repeat
obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state)
if cont == 'end' then
return ErrorUnterminatedCall (str, start, "array", state)
end
pos = npos
if cont == 'cont' or cont == 'last' then
nt = nt + 1
t[nt] = obj
end
until cont ~= 'cont'
return pos, setmetatable (t, state.arraymeta)
end
local function parseobject (str, pos, nullval, state)
local obj, key, cont
local start = pos
local npos
local t = {}
repeat
key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state)
if cont == 'end' then
return ErrorUnterminatedCall (str, start, "object", state)
end
pos = npos
if cont == 'cont' or cont == 'last' then
t[key] = obj
end
until cont ~= 'cont'
return pos, setmetatable (t, state.objectmeta)
end
local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray)
local Object = P"{" * g.Cmt (g.Carg(1) * g.Carg(2), parseobject)
local Value = Space * (Array + Object + SimpleValue)
local ExpectedValue = Value + Space * Err "value expected"
local ExpectedKey = String + Err "key expected"
local End = P(-1) * g.Cc'end'
local ErrInvalid = Err "invalid JSON"
ArrayContent = (Value * Space * (P"," * g.Cc'cont' + P"]" * g.Cc'last'+ End + ErrInvalid) + g.Cc(nil) * (P"]" * g.Cc'empty' + End + ErrInvalid)) * g.Cp()
local Pair = g.Cg (Space * ExpectedKey * Space * (P":" + Err "colon expected") * ExpectedValue)
ObjectContent = (g.Cc(nil) * g.Cc(nil) * P"}" * g.Cc'empty' + End + (Pair * Space * (P"," * g.Cc'cont' + P"}" * g.Cc'last' + End + ErrInvalid) + ErrInvalid)) * g.Cp()
local DecodeValue = ExpectedValue * g.Cp ()
jsonlpeg.version = json.version
jsonlpeg.encode = json.encode
jsonlpeg.null = json.null
jsonlpeg.quotestring = json.quotestring
jsonlpeg.addnewline = json.addnewline
jsonlpeg.encodeexception = json.encodeexception
jsonlpeg.using_lpeg = true
function jsonlpeg.decode (str, pos, nullval, ...)
local state = {}
state.objectmeta, state.arraymeta = optionalmetatables(...)
local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state)
if state.msg then
return nil, state.pos, state.msg
else
return obj, retpos
end
end
-- cache result of this function:
json.use_lpeg = function () return jsonlpeg end
jsonlpeg.use_lpeg = json.use_lpeg
return jsonlpeg
end
if always_use_lpeg then
return json.use_lpeg()
end
return json

View File

@@ -1,253 +0,0 @@
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
function M.a(link, str)
link = link or "https://example.com"
str = str or link
return "<a href=\"" .. link .. "\">" .. str .. "</a>"
end
function M.ha(link, str)
link = link or "https://example.com"
str = str or link
return "<a class=\"hidden\" href=\"" .. link .. "\">" .. str .. "</a>"
end
function M.external(link, str)
return "<a target=\"_blank\" href=\"" .. link .. "\">" .. str .. "</a>"
end
function M.note(str)
return '<div class="note">' .. str .. '</div>'
end
function M.muted(str)
return '<div class="muted">' .. str .. '</div>'
end
function M.callout(str)
return '<div class="callout">' .. str .. '</div>'
end
function M.h1(str)
return "<h1>" .. (str or "") .. "</h1>"
end
function M.h2(str)
return "<h2>" .. (str or "") .. "</h2>"
end
function M.h3(str)
return "<h3>" .. (str or "") .. "</h3>"
end
function M.h4(str) return "<h4>" .. (str or "") .. "</h4>"
end
function M.h5(str)
return "<h5>" .. (str or "") .. "</h5>"
end
function M.h6(str)
return "<h6>" .. (str or "") .. "</h6>"
end
function M.p(str)
return "<p>" .. (str or "") .. "</p>"
end
function M.pre(str)
return "<pre>" .. (str or "") .. "</pre>"
end
function M.code(str)
return "<pre><code>" .. (str or "") .. "</code></pre>"
end
function M.ul(items)
items = items or {}
local html = "<ul>"
for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>"
end
html = html .. "</ul>"
return html
end
function M.ol(items)
items = items or {}
local html = "<ol>"
for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>"
end
html = html .. "</ol>"
return html
end
function M.tl(items)
items = items or {}
local html = '<ul class="tl">'
for _, item in ipairs(items) do
html = html .. "<li>" .. tostring(item) .. "</li>"
end
html = html .. "</ul>"
return html
end
function M.blockquote(str)
return "<blockquote>" .. (str or "") .. "</blockquote>"
end
function M.hr()
return "<hr>"
end
function M.img(src, alt)
src = src or ""
alt = alt or ""
return '<img src="' .. src .. '" alt="' .. alt .. '">'
end
function M.strong(str)
return "<strong>" .. (str or "") .. "</strong>"
end
function M.em(str)
return "<em>" .. (str or "") .. "</em>"
end
function M.br()
return "<br>"
end
function M.div(content, class)
content = content or ""
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<div" .. class_attr .. ">" .. content .. "</div>"
end
function M.span(content, class)
content = content or ""
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<span" .. class_attr .. ">" .. content .. "</span>"
end
-- HTML escaping utility
function M.escape(str)
str = tostring(str or "")
str = str:gsub("&", "&amp;")
str = str:gsub("<", "&lt;")
str = str:gsub(">", "&gt;")
str = str:gsub('"', "&quot;")
str = str:gsub("'", "&#39;")
return str
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
-- Join array with separator
function M.join(arr, sep)
arr = arr or {}
sep = sep or ", "
local result = {}
for _, v in ipairs(arr) do
table.insert(result, tostring(v))
end
return table.concat(result, sep)
end
-- Trim whitespace
function M.trim(str)
str = tostring(str or "")
return str:match("^%s*(.-)%s*$")
end
-- Table HTML generator
function M.table(headers, rows)
headers = headers or {}
rows = rows or {}
local html = "<table><thead><tr>"
for _, header in ipairs(headers) do
html = html .. "<th>" .. tostring(header) .. "</th>"
end
html = html .. "</tr></thead><tbody>"
for _, row in ipairs(rows) do
html = html .. "<tr>"
for _, cell in ipairs(row) do
html = html .. "<td>" .. tostring(cell) .. "</td>"
end
html = html .. "</tr>"
end
html = html .. "</tbody></table>"
return html
end
function M.highlight(str)
return '<span class="highlight">' .. (str or "") .. "</span>"
end
function M.banner(str)
return '<div class="banner">' .. (str or "") .. "</div>"
end
function M.center(str)
return '<div class="center">' .. (str or "") .. "</div>"
end
function M.nav(link, str)
link = link or "example.com"
str = str or link
return '<a class="nav" href="' .. link .. '">' .. str .. "</a>"
end
function M.rl(r, l)
r = r or ""
l = l or ""
return string.format('<span class="left">%s</span><span class="right">%s</span>', r, l)
end
return M

View File

@@ -1,7 +0,0 @@
local M = {}
M.copyright = "&#169;"
M.registered_trademark = "&#174;"
M.trademark = "&#8482;"
return M

View File

@@ -1,14 +0,0 @@
local std = require("core.std")
local symbol = require("core.symbol")
local M = {}
function M.cc(tbl)
return table.concat(tbl)
end
function M.copyright(link, holder)
return symbol.copyright .. " " .. std.external(link, holder)
end
return M

View File

@@ -1,7 +0,0 @@
local foo = {}
foo.render = function()
return "This was called from a foo function"
end
return foo

View File

@@ -1,15 +0,0 @@
local fes = require("fes")
local std = fes.std
local site = fes.fes()
site.copyright = fes.util.copyright("https://git.vxserver.dev/fSD", "fSD")
site:h1("Hello, World!")
site:note(
fes.app.foo.render()
)
return site

View File

@@ -1,17 +0,0 @@
local fes = require("fes")
local std = fes.std
local site = fes.fes()
site.copyright = fes.util.copyright("https://git.vxserver.dev/fSD", "fSD")
site:h1("Hello, World!")
site:note(fes.util.cc {
std.h2("Files"),
std.ul {
std.a("/archive", "to the file room!"),
}
})
return site

View File

@@ -1,6 +0,0 @@
local fes = require("fes")
local site = fes.fes()
This is what an error looks like
return site

View File

@@ -1 +0,0 @@
# Hello, World!

14
go.mod
View File

@@ -3,13 +3,15 @@ module fes
go 1.25.4
require (
github.com/fatih/color v1.18.0 // indirect
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a // indirect
github.com/gomarkdown/mdtohtml v0.0.0-20240124153210-d773061d1585 // indirect
github.com/fatih/color v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/yuin/gopher-lua v1.1.1
)
require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/sys v0.25.0 // indirect
)

2
go.sum
View File

@@ -1,9 +1,7 @@
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/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/mdtohtml v0.0.0-20240124153210-d773061d1585/go.mod h1:6grYm5/uY15CwgBBqwA3+o/cAzaxssckznJ0B35ouBY=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=

634
index.html Normal file
View File

@@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Documentation</title>
<style>
html, body {
min-height: 100%;
margin: 0;
padding: 0;
background: #0f1113;
color: #e6eef3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
max-width: 830px;
margin: 0 auto;
padding: 36px;
}
header {
margin-bottom: 36px;
}
h1 {
font-size: 40px;
font-weight: 700;
margin: 0 0 20px 0;
}
h2 {
font-size: 32px;
margin: 26px 0 14px;
border-bottom: 1px solid rgba(255,255,255,.1);
padding-bottom: 6px;
}
h3 {
font-size: 26px;
margin: 22px 0 12px;
}
p {
margin: 14px 0;
}
header p {
color: #9aa6b1;
}
nav {
margin: 28px 0;
padding: 20px;
background: #1a1c20;
border: 1px solid rgba(255,255,255,.06);
border-radius: 4px;
}
nav h2 {
font-size: 20px;
margin: 0 0 12px 0;
border: none;
padding: 0;
}
nav ul {
list-style: none;
padding: 0;
margin: 0;
}
nav li {
margin: 6px 0;
}
a {
color: #68a6ff;
text-decoration: none;
transition: color .15s ease;
}
a:hover {
text-decoration: underline;
}
section {
margin-top: 36px;
}
ul, ol {
margin: 14px 0;
padding-left: 26px;
}
li {
margin: 6px 0;
}
code {
padding: 3px 7px;
border-radius: 3px;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: .9em;
color: #cde7ff;
background: #1a1c20;
border: 1px solid rgba(255,255,255,.06);
}
pre {
padding: 20px;
border-radius: 4px;
margin: 14px 0;
overflow-x: auto;
background: #1a1c20;
border: 1px solid rgba(255,255,255,.06);
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
}
pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
color: inherit;
}
blockquote {
border-left: 3px solid #68a6ff;
padding-left: 18px;
margin: 14px 0;
color: #dfe9ee;
font-style: italic;
}
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,.06);
}
th {
background: #1a1c20;
font-weight: 600;
color: #f0f6f8;
}
tr:hover {
background: rgba(255,255,255,.02);
}
footer {
margin-top: 48px;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,.1);
color: #9aa6b1;
font-size: 14px;
}
</style>
</head>
<body>
<main>
<header>
<h1>Documentation</h1>
<p>Fes: A lightweight, static, and opinionated microframework.</p>
</header>
<nav>
<h2>Contents</h2>
<ul>
<li><a href="#introduction">Introduction</a></li>
<li><a href="#installation">Installation</a></li>
<li><a href="#usage">Usage</a></li>
<li><a href="#quick">Quick Start</a></li>
<li><a href="#cli-reference">Cli Reference</a></li>
<li><a href="#reference">Reference</a></li>
</ul>
</nav>
<section id="introduction">
<h2>Introduction</h2>
<p>Fes, or Free Easy Site, is a microframework used for small static sites. It is not designed for complex web applications and that is why it is good. Yes, I hate modern web and that is the reason this exists.</p>
</section>
<section id="installation">
<h2>Installation</h2>
<pre><code>git clone https://git.vxserver.dev/fSD/fes</code></pre>
<pre><code>cd fes</code></pre>
<pre><code>go install fes</code></pre>
</section>
<section id="usage">
<h2>Usage</h2>
<p>Typical workflows and examples.</p>
<ul>
<li>Creating project</li>
<li>Hosting websites</li>
<li>Generating websites</li>
</ul>
</section>
<section id="quick">
<h2>Quick Start</h2>
<pre><code>fes new hello</code></pre>
<p>This creates a new project under the name <code>hello</code>.</p>
<pre><code>fes run hello</code></pre>
<p>This runs your project <code>hello</code>, by default at <a href="localhost:3000" target="_blank">localhost:3000</a>.</p>
<h3>Extensions</h3>
<p>Let's add a paragraph to this simple site. Right now you have the following page:</p>
<pre><code>local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "vx-clutch")
site:h1("Hello, World!")
return site</code></pre>
<p>To add a simple paragraph modify like so:</p>
<pre><code>local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "vx-clutch")
site:h1("Hello, World!")
site:p("This is a paragraph")
return site</code></pre>
</section>
<section id="cli-reference">
<h2>Cli Reference</h2>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>new &lt;project&gt;</code></td>
<td>Create a new projet called &lt;project&gt;</td>
</tr>
<tr>
<td><code>doc</code></td>
<td>Open this documention page</td>
</tr>
<tr>
<td><code>run &lt;project&gt;</code></td>
<td>Run the projet called &lt;project&gt;</td>
</tr>
<tr>
<td><code>-help</code></td>
<td>Display help information.</td>
</tr>
<tr>
<td><code>-V1</code></td>
<td>Print extended version information, this is very helpful when it comes to bug reporting.</td>
</tr>
<tr>
<td><code>-no-color</code></td>
<td>Disable color output.</td>
</tr>
<tr>
<td><code>-p &lt;port&gt;</code></td>
<td>Set the server port.</td>
</tr>
<tr>
<td><code>-static</code></td>
<td>Render and save all pages. (this feature is yet to be implemented)</td>
</tr>
<tr>
<td><code>-version</code></td>
<td>Print the version.</td>
</tr>
</tbody>
</table>
</section>
<section id="reference">
<h2>Reference</h2>
All <code>std</code> functions have binding for the site and can be used like so: <code>site:h1("Hello, World!")</code>, where site is the site object.
<h3>Builtin</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fes()</code></td>
<td>Generate a site object</td>
</tr>
<tr>
<td><code>:custom()</code></td>
<td>Add a custom string to the site body</td>
</tr>
<tr>
<td><code>markdown_to_html(str: string)</code></td>
<td>Returns generated HTML from provided Markdown string.</td>
</tr>
</tbody>
</table>
<h3>Std</h3>
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>std.fes_version()</code></td>
<td>Get the current version of fes.</td>
</tr>
<tr>
<td><code>std.site_version()</code></td>
<td>Get the current version of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_name()</code></td>
<td>Get the current name of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_title()</code></td>
<td>Get the current name of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_authors()</code></td>
<td>Get a table of the authors of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.join</code></td>
<td>Get a table of the authors of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.a(link: string, str: string)</code></td>
<td>Returns an anchor tag.</td>
</tr>
<tr>
<td><code>std.ha(link: string, str: string)</code></td>
<td>Returns an anchor tag with sytiling to make it hidden.</td>
</tr>
<tr>
<td><code>std.external(link: string, str: string)</code></td>
<td>Returns an anchor tag that opens up in a new tab.</td>
</tr>
<tr>
<td><code>std.note(str: string)</code></td>
<td>Returns a note object.</td>
</tr>
<tr>
<td><code>std.muted(str: string)</code></td>
<td>Returns a muted object.</td>
</tr>
<tr>
<td><code>std.callout(str: string)</code></td>
<td>Returns a callout object.</td>
</tr>
<tr>
<td><code>std.h1(str: string)</code></td>
<td>Returns a header level 1 object.</td>
</tr>
<tr>
<td><code>std.h2(str: string)</code></td>
<td>Returns a header level 2 object.</td>
</tr>
<tr>
<td><code>std.h3(str: string)</code></td>
<td>Returns a header level 3 object.</td>
</tr>
<tr>
<td><code>std.h4(str: string)</code></td>
<td>Returns a header level 4 object.</td>
</tr>
<tr>
<td><code>std.h5(str: string)</code></td>
<td>Returns a header level 5 object.</td>
</tr>
<tr>
<td><code>std.h6(str: string)</code></td>
<td>Returns a header level 6 object.</td>
</tr>
<tr>
<td><code>std.p(str: string)</code></td>
<td>Returns a paragraph object.</td>
</tr>
<tr>
<td><code>std.pre(str: string)</code></td>
<td>Returns preformated text.</td>
</tr>
<tr>
<td><code>std.code(str: string)</code></td>
<td>Returns a codeblock.</td>
</tr>
<tr>
<td><code>std.ul(items: {...strings})</code></td>
<td>Generates an unordered list from a table of strings.</td>
</tr>
<tr>
<td><code>std.ol(items: {...strings})</code></td>
<td>Generates an ordered list from a table of strings.</td>
</tr>
<tr>
<td><code>std.tl(items: {...strings})</code></td>
<td>Generates a table from a table of strings.</td>
</tr>
<tr>
<td><code>std.blockquote(str: string)</code></td>
<td>Returns a blockquote.</td>
</tr>
<tr>
<td><code>std.hr()</code></td>
<td>Returns a horizonal rule.</td>
</tr>
<tr>
<td><code>std.img(src: string, alt: string)</code></td>
<td>Returns an image at location source with given alternative text.</td>
</tr>
<tr>
<td><code>std.strong(str: string)</code></td>
<td>Returns bolded text.</td>
</tr>
<tr>
<td><code>std.em(str: string)</code></td>
<td>Returns italicized text.</td>
</tr>
<tr>
<td><code>std.br()</code></td>
<td>Returns a break.</td>
</tr>
<tr>
<td><code>std.div(content: string, class: string)</code></td>
<td>Returns a div of class <code>class</code> with content of <code>content</code>.</td>
</tr>
<tr>
<td><code>std.spa(content: string, class: string)</code></td>
<td>Returns a span of class <code>class</code> with content of <code>content</code>.</td>
</tr>
<tr>
<td><code>std.escape(str: string)</code></td>
<td>Returns an html escaped string.</td>
</tr>
<tr>
<td><code>std.highlight(str: string)</code></td>
<td>Returns highlighted text.</td>
</tr>
<tr>
<td><code>std.banner(str: string)</code></td>
<td>Returns a banner that is attached to the top of the site.</td>
</tr>
<tr>
<td><code>std.center(str: string)</code></td>
<td>Returns centered text.</td>
</tr>
<tr>
<td><code>std.nav(link: string, str: string)</code></td>
<td>Returns a speical navigation link, used for in-site traversal.</td>
</tr>
<tr>
<td><code>std.rl(r: string, l: string)</code></td>
<td>Right and light alight content.</td>
</tr>
</tbody>
</table>
<h3>Symbol</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Symbol</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>symbol.copyright</code></td>
<td>&#169;</td> </tr>
<tr>
<td><code>Registered Trademark</code></td>
<td>&#174;</td>
</tr>
<tr>
<td><code>Trademark</code></td>
<td>&#8482;</td>
</tr>
</tbody>
</table>
<h3>Util</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>util.cc(tbl: {...strings})</code></td>
<td>Concatenate a table of strings into a single string.</td>
</tr>
<tr>
<td><code>util.copyright(link: string, holder: string)</code></td>
<td>Used when setting the website copyright holder.</td>
</tr>
</tbody>
</table>
<h3>Dkjson</h3>
This is a third party object and is best documented <a href="https://dkolf.de/dkjson-lua/documentation" target="_blank">here</a>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>dkjson.encode(obj: {...any})</code></td>
<td>Serilize a Lua table into JSON.</td>
</tr>
<tr>
<td><code>dkjson.decode(obj: {...any})</code></td>
<td>Deserilize JSON into a Lua table.</td>
</tr>
</tbody>
</table>
<h3>Bus</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>bus.url</code></td>
<td>The current url that was given.</td>
</tr>
<tr>
<td><code>bus.params</code></td>
<td>A table of url parameters if any. Ex: ?foo=bar*baz=foobar</td>
</tr>
</tbody>
</table>
<h3>Site</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>site.version</code></td>
<td>The version of the website found in the Fes.toml</td>
</tr>
<tr>
<td><code>site.name</code></td>
<td>The name of the website found in the Fes.toml</td>
</tr>
<tr>
<td><code>site.authors</code></td>
<td>A table of all authors defined in Fes.toml</td>
</tr>
</tbody>
</table>
<h3>App</h3>
Fes's <code>app</code> module is a special table
because it contains user defined functions. These
functions are defined in the <code>include/</code> For
example if you define a <code>include/hello.lua</code>
file with given contents: <pre><code>local hello = {}
hello.render(std) return std.h1("Hello, World!") end
return hello</pre></code> This can be called from another with,
<code>fes.app.hello.render(fes.std)</code>. Do with this
as you will.
<h3>Speical Directories</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>www/</code></td>
<td>The main website is
contained here, this is
where www/index.lua
lives.</td>
</tr>
<tr>
<td><code>static/</code></td>
<td>All static content should
be placed here and can
be accessed at
<code>/static/path-to-file</code>.</td>
</tr>
<tr>
<td><code>include/</code></td>
<td>Contains lua files that are
preloaded and globally
accessible.</td>
</tr>
<tr>
<td><code>archive/</code></td>
<td>Files here can be viewed in
a file browser like
format at
<code>/archive/path-to-dir</code>.</td>
</tr>
</tbody>
</table>
</section>
<footer>
<p>Last updated: 2025-12-27</p>
</footer>
</main>
</body>
</html>

816
lib/dkjson.lua Normal file
View File

@@ -0,0 +1,816 @@
-- Module options:
local always_use_lpeg = false
local register_global_module_table = false
local global_module_name = "json"
--[==[
David Kolf's JSON module for Lua 5.1 - 5.4
Version 2.8
For the documentation see the corresponding readme.txt or visit
<http://dkolf.de/dkjson-lua/>.
You can contact the author by sending an e-mail to 'david' at the
domain 'dkolf.de'.
Copyright (C) 2010-2024 David Heiko Kolf
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
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.
--]==]
-- global dependencies:
local pairs, type, tostring, tonumber, getmetatable, setmetatable =
pairs, type, tostring, tonumber, getmetatable, setmetatable
local error, require, pcall, select = error, require, pcall, select
local floor, huge = math.floor, math.huge
local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat =
string.rep, string.gsub, string.sub, string.byte, string.char, string.find, string.len, string.format
local strmatch = string.match
local concat = table.concat
local json = { version = "dkjson 2.8" }
local jsonlpeg = {}
if register_global_module_table then
if always_use_lpeg then
_G[global_module_name] = jsonlpeg
else
_G[global_module_name] = json
end
end
local _ENV = nil -- blocking globals in Lua 5.2 and later
pcall(function()
-- Enable access to blocked metatables.
-- Don't worry, this module doesn't change anything in them.
local debmeta = require("debug").getmetatable
if debmeta then
getmetatable = debmeta
end
end)
json.null = setmetatable({}, {
__tojson = function()
return "null"
end,
})
local function isarray(tbl)
local max, n, arraylen = 0, 0, 0
for k, v in pairs(tbl) do
if k == "n" and type(v) == "number" then
arraylen = v
if v > max then
max = v
end
else
if type(k) ~= "number" or k < 1 or floor(k) ~= k then
return false
end
if k > max then
max = k
end
n = n + 1
end
end
if max > 10 and max > arraylen and max > n * 2 then
return false -- don't create an array with too many holes
end
return true, max
end
local escapecodes = {
['"'] = '\\"',
["\\"] = "\\\\",
["\b"] = "\\b",
["\f"] = "\\f",
["\n"] = "\\n",
["\r"] = "\\r",
["\t"] = "\\t",
}
local function escapeutf8(uchar)
local value = escapecodes[uchar]
if value then
return value
end
local a, b, c, d = strbyte(uchar, 1, 4)
a, b, c, d = a or 0, b or 0, c or 0, d or 0
if a <= 0x7f then
value = a
elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then
value = (a - 0xc0) * 0x40 + b - 0x80
elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then
value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80
elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then
value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80
else
return ""
end
if value <= 0xffff then
return strformat("\\u%.4x", value)
elseif value <= 0x10ffff then
-- encode as UTF-16 surrogate pair
value = value - 0x10000
local highsur, lowsur = 0xD800 + floor(value / 0x400), 0xDC00 + (value % 0x400)
return strformat("\\u%.4x\\u%.4x", highsur, lowsur)
else
return ""
end
end
local function fsub(str, pattern, repl)
-- gsub always builds a new string in a buffer, even when no match
-- exists. First using find should be more efficient when most strings
-- don't contain the pattern.
if strfind(str, pattern) then
return gsub(str, pattern, repl)
else
return str
end
end
local function quotestring(value)
-- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js
value = fsub(value, '[%z\1-\31"\\\127]', escapeutf8)
if strfind(value, "[\194\216\220\225\226\239]") then
value = fsub(value, "\194[\128-\159\173]", escapeutf8)
value = fsub(value, "\216[\128-\132]", escapeutf8)
value = fsub(value, "\220\143", escapeutf8)
value = fsub(value, "\225\158[\180\181]", escapeutf8)
value = fsub(value, "\226\128[\140-\143\168-\175]", escapeutf8)
value = fsub(value, "\226\129[\160-\175]", escapeutf8)
value = fsub(value, "\239\187\191", escapeutf8)
value = fsub(value, "\239\191[\176-\191]", escapeutf8)
end
return '"' .. value .. '"'
end
json.quotestring = quotestring
local function replace(str, o, n)
local i, j = strfind(str, o, 1, true)
if i then
return strsub(str, 1, i - 1) .. n .. strsub(str, j + 1, -1)
else
return str
end
end
-- locale independent num2str and str2num functions
local decpoint, numfilter
local function updatedecpoint()
decpoint = strmatch(tostring(0.5), "([^05+])")
-- build a filter that can be used to remove group separators
numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+"
end
updatedecpoint()
local function num2str(num)
return replace(fsub(tostring(num), numfilter, ""), decpoint, ".")
end
local function str2num(str)
local num = tonumber(replace(str, ".", decpoint))
if not num then
updatedecpoint()
num = tonumber(replace(str, ".", decpoint))
end
return num
end
local function addnewline2(level, buffer, buflen)
buffer[buflen + 1] = "\n"
buffer[buflen + 2] = strrep(" ", level)
buflen = buflen + 2
return buflen
end
function json.addnewline(state)
if state.indent then
state.bufferlen = addnewline2(state.level or 0, state.buffer, state.bufferlen or #state.buffer)
end
end
local encode2 -- forward declaration
local function addpair(key, value, prev, indent, level, buffer, buflen, tables, globalorder, state)
local kt = type(key)
if kt ~= "string" and kt ~= "number" then
return nil, "type '" .. kt .. "' is not supported as a key by JSON."
end
if prev then
buflen = buflen + 1
buffer[buflen] = ","
end
if indent then
buflen = addnewline2(level, buffer, buflen)
end
-- When Lua is compiled with LUA_NOCVTN2S this will fail when
-- numbers are mixed into the keys of the table. JSON keys are always
-- strings, so this would be an implicit conversion too and the failure
-- is intentional.
buffer[buflen + 1] = quotestring(key)
buffer[buflen + 2] = ":"
return encode2(value, indent, level, buffer, buflen + 2, tables, globalorder, state)
end
local function appendcustom(res, buffer, state)
local buflen = state.bufferlen
if type(res) == "string" then
buflen = buflen + 1
buffer[buflen] = res
end
return buflen
end
local function exception(reason, value, state, buffer, buflen, defaultmessage)
defaultmessage = defaultmessage or reason
local handler = state.exception
if not handler then
return nil, defaultmessage
else
state.bufferlen = buflen
local ret, msg = handler(reason, value, state, defaultmessage)
if not ret then
return nil, msg or defaultmessage
end
return appendcustom(ret, buffer, state)
end
end
function json.encodeexception(reason, value, state, defaultmessage)
return quotestring("<" .. defaultmessage .. ">")
end
encode2 = function(value, indent, level, buffer, buflen, tables, globalorder, state)
local valtype = type(value)
local valmeta = getmetatable(value)
valmeta = type(valmeta) == "table" and valmeta -- only tables
local valtojson = valmeta and valmeta.__tojson
if valtojson then
if tables[value] then
return exception("reference cycle", value, state, buffer, buflen)
end
tables[value] = true
state.bufferlen = buflen
local ret, msg = valtojson(value, state)
if not ret then
return exception("custom encoder failed", value, state, buffer, buflen, msg)
end
tables[value] = nil
buflen = appendcustom(ret, buffer, state)
elseif value == nil then
buflen = buflen + 1
buffer[buflen] = "null"
elseif valtype == "number" then
local s
if value ~= value or value >= huge or -value >= huge then
-- This is the behaviour of the original JSON implementation.
s = "null"
else
s = num2str(value)
end
buflen = buflen + 1
buffer[buflen] = s
elseif valtype == "boolean" then
buflen = buflen + 1
buffer[buflen] = value and "true" or "false"
elseif valtype == "string" then
buflen = buflen + 1
buffer[buflen] = quotestring(value)
elseif valtype == "table" then
if tables[value] then
return exception("reference cycle", value, state, buffer, buflen)
end
tables[value] = true
level = level + 1
local isa, n = isarray(value)
if n == 0 and valmeta and valmeta.__jsontype == "object" then
isa = false
end
local msg
if isa then -- JSON array
buflen = buflen + 1
buffer[buflen] = "["
for i = 1, n do
buflen, msg = encode2(value[i], indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then
return nil, msg
end
if i < n then
buflen = buflen + 1
buffer[buflen] = ","
end
end
buflen = buflen + 1
buffer[buflen] = "]"
else -- JSON object
local prev = false
buflen = buflen + 1
buffer[buflen] = "{"
local order = valmeta and valmeta.__jsonorder or globalorder
if order then
local used = {}
n = #order
for i = 1, n do
local k = order[i]
local v = value[k]
if v ~= nil then
used[k] = true
buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element
end
end
for k, v in pairs(value) do
if not used[k] then
buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element
end
end
else -- unordered
for k, v in pairs(value) do
buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element
end
end
if indent then
buflen = addnewline2(level - 1, buffer, buflen)
end
buflen = buflen + 1
buffer[buflen] = "}"
end
tables[value] = nil
else
return exception(
"unsupported type",
value,
state,
buffer,
buflen,
"type '" .. valtype .. "' is not supported by JSON."
)
end
return buflen
end
function json.encode(value, state)
state = state or {}
local oldbuffer = state.buffer
local buffer = oldbuffer or {}
state.buffer = buffer
updatedecpoint()
local ret, msg = encode2(
value,
state.indent,
state.level or 0,
buffer,
state.bufferlen or 0,
state.tables or {},
state.keyorder,
state
)
if not ret then
error(msg, 2)
elseif oldbuffer == buffer then
state.bufferlen = ret
return true
else
state.bufferlen = nil
state.buffer = nil
return concat(buffer)
end
end
local function loc(str, where)
local line, pos, linepos = 1, 1, 0
while true do
pos = strfind(str, "\n", pos, true)
if pos and pos < where then
line = line + 1
linepos = pos
pos = pos + 1
else
break
end
end
return strformat("line %d, column %d", line, where - linepos)
end
local function unterminated(str, what, where)
return nil, strlen(str) + 1, "unterminated " .. what .. " at " .. loc(str, where)
end
local function scanwhite(str, pos)
while true do
pos = strfind(str, "%S", pos)
if not pos then
return nil
end
local sub2 = strsub(str, pos, pos + 1)
if sub2 == "\239\187" and strsub(str, pos + 2, pos + 2) == "\191" then
-- UTF-8 Byte Order Mark
pos = pos + 3
elseif sub2 == "//" then
pos = strfind(str, "[\n\r]", pos + 2)
if not pos then
return nil
end
elseif sub2 == "/*" then
pos = strfind(str, "*/", pos + 2)
if not pos then
return nil
end
pos = pos + 2
else
return pos
end
end
end
local escapechars = {
['"'] = '"',
["\\"] = "\\",
["/"] = "/",
["b"] = "\b",
["f"] = "\f",
["n"] = "\n",
["r"] = "\r",
["t"] = "\t",
}
local function unichar(value)
if value < 0 then
return nil
elseif value <= 0x007f then
return strchar(value)
elseif value <= 0x07ff then
return strchar(0xc0 + floor(value / 0x40), 0x80 + (floor(value) % 0x40))
elseif value <= 0xffff then
return strchar(0xe0 + floor(value / 0x1000), 0x80 + (floor(value / 0x40) % 0x40), 0x80 + (floor(value) % 0x40))
elseif value <= 0x10ffff then
return strchar(
0xf0 + floor(value / 0x40000),
0x80 + (floor(value / 0x1000) % 0x40),
0x80 + (floor(value / 0x40) % 0x40),
0x80 + (floor(value) % 0x40)
)
else
return nil
end
end
local function scanstring(str, pos)
local lastpos = pos + 1
local buffer, n = {}, 0
while true do
local nextpos = strfind(str, '["\\]', lastpos)
if not nextpos then
return unterminated(str, "string", pos)
end
if nextpos > lastpos then
n = n + 1
buffer[n] = strsub(str, lastpos, nextpos - 1)
end
if strsub(str, nextpos, nextpos) == '"' then
lastpos = nextpos + 1
break
else
local escchar = strsub(str, nextpos + 1, nextpos + 1)
local value
if escchar == "u" then
value = tonumber(strsub(str, nextpos + 2, nextpos + 5), 16)
if value then
local value2
if 0xD800 <= value and value <= 0xDBff then
-- we have the high surrogate of UTF-16. Check if there is a
-- low surrogate escaped nearby to combine them.
if strsub(str, nextpos + 6, nextpos + 7) == "\\u" then
value2 = tonumber(strsub(str, nextpos + 8, nextpos + 11), 16)
if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then
value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000
else
value2 = nil -- in case it was out of range for a low surrogate
end
end
end
value = value and unichar(value)
if value then
if value2 then
lastpos = nextpos + 12
else
lastpos = nextpos + 6
end
end
end
end
if not value then
value = escapechars[escchar] or escchar
lastpos = nextpos + 2
end
n = n + 1
buffer[n] = value
end
end
if n == 1 then
return buffer[1], lastpos
elseif n > 1 then
return concat(buffer), lastpos
else
return "", lastpos
end
end
local scanvalue -- forward declaration
local function scantable(what, closechar, str, startpos, nullval, objectmeta, arraymeta)
local tbl, n = {}, 0
local pos = startpos + 1
if what == "object" then
setmetatable(tbl, objectmeta)
else
setmetatable(tbl, arraymeta)
end
while true do
pos = scanwhite(str, pos)
if not pos then
return unterminated(str, what, startpos)
end
local char = strsub(str, pos, pos)
if char == closechar then
return tbl, pos + 1
end
local val1, err
val1, pos, err = scanvalue(str, pos, nullval, objectmeta, arraymeta)
if err then
return nil, pos, err
end
pos = scanwhite(str, pos)
if not pos then
return unterminated(str, what, startpos)
end
char = strsub(str, pos, pos)
if char == ":" then
if val1 == nil then
return nil, pos, "cannot use nil as table index (at " .. loc(str, pos) .. ")"
end
pos = scanwhite(str, pos + 1)
if not pos then
return unterminated(str, what, startpos)
end
local val2
val2, pos, err = scanvalue(str, pos, nullval, objectmeta, arraymeta)
if err then
return nil, pos, err
end
tbl[val1] = val2
pos = scanwhite(str, pos)
if not pos then
return unterminated(str, what, startpos)
end
char = strsub(str, pos, pos)
else
n = n + 1
tbl[n] = val1
end
if char == "," then
pos = pos + 1
end
end
end
scanvalue = function(str, pos, nullval, objectmeta, arraymeta)
pos = pos or 1
pos = scanwhite(str, pos)
if not pos then
return nil, strlen(str) + 1, "no valid JSON value (reached the end)"
end
local char = strsub(str, pos, pos)
if char == "{" then
return scantable("object", "}", str, pos, nullval, objectmeta, arraymeta)
elseif char == "[" then
return scantable("array", "]", str, pos, nullval, objectmeta, arraymeta)
elseif char == '"' then
return scanstring(str, pos)
else
local pstart, pend = strfind(str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos)
if pstart then
local number = str2num(strsub(str, pstart, pend))
if number then
return number, pend + 1
end
end
pstart, pend = strfind(str, "^%a%w*", pos)
if pstart then
local name = strsub(str, pstart, pend)
if name == "true" then
return true, pend + 1
elseif name == "false" then
return false, pend + 1
elseif name == "null" then
return nullval, pend + 1
end
end
return nil, pos, "no valid JSON value at " .. loc(str, pos)
end
end
local function optionalmetatables(...)
if select("#", ...) > 0 then
return ...
else
return { __jsontype = "object" }, { __jsontype = "array" }
end
end
function json.decode(str, pos, nullval, ...)
local objectmeta, arraymeta = optionalmetatables(...)
return scanvalue(str, pos, nullval, objectmeta, arraymeta)
end
function json.use_lpeg()
local g = require("lpeg")
if type(g.version) == "function" and g.version() == "0.11" then
error("due to a bug in LPeg 0.11, it cannot be used for JSON matching")
end
local pegmatch = g.match
local P, S, R = g.P, g.S, g.R
local function ErrorCall(str, pos, msg, state)
if not state.msg then
state.msg = msg .. " at " .. loc(str, pos)
state.pos = pos
end
return false
end
local function Err(msg)
return g.Cmt(g.Cc(msg) * g.Carg(2), ErrorCall)
end
local function ErrorUnterminatedCall(str, pos, what, state)
return ErrorCall(str, pos - 1, "unterminated " .. what, state)
end
local SingleLineComment = P("//") * (1 - S("\n\r")) ^ 0
local MultiLineComment = P("/*") * (1 - P("*/")) ^ 0 * P("*/")
local Space = (S(" \n\r\t") + P("\239\187\191") + SingleLineComment + MultiLineComment) ^ 0
local function ErrUnterminated(what)
return g.Cmt(g.Cc(what) * g.Carg(2), ErrorUnterminatedCall)
end
local PlainChar = 1 - S('"\\\n\r')
local EscapeSequence = (P("\\") * g.C(S('"\\/bfnrt') + Err("unsupported escape sequence"))) / escapechars
local HexDigit = R("09", "af", "AF")
local function UTF16Surrogate(match, pos, high, low)
high, low = tonumber(high, 16), tonumber(low, 16)
if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then
return true, unichar((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000)
else
return false
end
end
local function UTF16BMP(hex)
return unichar(tonumber(hex, 16))
end
local U16Sequence = (P("\\u") * g.C(HexDigit * HexDigit * HexDigit * HexDigit))
local UnicodeEscape = g.Cmt(U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence / UTF16BMP
local Char = UnicodeEscape + EscapeSequence + PlainChar
local String = P('"') * (g.Cs(Char ^ 0) * P('"') + ErrUnterminated("string"))
local Integer = P("-") ^ -1 * (P("0") + (R("19") * R("09") ^ 0))
local Fractal = P(".") * R("09") ^ 0
local Exponent = (S("eE")) * (S("+-")) ^ -1 * R("09") ^ 1
local Number = (Integer * Fractal ^ -1 * Exponent ^ -1) / str2num
local Constant = P("true") * g.Cc(true) + P("false") * g.Cc(false) + P("null") * g.Carg(1)
local SimpleValue = Number + String + Constant
local ArrayContent, ObjectContent
-- The functions parsearray and parseobject parse only a single value/pair
-- at a time and store them directly to avoid hitting the LPeg limits.
local function parsearray(str, pos, nullval, state)
local obj, cont
local start = pos
local npos
local t, nt = {}, 0
repeat
obj, cont, npos = pegmatch(ArrayContent, str, pos, nullval, state)
if cont == "end" then
return ErrorUnterminatedCall(str, start, "array", state)
end
pos = npos
if cont == "cont" or cont == "last" then
nt = nt + 1
t[nt] = obj
end
until cont ~= "cont"
return pos, setmetatable(t, state.arraymeta)
end
local function parseobject(str, pos, nullval, state)
local obj, key, cont
local start = pos
local npos
local t = {}
repeat
key, obj, cont, npos = pegmatch(ObjectContent, str, pos, nullval, state)
if cont == "end" then
return ErrorUnterminatedCall(str, start, "object", state)
end
pos = npos
if cont == "cont" or cont == "last" then
t[key] = obj
end
until cont ~= "cont"
return pos, setmetatable(t, state.objectmeta)
end
local Array = P("[") * g.Cmt(g.Carg(1) * g.Carg(2), parsearray)
local Object = P("{") * g.Cmt(g.Carg(1) * g.Carg(2), parseobject)
local Value = Space * (Array + Object + SimpleValue)
local ExpectedValue = Value + Space * Err("value expected")
local ExpectedKey = String + Err("key expected")
local End = P(-1) * g.Cc("end")
local ErrInvalid = Err("invalid JSON")
ArrayContent = (
Value * Space * (P(",") * g.Cc("cont") + P("]") * g.Cc("last") + End + ErrInvalid)
+ g.Cc(nil) * (P("]") * g.Cc("empty") + End + ErrInvalid)
) * g.Cp()
local Pair = g.Cg(Space * ExpectedKey * Space * (P(":") + Err("colon expected")) * ExpectedValue)
ObjectContent = (
g.Cc(nil) * g.Cc(nil) * P("}") * g.Cc("empty")
+ End
+ (Pair * Space * (P(",") * g.Cc("cont") + P("}") * g.Cc("last") + End + ErrInvalid) + ErrInvalid)
) * g.Cp()
local DecodeValue = ExpectedValue * g.Cp()
jsonlpeg.version = json.version
jsonlpeg.encode = json.encode
jsonlpeg.null = json.null
jsonlpeg.quotestring = json.quotestring
jsonlpeg.addnewline = json.addnewline
jsonlpeg.encodeexception = json.encodeexception
jsonlpeg.using_lpeg = true
function jsonlpeg.decode(str, pos, nullval, ...)
local state = {}
state.objectmeta, state.arraymeta = optionalmetatables(...)
local obj, retpos = pegmatch(DecodeValue, str, pos, nullval, state)
if state.msg then
return nil, state.pos, state.msg
else
return obj, retpos
end
end
-- cache result of this function:
json.use_lpeg = function()
return jsonlpeg
end
jsonlpeg.use_lpeg = json.use_lpeg
return jsonlpeg
end
if always_use_lpeg then
return json.use_lpeg()
end
return json

375
lib/fes.lua Normal file
View File

@@ -0,0 +1,375 @@
local std = require("lib.std")
local symbol = require("lib.symbol")
local M = {}
M.__index = M
function M.fes(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
if site_config.favicon then
site_config.favicon = '<link rel="icon" type="image/x-icon" href="' .. site_config.favicon .. '">'
end
local self = {
version = site_config.version,
title = site_config.title,
copyright = site_config.copyright,
favicon = site_config.favicon,
header = header or [[
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
{{FAVICON}}
<title>{{TITLE}}</title>
<style>
:root {
--bg: #f5f5f5;
--text: #111827;
--muted: #6b7280;
--link: #1a0dab;
--accent: #68a6ff;
--highlight: #004d99;
--note-bg: #ffffff;
--panel-bg: #ffffff;
--border: rgba(0,0,0,.1);
--table-head: #f3f4f6;
--code-color: #004d99;
--blockquote-border: #1a73e8;
--banner-bg: #ffffff;
--footer-bg: #ffffff;
--shadow: rgba(0,0,0,.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1113;
--text: #e6eef3;
--muted: #9aa6b1;
--link: #68a6ff;
--accent: #68a6ff;
--highlight: #cde7ff;
--note-bg: #1a1c20;
--panel-bg: #1a1c20;
--border: rgba(255,255,255,.06);
--table-head: #1a1c20;
--code-color: #cde7ff;
--blockquote-border: #68a6ff;
--banner-bg: #1a1c20;
--footer-bg: #1a1c20;
--shadow: rgba(0,0,0,.4);
}
}
html, body {
min-height: 100%;
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { padding: 36px; }
.container { max-width: 830px; margin: 0 auto; }
.container > *:not(.banner) { margin: 28px 0; }
h1, h2, h3, h4, h5, h6 { font-weight: 600; margin: 0 0 12px 0; }
h1 { font-size: 40px; margin-bottom: 20px; font-weight: 700; }
h2 { font-size: 32px; margin: 26px 0 14px; }
h3 { font-size: 26px; margin: 22px 0 12px; }
h4 { font-size: 20px; margin: 18px 0 10px; }
h5 { font-size: 16px; margin: 16px 0 8px; }
h6 { font-size: 14px; margin: 14px 0 6px; color: var(--muted); }
p { margin: 14px 0; }
a { color: var(--link); text-decoration: none; transition: color .15s ease, text-decoration-color .15s ease; }
.hidden { color: var(--text); text-decoration: none; }
a:hover { text-decoration: underline; }
summary { cursor: pointer; }
details {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 14px 16px;
margin: 16px 0;
}
details summary {
list-style: none;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
}
details summary::-webkit-details-marker { display: none; }
details summary::before {
content: "▸";
margin-right: 8px;
transition: transform .15s ease;
color: var(--accent);
}
details[open] summary::before { transform: rotate(90deg); }
summary::after { content: "Expand"; margin-left: auto; font-size: 13px; color: var(--muted); }
details[open] summary::after { content: "Collapse"; }
details > *:not(summary) { margin-top: 12px; }
.note, pre, code {
background: var(--note-bg);
border: 1px solid var(--border);
}
.note {
padding: 20px;
border-radius: 4px;
background: var(--note-bg);
border: 1px solid var(--border);
margin: 28px 0;
color: var(--text);
}
.note strong { color: var(--text); }
.muted { color: var(--muted); }
.lead { font-size: 15px; margin-top: 8px; }
.callout { display: block; margin: 12px 0; }
.small { font-size: 13px; color: var(--muted); margin-top: 6px; }
.highlight { font-weight: 700; color: var(--highlight); }
ul, ol { margin: 14px 0; padding-left: 26px; }
.tl {
display: grid;
grid-template-columns: repeat(auto-fill, 200px);
gap: 15px;
list-style-type: none;
padding: 0;
margin: 0;
justify-content: start;
}
ul.tl li { padding: 10px; width: fit-content; }
li { margin: 6px 0; }
code {
padding: 3px 7px;
border-radius: 3px;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: .9em;
color: var(--code-color);
}
pre {
padding: 20px;
border-radius: 4px;
margin: 14px 0;
overflow-x: auto;
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
}
pre code { background: none; border: none; padding: 0; font-size: inherit; }
blockquote {
border-left: 3px solid var(--blockquote-border);
padding-left: 18px;
margin: 14px 0;
color: var(--text);
font-style: italic;
}
hr { border: 0; border-top: 1px solid rgba(0,0,0,.08); margin: 26px 0; }
@media (prefers-color-scheme: dark) {
hr { border-top-color: rgba(255,255,255,.1); }
}
img { max-width: 100%; height: auto; border-radius: 4px; margin: 14px 0; }
table { width: 100%; border-collapse: collapse; margin: 14px 0; }
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--table-head);
font-weight: 600;
color: var(--text);
}
tr:hover { background: rgba(0,0,0,0.02); }
@media (prefers-color-scheme: dark) {
tr:hover { background: rgba(255,255,255,0.02); }
}
.divider { margin: 26px 0; height: 1px; background: rgba(0,0,0,.08); }
@media (prefers-color-scheme: dark) {
.divider { background: rgba(255,255,255,.1); }
}
.section { margin-top: 36px; }
.links { margin: 12px 0; }
.links a { display: inline-block; margin: 0 14px 6px 0; color: var(--link); }
strong, b { font-weight: 600; color: var(--text); }
em, i { font-style: italic; }
.center { display: flex; justify-content: center; align-items: center; }
.banner {
width: 100%;
box-sizing: border-box;
text-align: center;
background: var(--banner-bg);
padding: 20px;
border: 1px solid var(--border);
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
color: var(--text);
margin: -36px 0 28px 0;
box-shadow: 0 0.2em 0.6em var(--shadow);
}
.nav { margin-left: auto; margin-right: auto; }
.nav a { color: var(--highlight); }
.footer {
background: var(--footer-bg);
padding: 20px 0;
border-top: 1px solid rgba(0,0,0,.08);
font-size: 14px;
color: var(--muted);
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 28px !important;
margin-bottom: 0;
}
.left { text-align: left; float: left; }
.right { text-align: right; float: right; }
</style>
</head>
<body>
<div class="container">
]],
footer = footer or [[
<footer class="footer">
<a href="https://git.vxserver.dev/fSD/fes" target="_blank">Fes Powered</a>
<a href="https://www.lua.org/" target="_blank">Lua Powered</a>
<a href="https://git.vxserver.dev/fSD/fes/src/branch/master/COPYING" target="_blank">ISC Licensed</a>
<p>{{COPYRIGHT}}</p>
</footer>
</div>
</body>
</html>
]],
parts = {},
}
return setmetatable(self, M)
end
function M:g(str)
table.insert(self.parts, str)
return self
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
if type(func) == "function" then
M[name] = function(self, ...)
local result = func(...)
table.insert(self.parts, result)
return self
end
end
end
function M:build()
local header = self.header
header = header:gsub("{{TITLE}}", self.title or "Document")
local favicon_html = self.favicon and ('<link rel="icon" type="image/x-icon" href="' .. self.favicon .. '">')
header = header:gsub(
"{{FAVICON}}",
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>">]]
)
local footer = self.footer:gsub("{{COPYRIGHT}}",
self.copyright or symbol.legal.copyright .. "The Copyright Holder")
return header .. table.concat(self.parts, "\n") .. footer
end
M.__tostring = function(self)
return self:build()
end
return M

195
lib/std.lua Normal file
View File

@@ -0,0 +1,195 @@
local M = {}
function M.element(tag, attrs, content)
local out = { "<", tag }
if attrs then
for k, v in pairs(attrs) do
if v ~= false and v ~= nil then
if v == true then
out[#out + 1] = " " .. k
else
out[#out + 1] = " " .. k .. "=\"" .. tostring(v) .. "\""
end
end
end
end
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
function M.a(link, str)
link = link or "https://example.com"
str = str or link
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
function M.ha(link, str)
link = link or "https://example.com"
str = str or link
return M.element("a", { href = link, class = "hidden" }, str)
end
function M.external(link, str)
return M.element("a", { href = link, target = "_blank" }, str)
end
function M.note(str)
return M.element("div", { class = "note" }, str)
end
function M.muted(str)
return M.element("div", { class = "muted" }, str)
end
function M.callout(str)
return M.element("div", { class = "callout" }, str)
end
function M.h1(str)
return M.element("h1", nil, str or "")
end
function M.h2(str)
return M.element("h2", nil, str or "")
end
function M.h3(str)
return M.element("h3", nil, str or "")
end
function M.h4(str)
return M.element("h4", nil, str or "")
end
function M.h5(str)
return M.element("h5", nil, str or "")
end
function M.h6(str)
return M.element("h6", nil, str or "")
end
function M.p(str)
return M.element("p", nil, str or "")
end
function M.pre(str)
return M.element("pre", nil, str or "")
end
function M.code(str)
return M.element("pre", nil, M.element("code", nil, str or ""))
end
function M.ul(items)
items = items or {}
local out = {}
for _, item in ipairs(items) do
out[#out + 1] = M.element("li", nil, item)
end
return M.element("ul", nil, table.concat(out))
end
function M.ol(items)
items = items or {}
local out = {}
for _, item in ipairs(items) do
out[#out + 1] = M.element("li", nil, item)
end
return M.element("ol", nil, table.concat(out))
end
function M.tl(items)
items = items or {}
local out = {}
for _, item in ipairs(items) do
out[#out + 1] = M.element("li", nil, item)
end
return M.element("ul", { class = "tl" }, table.concat(out))
end
function M.blockquote(str)
return M.element("blockquote", nil, str or "")
end
function M.hr()
return M.element("hr")
end
function M.img(src, alt)
return M.element("img", { src = src or "", alt = alt or "" })
end
function M.strong(str)
return M.element("strong", nil, str or "")
end
function M.em(str)
return M.element("em", nil, str or "")
end
function M.br()
return M.element("br")
end
function M.div(content, class)
return M.element("div", class and { class = class } or nil, content or "")
end
function M.span(content, class)
return M.element("span", class and { class = class } or nil, content or "")
end
function M.escape(str)
str = tostring(str or "")
str = str:gsub("&", "&amp;")
str = str:gsub("<", "&lt;")
str = str:gsub(">", "&gt;")
str = str:gsub('"', "&quot;")
str = str:gsub("'", "&#39;")
return str
end
function M.highlight(str)
return M.element("span", { class = "highlight" }, str or "")
end
function M.banner(str)
return M.element("div", { class = "banner" }, str or "")
end
function M.center(str)
return M.element("div", { class = "center" }, str or "")
end
function M.nav(link, str)
link = link or "example.com"
str = str or link
return M.element("a", { href = link, class = "nav" }, str)
end
function M.rl(r, l)
return
M.element("span", { class = "left" }, r or "") ..
M.element("span", { class = "right" }, l or "")
end
return M

75
lib/symbol.lua Normal file
View File

@@ -0,0 +1,75 @@
local M = {}
local function get(s)
return "&" .. (s or "") .. ";"
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

35
lib/util.lua Normal file
View File

@@ -0,0 +1,35 @@
local std = require("lib.std")
local symbol = require("lib.symbol")
local M = {}
function M.cc(tbl, sep)
return table.concat(tbl, sep or "")
end
function M.year(y)
return y or os.date("%Y")
end
function M.copyright(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
return M

68
main.go
View File

@@ -2,40 +2,64 @@ package main
import (
"embed"
"errors"
"flag"
"fmt"
"os"
"runtime"
"github.com/fatih/color"
"fes/src/config"
"fes/src/doc"
"fes/src/new"
"fes/src/server"
"fes/modules/config"
"fes/modules/doc"
"fes/modules/new"
"fes/modules/server"
"fes/modules/ui"
"fes/modules/version"
)
//go:embed core/*
var core embed.FS
//go:embed lib/*
var lib embed.FS
//go:embed index.html
var documentation string
func init() {
config.Port = flag.Int("p", 3000, "Set the server port")
config.Color = flag.Bool("no-color", false, "Disable color output")
config.Core = core
config.Static = flag.Bool("static", false, "Render and save all pages")
config.Docker = flag.Bool("docker", false, "Create a docker project")
config.Lib = lib
config.Doc = documentation
config.Verbose = flag.Bool("verbose", false, "Enable verbose logging")
}
func main() {
var m runtime.MemStats
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options] <command> <project_dir>\n", os.Args[0])
fmt.Println("Commands:")
fmt.Println(" new <project_dir> Create a new project")
fmt.Println(" doc Open documentation")
fmt.Println(" run <project_dir> Start the server")
fmt.Println("Options:")
fmt.Fprintln(flag.CommandLine.Output(), "Commands:")
fmt.Fprintln(flag.CommandLine.Output(), " new <project_dir> Create a new project")
fmt.Fprintln(flag.CommandLine.Output(), " doc Open documentation")
fmt.Fprintln(flag.CommandLine.Output(), " run <project_dir> Start the server")
fmt.Fprintln(flag.CommandLine.Output(), "Options:")
flag.PrintDefaults()
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")
showFullVersion := flag.Bool("V1", false, "Show extended version information and exit")
flag.Parse()
if *showVersion {
version.Version()
}
if *showFullVersion {
version.FullVersion()
}
if *config.Color {
color.NoColor = true
}
@@ -69,16 +93,16 @@ func main() {
os.Exit(1)
}
case "run":
if err := server.Start(dir); err != nil {
if errors.Is(err, os.ErrNotExist) {
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)
}
if *config.Port == 3000 {
ui.WARNING("Using default port, this may lead to conflicts with other services")
}
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:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
flag.Usage()

View File

@@ -1,15 +1,24 @@
package config
import "embed"
import (
"embed"
"errors"
)
var Core embed.FS
var Lib embed.FS
var Doc string
var Port *int
var Color *bool
var Static *bool
var Docker *bool
var Verbose *bool
type MyConfig struct {
type AppConfig struct {
App struct {
Name string `toml:"name"`
Version string `toml:"version"`
Authors []string `toml:"authors"`
} `toml:"app"`
}
var ErrRouteMiss = errors.New("not found")

23
modules/doc/doc.go Normal file
View File

@@ -0,0 +1,23 @@
package doc
import (
"fes/modules/config"
"fmt"
"os"
"path/filepath"
"github.com/pkg/browser"
)
/* open documentation in browser */
func Open() error {
fmt.Println("Opening documentation in browser")
tmpFile := filepath.Join(os.TempDir(), "doc.html")
if err := os.WriteFile(tmpFile, []byte(config.Doc), 0644); err != nil {
return err
}
return browser.OpenFile(tmpFile)
}

130
modules/new/new.go Normal file
View File

@@ -0,0 +1,130 @@
package new
import (
"fes/modules/config"
"fes/modules/ui"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
/* try to get git user, if not system user */
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 "unknown"
}
/* helper function for writing files */
func write(path string, format string, args ...any) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
panic(err)
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer f.Close()
_, err = fmt.Fprintf(f, format, args...)
return err
}
/* creates a hello world project */
func Project(dir string) error {
if err := os.Mkdir(dir, 0755); err != nil {
return err
}
if err := os.Chdir(dir); err != nil {
return err
}
if *config.Docker {
write("docker-compose.yml", `services:
%s:
image: git.vxserver.dev/fsd/fes:latest
ports:
- "3000:3000"
volumes:
- ./app:/app`, dir)
if err := os.Mkdir("app", 0755); err != nil {
return err
}
if err := os.Chdir("app"); err != nil {
return err
}
}
name := getName()
write("www/index.lua", `local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "%s")
site:h1("Hello, World!")
return site`, name)
write("Fes.toml", `[app]
name = "%s"
version = "0.0.1"
authors = ["%s"]`, dir, name)
write("README.md", strings.ReplaceAll(`# %s
$$$$$$
fes new %s
$$$$$$
> **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).`, "$$", "`"), dir, dir)
ui.Hint("you can run this with `fes run %s`", dir)
fmt.Println("Created new Fes project at", func() string {
if cwd, err := os.Getwd(); err != nil {
return dir
} else {
return cwd
}
}())
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
}

81
modules/server/server.go Normal file
View File

@@ -0,0 +1,81 @@
package server
import (
"fes/modules/config"
"fes/modules/ui"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
var routes map[string]string
func Start(dir string) {
if err := os.Chdir(dir); err != nil {
ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err)
}
ui.Log("running root=%s, port=%d.", filepath.Clean(dir), *config.Port)
routes := loadDirs()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
route, ok := routes[r.URL.Path]
var err error = nil
/* defer won't update paramaters unless we do this. */
defer func() {
ui.Path(route, err)
}()
if !ok {
err = config.ErrRouteMiss
route = r.URL.Path
if strings.HasPrefix(route, "/archive") {
err = readArchive(w, route)
} else {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>fes</center>
</body>
</html>`))
}
return
}
params := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
var data []byte
if strings.HasSuffix(route, ".lua") {
data, err = render(route, reqData{path: r.URL.Path, params: params})
} else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data)))
data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
} else {
data, err = os.ReadFile(route)
}
if err != nil {
http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError)
}
w.Write(data)
})
ui.Log("Server initialized")
log.Fatal(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil))
}

31
modules/server/util.go Normal file
View File

@@ -0,0 +1,31 @@
package server
import (
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
func markdownToHTML(mdText string) string {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse([]byte(mdText))
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
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
}

117
modules/ui/ui.go Normal file
View File

@@ -0,0 +1,117 @@
package ui
import (
"errors"
"fmt"
"strings"
"time"
"fes/modules/config"
"github.com/fatih/color"
)
const (
hintColor = 0xbda02a
)
func formatTimestamp() string {
return time.Now().Format("02 Jan 2006 15:04")
}
func logMessage(prefix string, msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
if prefix == "" {
fmt.Printf("%s * %s\n", formatTimestamp(), formatted)
} else {
fmt.Printf("%s * %s: %s\n", formatTimestamp(), prefix, formatted)
}
}
// Generic log
func Log(msg string, args ...any) {
logMessage("", msg, args...)
}
// OK message (green)
func OK(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.Green("%s * %s\n", formatTimestamp(), formatted)
}
// Warning message (magenta)
func WARN(msg string, args ...any) {
formatted := fmt.Sprintf(msg, args...)
color.Magenta("%s # %s\n", formatTimestamp(), formatted)
}
// Warning message (magenta)
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) {
r = (hex >> 16) & 0xFF
g = (hex >> 8) & 0xFF
b = hex & 0xFF
return
}(hintColor)).Printf("hint: %s\n", formatted)
}
// Path logging: prints route and status
func Path(path string, err error) {
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)
}
}
// System warning with prefix
func Warning(msg string, err error) error {
WARN("%s: %v", msg, err)
return err
}
// System error with prefix
func Error(msg string, err error) error {
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

@@ -0,0 +1,26 @@
package version
import (
"fmt"
"os"
)
var gitCommit string = "devel"
const PROGRAM_NAME string = "fes"
const PROGRAM_NAME_LONG string = "fes/fSD"
const VERSION string = "1.1.0"
func Version() {
fmt.Printf("%s version %s\n", PROGRAM_NAME_LONG, VERSION)
os.Exit(0)
}
func FullVersion() {
fmt.Printf("%s+%s\n", VERSION, gitCommit)
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

@@ -1,25 +0,0 @@
package doc
import (
"fmt"
"os"
"path/filepath"
"github.com/pkg/browser"
)
func Open() error {
fmt.Println("Opening documentation in browser")
tmpFile := filepath.Join(os.TempDir(), "doc.html")
content := `<html><body><pre>
This feature is not implemented yet. It will be once the doc site
is up and running, for now read through the core/ files and examples.
</pre></body></html>`
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return err
}
return browser.OpenFile(tmpFile)
}

View File

@@ -1,68 +0,0 @@
package new
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
)
/* try to get git user, if not system user */
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 "unknown"
}
/* helper function for writing files */
func write(path string, format string, args ...interface{}) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
panic(err)
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(err)
}
defer f.Close()
_, err = fmt.Fprintf(f, format, args...)
return err
}
/* creates a hello world project */
func Project(dir string) error {
if err := os.Mkdir(dir, 0755); err != nil {
return err
}
if err := os.Chdir(dir); err != nil {
return err
}
name := getName()
write("www/index.lua", `local fes = require("fes")
local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "%s")
site:h1("Hello, World!")
return site`, name)
write("Fes.toml", `[app]
name = "%s"
version = "0.0.1"
authors = ["%s"]`, dir, name)
return nil
}

View File

@@ -1,471 +0,0 @@
package server
import (
"fmt"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"fes/src/config"
"github.com/fatih/color"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/pelletier/go-toml/v2"
lua "github.com/yuin/gopher-lua"
"html/template"
)
type reqData struct {
path string
params map[string]string
}
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
}
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 {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse([]byte(mdText))
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return string(markdown.Render(doc, renderer))
}
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
}
func loadLua(luaDir string, entry string, cfg *config.MyConfig, requestData reqData) (string, error) {
L := lua.NewState()
defer L.Close()
coreFiles, err := fs.ReadDir(config.Core, "core")
if err == nil {
for _, de := range coreFiles {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
continue
}
path := filepath.Join("core", de.Name())
fileData, err := config.Core.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.Core.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("core.std", "core/std.lua")
preloadLuaModule("core.symbol", "core/symbol.lua")
preloadLuaModule("core.util", "core/util.lua")
L.PreloadModule("fes", func(L *lua.LState) int {
mod := L.NewTable()
coreModules := []string{}
if ents, err := fs.ReadDir(config.Core, "core"); err == nil {
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
}
coreModules = append(coreModules, strings.TrimSuffix(e.Name(), ".lua"))
}
}
for _, modName := range coreModules {
path := filepath.Join("core", modName+".lua")
fileData, err := config.Core.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 == "builtin" {
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 "", err
}
if L.GetTop() == 0 {
return "", 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 s, nil
}
return "", nil
}
if s := L.ToString(-1); s != "" {
return s, nil
}
return "", nil
}
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 = 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
}
func Start(dir string) error {
if err := os.Chdir(dir); err != nil {
return fmt.Errorf("failed to change directory to %s: %w", dir, err)
}
dir = "."
tomlDocument, err := os.ReadFile("Fes.toml")
if err != nil {
return fmt.Errorf("failed to read Fes.toml: %w", err)
}
docStr := fixMalformedToml(string(tomlDocument))
var cfg config.MyConfig
if err := toml.Unmarshal([]byte(docStr), &cfg); err != nil {
fmt.Printf("Warning: failed to parse Fes.toml: %v\n", err)
cfg.App.Authors = []string{"unknown"}
cfg.App.Name = "unknown"
cfg.App.Version = "unknown"
}
notFoundData := `
<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 := loadLua(dir, "www/404.lua", &cfg, reqData{}); err == nil {
notFoundData = nf
}
} else if _, err := os.Stat("www/404.html"); err == nil {
if buf, err := os.ReadFile("www/404.html"); err == nil {
notFoundData = string(buf)
}
}
routes := make(map[string]string)
if entries, err := os.ReadDir("www"); err == nil {
if err := handleDir(entries, "www", routes, "", false); err != nil {
fmt.Printf("Warning: failed to handle www directory: %v\n", err)
}
}
if entries, err := os.ReadDir("static"); err == nil {
if err := handleDir(entries, "static", routes, "/static", true); err != nil {
fmt.Printf("Warning: failed to handle static directory: %v\n", err)
}
}
if entries, err := os.ReadDir("archive"); err == nil {
if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
fmt.Printf("Warning: failed to handle archive directory: %v\n", err)
}
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
p, ok := routes[path]
fmt.Printf("> %s ", basePath(filepath.Base(p)))
if !ok && strings.HasPrefix(path, "/archive") {
fsPath := "." + path
info, err := os.Stat(fsPath)
if err == nil && info.IsDir() {
if htmlStr, err := generateArchiveIndex(fsPath, path); err == nil {
w.Write([]byte(htmlStr))
return
}
}
}
if !ok {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(notFoundData))
color.Yellow("not found")
return
}
params := make(map[string]string)
for k, val := range r.URL.Query() {
if len(val) > 0 {
params[k] = val[0]
}
}
req := reqData{
path: path,
params: params,
}
var data []byte
var err error
if strings.HasSuffix(p, ".lua") {
var b string
b, err = loadLua(dir, p, &cfg, req)
data = []byte(b)
} else if strings.HasSuffix(p, ".md") {
data, err = os.ReadFile(p)
data = []byte(markdownToHTML(string(data)))
} else {
data, err = os.ReadFile(p)
}
if err != nil {
http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError)
color.Red("bad")
return
}
w.Write(data)
color.Green("ok")
})
fmt.Printf("Server is running on http://localhost:%d\n", *config.Port)
return http.ListenAndServe(fmt.Sprintf(":%d", *config.Port), nil)
}

View File

@@ -0,0 +1,4 @@
# archive
This example demonstrates the archive feature of Fes it is useful for file
sharing purposes.

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

@@ -0,0 +1,10 @@
local fes = require("fes")
local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
site:h1("Hello, World!")
site:a("/archive", fes.std.h2("To the file room!"))
return site

View File

@@ -0,0 +1,4 @@
# hello
This is a very simple hello world program, the only difference between this and
default is this README.

View File

@@ -1,7 +1,7 @@
local fes = require("fes")
local site = fes.fes()
site.copyright = fes.util.copyright("https://git.vxserver.dev/fSD", "fSD")
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
site:h1("Hello, World!")

View File

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

View File

@@ -0,0 +1,23 @@
# best
This is an example of best practices for the Fes framework.
## Parts
With best practice we can break our sites into a few parts.
## Index
The main page of the site loads in the header and the footer, as well as shows
some core information
## Include
Within include the header and footer are defined.
* **Header:** Site navigation and name display
* **Footer:** Extra and external information.
## Static
This is where we store our favicon.

View File

@@ -0,0 +1,13 @@
local footer = {}
footer.render = function(std)
return table.concat({
std.h2("Other resources"),
std.tl({
std.external("https://git.vxserver.dev/fSD/fes", "Fes source"),
std.external("https://docs.vxserver.dev/static/fes.html", "Documentation"),
}),
})
end
return footer

View File

@@ -0,0 +1,7 @@
local header = {}
header.render = function(std)
return std.center(std.ha("/", std.h1("Best Practices")))
end
return header

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,20 @@
local fes = require("fes")
local std = fes.std
local u = fes.util
local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
site.title = "Best practices"
site.favicon = "/static/favicon.ico"
site:banner(fes.app.header.render(std))
site:note(u.cc {
std.h2("Hello, World!"),
std.p("This is an example of the best practices/canonical Fes site.")
})
site:note(fes.app.footer.render(std))
return site

View File

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

View File

@@ -0,0 +1,33 @@
# default
```
fes new default
```
> **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

@@ -0,0 +1,8 @@
local fes = require("fes")
local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
site:h1("Hello, World!")
return site

View File

@@ -0,0 +1,6 @@
# error
This shows what a Lua error looks like to the user. Lua errors are the most
common and the most critical so that is why they are shown to the user. Other,
lesser errors, are only shown to the developer because of their different
nature.

View File

@@ -0,0 +1,10 @@
local fes = require("fes")
local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
This is what an error looks like
site:h1("Hello, World!")
return site

View File

@@ -0,0 +1,3 @@
# markdown
This example demonstrate Fes's ability to handle markdown routes.

View File

@@ -0,0 +1,3 @@
# Markdown!
**Fes** also supports markdown routes!

View File

@@ -0,0 +1,5 @@
# simple
This simple example shows the extensibility of the Fes framework. It shows the
you do not necessarily need to use the site object (although it is recommended)
you can define your own site, similar to how Lisps do things.