36 Commits

Author SHA1 Message Date
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
53 changed files with 1782 additions and 1233 deletions

2
.gitignore vendored
View File

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

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:1.25-alpine AS builder
WORKDIR /src
RUN apk add --no-cache git build-base
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 ISC License
Copyright (c) 2025 fSD Copyright (c) 2025-2026 fSD
Permission to use, copy, modify, and/or distribute this software for any Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above purpose with or without fee is hereby granted, provided that the above

View File

@@ -34,12 +34,12 @@ go install fes
## Documentation ## Documentation
Run `fes run doc` for the documentation website or goto [docs.vxserver.dev](https://docs.vxserver.dev) Run `fes doc` for the documentation website or goto [docs.vxserver.dev](https://docs.vxserver.dev)
## License ## License
ISC License ISC License
Copyright (C) 2025 fSD Copyright (C) 2025-2026 fSD
See `COPYING` See `COPYING`

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,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 go 1.25.4
require ( require (
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a // indirect github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/gomarkdown/mdtohtml v0.0.0-20240124153210-d773061d1585 // indirect 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/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 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 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gomarkdown/markdown v0.0.0-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 h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=

View File

@@ -183,6 +183,7 @@ footer {
<li><a href="#introduction">Introduction</a></li> <li><a href="#introduction">Introduction</a></li>
<li><a href="#installation">Installation</a></li> <li><a href="#installation">Installation</a></li>
<li><a href="#usage">Usage</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="#cli-reference">Cli Reference</a></li>
<li><a href="#reference">Reference</a></li> <li><a href="#reference">Reference</a></li>
</ul> </ul>
@@ -210,6 +211,34 @@ footer {
</ul> </ul>
</section> </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"> <section id="cli-reference">
<h2>Cli Reference</h2> <h2>Cli Reference</h2>
<table> <thead> <table> <thead>
@@ -219,18 +248,6 @@ footer {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr>
<td><code>--help</code></td>
<td>Display help information</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> <tr>
<td><code>new &lt;project&gt;</code></td> <td><code>new &lt;project&gt;</code></td>
<td>Create a new projet called &lt;project&gt;</td> <td>Create a new projet called &lt;project&gt;</td>
@@ -243,6 +260,30 @@ footer {
<td><code>run &lt;project&gt;</code></td> <td><code>run &lt;project&gt;</code></td>
<td>Run the projet called &lt;project&gt;</td> <td>Run the projet called &lt;project&gt;</td>
</tr> </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> </tbody>
</table> </table>
</section> </section>
@@ -586,7 +627,7 @@ return hello</pre></code> This can be called from another with,
</section> </section>
<footer> <footer>
<p>Last updated: 2025-12-16</p> <p>Last updated: 2025-12-27</p>
</footer> </footer>
</main> </main>
</body> </body>

View File

@@ -1,7 +1,7 @@
-- Module options: -- Module options:
local always_use_lpeg = false local always_use_lpeg = false
local register_global_module_table = false local register_global_module_table = false
local global_module_name = 'json' local global_module_name = "json"
--[==[ --[==[
@@ -47,8 +47,7 @@ local pairs, type, tostring, tonumber, getmetatable, setmetatable =
local error, require, pcall, select = error, require, pcall, select local error, require, pcall, select = error, require, pcall, select
local floor, huge = math.floor, math.huge local floor, huge = math.floor, math.huge
local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat = local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat =
string.rep, string.gsub, string.sub, string.byte, string.char, string.rep, string.gsub, string.sub, string.byte, string.char, string.find, string.len, string.format
string.find, string.len, string.format
local strmatch = string.match local strmatch = string.match
local concat = table.concat local concat = table.concat
@@ -66,27 +65,31 @@ end
local _ENV = nil -- blocking globals in Lua 5.2 and later local _ENV = nil -- blocking globals in Lua 5.2 and later
pcall (function() pcall(function()
-- Enable access to blocked metatables. -- Enable access to blocked metatables.
-- Don't worry, this module doesn't change anything in them. -- Don't worry, this module doesn't change anything in them.
local debmeta = require "debug".getmetatable local debmeta = require("debug").getmetatable
if debmeta then getmetatable = debmeta end if debmeta then
getmetatable = debmeta
end
end) end)
json.null = setmetatable ({}, { json.null = setmetatable({}, {
__tojson = function () return "null" end __tojson = function()
return "null"
end,
}) })
local function isarray (tbl) local function isarray(tbl)
local max, n, arraylen = 0, 0, 0 local max, n, arraylen = 0, 0, 0
for k,v in pairs (tbl) do for k, v in pairs(tbl) do
if k == 'n' and type(v) == 'number' then if k == "n" and type(v) == "number" then
arraylen = v arraylen = v
if v > max then if v > max then
max = v max = v
end end
else else
if type(k) ~= 'number' or k < 1 or floor(k) ~= k then if type(k) ~= "number" or k < 1 or floor(k) ~= k then
return false return false
end end
if k > max then if k > max then
@@ -102,16 +105,21 @@ local function isarray (tbl)
end end
local escapecodes = { local escapecodes = {
["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f", ['"'] = '\\"',
["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" ["\\"] = "\\\\",
["\b"] = "\\b",
["\f"] = "\\f",
["\n"] = "\\n",
["\r"] = "\\r",
["\t"] = "\\t",
} }
local function escapeutf8 (uchar) local function escapeutf8(uchar)
local value = escapecodes[uchar] local value = escapecodes[uchar]
if value then if value then
return value return value
end end
local a, b, c, d = strbyte (uchar, 1, 4) 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 a, b, c, d = a or 0, b or 0, c or 0, d or 0
if a <= 0x7f then if a <= 0x7f then
value = a value = a
@@ -125,49 +133,49 @@ local function escapeutf8 (uchar)
return "" return ""
end end
if value <= 0xffff then if value <= 0xffff then
return strformat ("\\u%.4x", value) return strformat("\\u%.4x", value)
elseif value <= 0x10ffff then elseif value <= 0x10ffff then
-- encode as UTF-16 surrogate pair -- encode as UTF-16 surrogate pair
value = value - 0x10000 value = value - 0x10000
local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400) local highsur, lowsur = 0xD800 + floor(value / 0x400), 0xDC00 + (value % 0x400)
return strformat ("\\u%.4x\\u%.4x", highsur, lowsur) return strformat("\\u%.4x\\u%.4x", highsur, lowsur)
else else
return "" return ""
end end
end end
local function fsub (str, pattern, repl) local function fsub(str, pattern, repl)
-- gsub always builds a new string in a buffer, even when no match -- gsub always builds a new string in a buffer, even when no match
-- exists. First using find should be more efficient when most strings -- exists. First using find should be more efficient when most strings
-- don't contain the pattern. -- don't contain the pattern.
if strfind (str, pattern) then if strfind(str, pattern) then
return gsub (str, pattern, repl) return gsub(str, pattern, repl)
else else
return str return str
end end
end end
local function quotestring (value) local function quotestring(value)
-- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js -- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js
value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8) value = fsub(value, '[%z\1-\31"\\\127]', escapeutf8)
if strfind (value, "[\194\216\220\225\226\239]") then if strfind(value, "[\194\216\220\225\226\239]") then
value = fsub (value, "\194[\128-\159\173]", escapeutf8) value = fsub(value, "\194[\128-\159\173]", escapeutf8)
value = fsub (value, "\216[\128-\132]", escapeutf8) value = fsub(value, "\216[\128-\132]", escapeutf8)
value = fsub (value, "\220\143", escapeutf8) value = fsub(value, "\220\143", escapeutf8)
value = fsub (value, "\225\158[\180\181]", escapeutf8) value = fsub(value, "\225\158[\180\181]", escapeutf8)
value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8) value = fsub(value, "\226\128[\140-\143\168-\175]", escapeutf8)
value = fsub (value, "\226\129[\160-\175]", escapeutf8) value = fsub(value, "\226\129[\160-\175]", escapeutf8)
value = fsub (value, "\239\187\191", escapeutf8) value = fsub(value, "\239\187\191", escapeutf8)
value = fsub (value, "\239\191[\176-\191]", escapeutf8) value = fsub(value, "\239\191[\176-\191]", escapeutf8)
end end
return "\"" .. value .. "\"" return '"' .. value .. '"'
end end
json.quotestring = quotestring json.quotestring = quotestring
local function replace(str, o, n) local function replace(str, o, n)
local i, j = strfind (str, o, 1, true) local i, j = strfind(str, o, 1, true)
if i then if i then
return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1) return strsub(str, 1, i - 1) .. n .. strsub(str, j + 1, -1)
else else
return str return str
end end
@@ -176,7 +184,7 @@ end
-- locale independent num2str and str2num functions -- locale independent num2str and str2num functions
local decpoint, numfilter local decpoint, numfilter
local function updatedecpoint () local function updatedecpoint()
decpoint = strmatch(tostring(0.5), "([^05+])") decpoint = strmatch(tostring(0.5), "([^05+])")
-- build a filter that can be used to remove group separators -- build a filter that can be used to remove group separators
numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+" numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+"
@@ -184,11 +192,11 @@ end
updatedecpoint() updatedecpoint()
local function num2str (num) local function num2str(num)
return replace(fsub(tostring(num), numfilter, ""), decpoint, ".") return replace(fsub(tostring(num), numfilter, ""), decpoint, ".")
end end
local function str2num (str) local function str2num(str)
local num = tonumber(replace(str, ".", decpoint)) local num = tonumber(replace(str, ".", decpoint))
if not num then if not num then
updatedecpoint() updatedecpoint()
@@ -197,25 +205,24 @@ local function str2num (str)
return num return num
end end
local function addnewline2 (level, buffer, buflen) local function addnewline2(level, buffer, buflen)
buffer[buflen+1] = "\n" buffer[buflen + 1] = "\n"
buffer[buflen+2] = strrep (" ", level) buffer[buflen + 2] = strrep(" ", level)
buflen = buflen + 2 buflen = buflen + 2
return buflen return buflen
end end
function json.addnewline (state) function json.addnewline(state)
if state.indent then if state.indent then
state.bufferlen = addnewline2 (state.level or 0, state.bufferlen = addnewline2(state.level or 0, state.buffer, state.bufferlen or #state.buffer)
state.buffer, state.bufferlen or #(state.buffer))
end end
end end
local encode2 -- forward declaration local encode2 -- forward declaration
local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state) local function addpair(key, value, prev, indent, level, buffer, buflen, tables, globalorder, state)
local kt = type (key) local kt = type(key)
if kt ~= 'string' and kt ~= 'number' then if kt ~= "string" and kt ~= "number" then
return nil, "type '" .. kt .. "' is not supported as a key by JSON." return nil, "type '" .. kt .. "' is not supported as a key by JSON."
end end
if prev then if prev then
@@ -223,20 +230,20 @@ local function addpair (key, value, prev, indent, level, buffer, buflen, tables,
buffer[buflen] = "," buffer[buflen] = ","
end end
if indent then if indent then
buflen = addnewline2 (level, buffer, buflen) buflen = addnewline2(level, buffer, buflen)
end end
-- When Lua is compiled with LUA_NOCVTN2S this will fail when -- When Lua is compiled with LUA_NOCVTN2S this will fail when
-- numbers are mixed into the keys of the table. JSON keys are always -- 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 -- strings, so this would be an implicit conversion too and the failure
-- is intentional. -- is intentional.
buffer[buflen+1] = quotestring (key) buffer[buflen + 1] = quotestring(key)
buffer[buflen+2] = ":" buffer[buflen + 2] = ":"
return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state) return encode2(value, indent, level, buffer, buflen + 2, tables, globalorder, state)
end end
local function appendcustom(res, buffer, state) local function appendcustom(res, buffer, state)
local buflen = state.bufferlen local buflen = state.bufferlen
if type (res) == 'string' then if type(res) == "string" then
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = res buffer[buflen] = res
end end
@@ -250,8 +257,10 @@ local function exception(reason, value, state, buffer, buflen, defaultmessage)
return nil, defaultmessage return nil, defaultmessage
else else
state.bufferlen = buflen state.bufferlen = buflen
local ret, msg = handler (reason, value, state, defaultmessage) local ret, msg = handler(reason, value, state, defaultmessage)
if not ret then return nil, msg or defaultmessage end if not ret then
return nil, msg or defaultmessage
end
return appendcustom(ret, buffer, state) return appendcustom(ret, buffer, state)
end end
end end
@@ -260,48 +269,50 @@ function json.encodeexception(reason, value, state, defaultmessage)
return quotestring("<" .. defaultmessage .. ">") return quotestring("<" .. defaultmessage .. ">")
end end
encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state) encode2 = function(value, indent, level, buffer, buflen, tables, globalorder, state)
local valtype = type (value) local valtype = type(value)
local valmeta = getmetatable (value) local valmeta = getmetatable(value)
valmeta = type (valmeta) == 'table' and valmeta -- only tables valmeta = type(valmeta) == "table" and valmeta -- only tables
local valtojson = valmeta and valmeta.__tojson local valtojson = valmeta and valmeta.__tojson
if valtojson then if valtojson then
if tables[value] then if tables[value] then
return exception('reference cycle', value, state, buffer, buflen) return exception("reference cycle", value, state, buffer, buflen)
end end
tables[value] = true tables[value] = true
state.bufferlen = buflen state.bufferlen = buflen
local ret, msg = valtojson (value, state) local ret, msg = valtojson(value, state)
if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end if not ret then
return exception("custom encoder failed", value, state, buffer, buflen, msg)
end
tables[value] = nil tables[value] = nil
buflen = appendcustom(ret, buffer, state) buflen = appendcustom(ret, buffer, state)
elseif value == nil then elseif value == nil then
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = "null" buffer[buflen] = "null"
elseif valtype == 'number' then elseif valtype == "number" then
local s local s
if value ~= value or value >= huge or -value >= huge then if value ~= value or value >= huge or -value >= huge then
-- This is the behaviour of the original JSON implementation. -- This is the behaviour of the original JSON implementation.
s = "null" s = "null"
else else
s = num2str (value) s = num2str(value)
end end
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = s buffer[buflen] = s
elseif valtype == 'boolean' then elseif valtype == "boolean" then
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = value and "true" or "false" buffer[buflen] = value and "true" or "false"
elseif valtype == 'string' then elseif valtype == "string" then
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = quotestring (value) buffer[buflen] = quotestring(value)
elseif valtype == 'table' then elseif valtype == "table" then
if tables[value] then if tables[value] then
return exception('reference cycle', value, state, buffer, buflen) return exception("reference cycle", value, state, buffer, buflen)
end end
tables[value] = true tables[value] = true
level = level + 1 level = level + 1
local isa, n = isarray (value) local isa, n = isarray(value)
if n == 0 and valmeta and valmeta.__jsontype == 'object' then if n == 0 and valmeta and valmeta.__jsontype == "object" then
isa = false isa = false
end end
local msg local msg
@@ -309,8 +320,10 @@ encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, s
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = "[" buffer[buflen] = "["
for i = 1, n do for i = 1, n do
buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state) buflen, msg = encode2(value[i], indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end if not buflen then
return nil, msg
end
if i < n then if i < n then
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = "," buffer[buflen] = ","
@@ -331,63 +344,83 @@ encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, s
local v = value[k] local v = value[k]
if v ~= nil then if v ~= nil then
used[k] = true used[k] = true
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element prev = true -- add a seperator before the next element
end end
end end
for k,v in pairs (value) do for k, v in pairs(value) do
if not used[k] then if not used[k] then
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element prev = true -- add a seperator before the next element
end end
end end
else -- unordered else -- unordered
for k,v in pairs (value) do for k, v in pairs(value) do
buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) buflen, msg = addpair(k, v, prev, indent, level, buffer, buflen, tables, globalorder, state)
if not buflen then return nil, msg end if not buflen then
return nil, msg
end
prev = true -- add a seperator before the next element prev = true -- add a seperator before the next element
end end
end end
if indent then if indent then
buflen = addnewline2 (level - 1, buffer, buflen) buflen = addnewline2(level - 1, buffer, buflen)
end end
buflen = buflen + 1 buflen = buflen + 1
buffer[buflen] = "}" buffer[buflen] = "}"
end end
tables[value] = nil tables[value] = nil
else else
return exception ('unsupported type', value, state, buffer, buflen, return exception(
"type '" .. valtype .. "' is not supported by JSON.") "unsupported type",
value,
state,
buffer,
buflen,
"type '" .. valtype .. "' is not supported by JSON."
)
end end
return buflen return buflen
end end
function json.encode (value, state) function json.encode(value, state)
state = state or {} state = state or {}
local oldbuffer = state.buffer local oldbuffer = state.buffer
local buffer = oldbuffer or {} local buffer = oldbuffer or {}
state.buffer = buffer state.buffer = buffer
updatedecpoint() updatedecpoint()
local ret, msg = encode2 (value, state.indent, state.level or 0, local ret, msg = encode2(
buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state) value,
state.indent,
state.level or 0,
buffer,
state.bufferlen or 0,
state.tables or {},
state.keyorder,
state
)
if not ret then if not ret then
error (msg, 2) error(msg, 2)
elseif oldbuffer == buffer then elseif oldbuffer == buffer then
state.bufferlen = ret state.bufferlen = ret
return true return true
else else
state.bufferlen = nil state.bufferlen = nil
state.buffer = nil state.buffer = nil
return concat (buffer) return concat(buffer)
end end
end end
local function loc (str, where) local function loc(str, where)
local line, pos, linepos = 1, 1, 0 local line, pos, linepos = 1, 1, 0
while true do while true do
pos = strfind (str, "\n", pos, true) pos = strfind(str, "\n", pos, true)
if pos and pos < where then if pos and pos < where then
line = line + 1 line = line + 1
linepos = pos linepos = pos
@@ -396,27 +429,33 @@ local function loc (str, where)
break break
end end
end end
return strformat ("line %d, column %d", line, where - linepos) return strformat("line %d, column %d", line, where - linepos)
end end
local function unterminated (str, what, where) local function unterminated(str, what, where)
return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where) return nil, strlen(str) + 1, "unterminated " .. what .. " at " .. loc(str, where)
end end
local function scanwhite (str, pos) local function scanwhite(str, pos)
while true do while true do
pos = strfind (str, "%S", pos) pos = strfind(str, "%S", pos)
if not pos then return nil end if not pos then
local sub2 = strsub (str, pos, pos + 1) return nil
if sub2 == "\239\187" and strsub (str, pos + 2, pos + 2) == "\191" then 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 -- UTF-8 Byte Order Mark
pos = pos + 3 pos = pos + 3
elseif sub2 == "//" then elseif sub2 == "//" then
pos = strfind (str, "[\n\r]", pos + 2) pos = strfind(str, "[\n\r]", pos + 2)
if not pos then return nil end if not pos then
return nil
end
elseif sub2 == "/*" then elseif sub2 == "/*" then
pos = strfind (str, "*/", pos + 2) pos = strfind(str, "*/", pos + 2)
if not pos then return nil end if not pos then
return nil
end
pos = pos + 2 pos = pos + 2
else else
return pos return pos
@@ -425,59 +464,64 @@ local function scanwhite (str, pos)
end end
local escapechars = { local escapechars = {
["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f", ['"'] = '"',
["n"] = "\n", ["r"] = "\r", ["t"] = "\t" ["\\"] = "\\",
["/"] = "/",
["b"] = "\b",
["f"] = "\f",
["n"] = "\n",
["r"] = "\r",
["t"] = "\t",
} }
local function unichar (value) local function unichar(value)
if value < 0 then if value < 0 then
return nil return nil
elseif value <= 0x007f then elseif value <= 0x007f then
return strchar (value) return strchar(value)
elseif value <= 0x07ff then elseif value <= 0x07ff then
return strchar (0xc0 + floor(value/0x40), return strchar(0xc0 + floor(value / 0x40), 0x80 + (floor(value) % 0x40))
0x80 + (floor(value) % 0x40))
elseif value <= 0xffff then elseif value <= 0xffff then
return strchar (0xe0 + floor(value/0x1000), return strchar(0xe0 + floor(value / 0x1000), 0x80 + (floor(value / 0x40) % 0x40), 0x80 + (floor(value) % 0x40))
0x80 + (floor(value/0x40) % 0x40),
0x80 + (floor(value) % 0x40))
elseif value <= 0x10ffff then elseif value <= 0x10ffff then
return strchar (0xf0 + floor(value/0x40000), return strchar(
0x80 + (floor(value/0x1000) % 0x40), 0xf0 + floor(value / 0x40000),
0x80 + (floor(value/0x40) % 0x40), 0x80 + (floor(value / 0x1000) % 0x40),
0x80 + (floor(value) % 0x40)) 0x80 + (floor(value / 0x40) % 0x40),
0x80 + (floor(value) % 0x40)
)
else else
return nil return nil
end end
end end
local function scanstring (str, pos) local function scanstring(str, pos)
local lastpos = pos + 1 local lastpos = pos + 1
local buffer, n = {}, 0 local buffer, n = {}, 0
while true do while true do
local nextpos = strfind (str, "[\"\\]", lastpos) local nextpos = strfind(str, '["\\]', lastpos)
if not nextpos then if not nextpos then
return unterminated (str, "string", pos) return unterminated(str, "string", pos)
end end
if nextpos > lastpos then if nextpos > lastpos then
n = n + 1 n = n + 1
buffer[n] = strsub (str, lastpos, nextpos - 1) buffer[n] = strsub(str, lastpos, nextpos - 1)
end end
if strsub (str, nextpos, nextpos) == "\"" then if strsub(str, nextpos, nextpos) == '"' then
lastpos = nextpos + 1 lastpos = nextpos + 1
break break
else else
local escchar = strsub (str, nextpos + 1, nextpos + 1) local escchar = strsub(str, nextpos + 1, nextpos + 1)
local value local value
if escchar == "u" then if escchar == "u" then
value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16) value = tonumber(strsub(str, nextpos + 2, nextpos + 5), 16)
if value then if value then
local value2 local value2
if 0xD800 <= value and value <= 0xDBff then if 0xD800 <= value and value <= 0xDBff then
-- we have the high surrogate of UTF-16. Check if there is a -- we have the high surrogate of UTF-16. Check if there is a
-- low surrogate escaped nearby to combine them. -- low surrogate escaped nearby to combine them.
if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then if strsub(str, nextpos + 6, nextpos + 7) == "\\u" then
value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16) value2 = tonumber(strsub(str, nextpos + 8, nextpos + 11), 16)
if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then
value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000 value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000
else else
@@ -485,7 +529,7 @@ local function scanstring (str, pos)
end end
end end
end end
value = value and unichar (value) value = value and unichar(value)
if value then if value then
if value2 then if value2 then
lastpos = nextpos + 12 lastpos = nextpos + 12
@@ -506,7 +550,7 @@ local function scanstring (str, pos)
if n == 1 then if n == 1 then
return buffer[1], lastpos return buffer[1], lastpos
elseif n > 1 then elseif n > 1 then
return concat (buffer), lastpos return concat(buffer), lastpos
else else
return "", lastpos return "", lastpos
end end
@@ -514,40 +558,52 @@ end
local scanvalue -- forward declaration local scanvalue -- forward declaration
local function scantable (what, closechar, str, startpos, nullval, objectmeta, arraymeta) local function scantable(what, closechar, str, startpos, nullval, objectmeta, arraymeta)
local tbl, n = {}, 0 local tbl, n = {}, 0
local pos = startpos + 1 local pos = startpos + 1
if what == 'object' then if what == "object" then
setmetatable (tbl, objectmeta) setmetatable(tbl, objectmeta)
else else
setmetatable (tbl, arraymeta) setmetatable(tbl, arraymeta)
end end
while true do while true do
pos = scanwhite (str, pos) pos = scanwhite(str, pos)
if not pos then return unterminated (str, what, startpos) end if not pos then
local char = strsub (str, pos, pos) return unterminated(str, what, startpos)
end
local char = strsub(str, pos, pos)
if char == closechar then if char == closechar then
return tbl, pos + 1 return tbl, pos + 1
end end
local val1, err local val1, err
val1, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) val1, pos, err = scanvalue(str, pos, nullval, objectmeta, arraymeta)
if err then return nil, pos, err end if err then
pos = scanwhite (str, pos) return nil, pos, err
if not pos then return unterminated (str, what, startpos) end end
char = strsub (str, pos, pos) pos = scanwhite(str, pos)
if not pos then
return unterminated(str, what, startpos)
end
char = strsub(str, pos, pos)
if char == ":" then if char == ":" then
if val1 == nil then if val1 == nil then
return nil, pos, "cannot use nil as table index (at " .. loc (str, pos) .. ")" 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 end
pos = scanwhite (str, pos + 1)
if not pos then return unterminated (str, what, startpos) end
local val2 local val2
val2, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) val2, pos, err = scanvalue(str, pos, nullval, objectmeta, arraymeta)
if err then return nil, pos, err end if err then
return nil, pos, err
end
tbl[val1] = val2 tbl[val1] = val2
pos = scanwhite (str, pos) pos = scanwhite(str, pos)
if not pos then return unterminated (str, what, startpos) end if not pos then
char = strsub (str, pos, pos) return unterminated(str, what, startpos)
end
char = strsub(str, pos, pos)
else else
n = n + 1 n = n + 1
tbl[n] = val1 tbl[n] = val1
@@ -558,30 +614,30 @@ local function scantable (what, closechar, str, startpos, nullval, objectmeta, a
end end
end end
scanvalue = function (str, pos, nullval, objectmeta, arraymeta) scanvalue = function(str, pos, nullval, objectmeta, arraymeta)
pos = pos or 1 pos = pos or 1
pos = scanwhite (str, pos) pos = scanwhite(str, pos)
if not pos then if not pos then
return nil, strlen (str) + 1, "no valid JSON value (reached the end)" return nil, strlen(str) + 1, "no valid JSON value (reached the end)"
end end
local char = strsub (str, pos, pos) local char = strsub(str, pos, pos)
if char == "{" then if char == "{" then
return scantable ('object', "}", str, pos, nullval, objectmeta, arraymeta) return scantable("object", "}", str, pos, nullval, objectmeta, arraymeta)
elseif char == "[" then elseif char == "[" then
return scantable ('array', "]", str, pos, nullval, objectmeta, arraymeta) return scantable("array", "]", str, pos, nullval, objectmeta, arraymeta)
elseif char == "\"" then elseif char == '"' then
return scanstring (str, pos) return scanstring(str, pos)
else else
local pstart, pend = strfind (str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos) local pstart, pend = strfind(str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos)
if pstart then if pstart then
local number = str2num (strsub (str, pstart, pend)) local number = str2num(strsub(str, pstart, pend))
if number then if number then
return number, pend + 1 return number, pend + 1
end end
end end
pstart, pend = strfind (str, "^%a%w*", pos) pstart, pend = strfind(str, "^%a%w*", pos)
if pstart then if pstart then
local name = strsub (str, pstart, pend) local name = strsub(str, pstart, pend)
if name == "true" then if name == "true" then
return true, pend + 1 return true, pend + 1
elseif name == "false" then elseif name == "false" then
@@ -590,7 +646,7 @@ scanvalue = function (str, pos, nullval, objectmeta, arraymeta)
return nullval, pend + 1 return nullval, pend + 1
end end
end end
return nil, pos, "no valid JSON value at " .. loc (str, pos) return nil, pos, "no valid JSON value at " .. loc(str, pos)
end end
end end
@@ -598,125 +654,132 @@ local function optionalmetatables(...)
if select("#", ...) > 0 then if select("#", ...) > 0 then
return ... return ...
else else
return {__jsontype = 'object'}, {__jsontype = 'array'} return { __jsontype = "object" }, { __jsontype = "array" }
end end
end end
function json.decode (str, pos, nullval, ...) function json.decode(str, pos, nullval, ...)
local objectmeta, arraymeta = optionalmetatables(...) local objectmeta, arraymeta = optionalmetatables(...)
return scanvalue (str, pos, nullval, objectmeta, arraymeta) return scanvalue(str, pos, nullval, objectmeta, arraymeta)
end end
function json.use_lpeg () function json.use_lpeg()
local g = require ("lpeg") local g = require("lpeg")
if type(g.version) == 'function' and g.version() == "0.11" then 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" error("due to a bug in LPeg 0.11, it cannot be used for JSON matching")
end end
local pegmatch = g.match local pegmatch = g.match
local P, S, R = g.P, g.S, g.R local P, S, R = g.P, g.S, g.R
local function ErrorCall (str, pos, msg, state) local function ErrorCall(str, pos, msg, state)
if not state.msg then if not state.msg then
state.msg = msg .. " at " .. loc (str, pos) state.msg = msg .. " at " .. loc(str, pos)
state.pos = pos state.pos = pos
end end
return false return false
end end
local function Err (msg) local function Err(msg)
return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall) return g.Cmt(g.Cc(msg) * g.Carg(2), ErrorCall)
end end
local function ErrorUnterminatedCall (str, pos, what, state) local function ErrorUnterminatedCall(str, pos, what, state)
return ErrorCall (str, pos - 1, "unterminated " .. what, state) return ErrorCall(str, pos - 1, "unterminated " .. what, state)
end end
local SingleLineComment = P"//" * (1 - S"\n\r")^0 local SingleLineComment = P("//") * (1 - S("\n\r")) ^ 0
local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/" local MultiLineComment = P("/*") * (1 - P("*/")) ^ 0 * P("*/")
local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0 local Space = (S(" \n\r\t") + P("\239\187\191") + SingleLineComment + MultiLineComment) ^ 0
local function ErrUnterminated (what) local function ErrUnterminated(what)
return g.Cmt (g.Cc (what) * g.Carg (2), ErrorUnterminatedCall) return g.Cmt(g.Cc(what) * g.Carg(2), ErrorUnterminatedCall)
end end
local PlainChar = 1 - S"\"\\\n\r" local PlainChar = 1 - S('"\\\n\r')
local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars local EscapeSequence = (P("\\") * g.C(S('"\\/bfnrt') + Err("unsupported escape sequence"))) / escapechars
local HexDigit = R("09", "af", "AF") local HexDigit = R("09", "af", "AF")
local function UTF16Surrogate (match, pos, high, low) local function UTF16Surrogate(match, pos, high, low)
high, low = tonumber (high, 16), tonumber (low, 16) high, low = tonumber(high, 16), tonumber(low, 16)
if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then
return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000) return true, unichar((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000)
else else
return false return false
end end
end end
local function UTF16BMP (hex) local function UTF16BMP(hex)
return unichar (tonumber (hex, 16)) return unichar(tonumber(hex, 16))
end end
local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit)) local U16Sequence = (P("\\u") * g.C(HexDigit * HexDigit * HexDigit * HexDigit))
local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP local UnicodeEscape = g.Cmt(U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence / UTF16BMP
local Char = UnicodeEscape + EscapeSequence + PlainChar local Char = UnicodeEscape + EscapeSequence + PlainChar
local String = P"\"" * (g.Cs (Char ^ 0) * P"\"" + ErrUnterminated "string") local String = P('"') * (g.Cs(Char ^ 0) * P('"') + ErrUnterminated("string"))
local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0)) local Integer = P("-") ^ -1 * (P("0") + (R("19") * R("09") ^ 0))
local Fractal = P"." * R"09"^0 local Fractal = P(".") * R("09") ^ 0
local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1 local Exponent = (S("eE")) * (S("+-")) ^ -1 * R("09") ^ 1
local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num 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 Constant = P("true") * g.Cc(true) + P("false") * g.Cc(false) + P("null") * g.Carg(1)
local SimpleValue = Number + String + Constant local SimpleValue = Number + String + Constant
local ArrayContent, ObjectContent local ArrayContent, ObjectContent
-- The functions parsearray and parseobject parse only a single value/pair -- The functions parsearray and parseobject parse only a single value/pair
-- at a time and store them directly to avoid hitting the LPeg limits. -- at a time and store them directly to avoid hitting the LPeg limits.
local function parsearray (str, pos, nullval, state) local function parsearray(str, pos, nullval, state)
local obj, cont local obj, cont
local start = pos local start = pos
local npos local npos
local t, nt = {}, 0 local t, nt = {}, 0
repeat repeat
obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state) obj, cont, npos = pegmatch(ArrayContent, str, pos, nullval, state)
if cont == 'end' then if cont == "end" then
return ErrorUnterminatedCall (str, start, "array", state) return ErrorUnterminatedCall(str, start, "array", state)
end end
pos = npos pos = npos
if cont == 'cont' or cont == 'last' then if cont == "cont" or cont == "last" then
nt = nt + 1 nt = nt + 1
t[nt] = obj t[nt] = obj
end end
until cont ~= 'cont' until cont ~= "cont"
return pos, setmetatable (t, state.arraymeta) return pos, setmetatable(t, state.arraymeta)
end end
local function parseobject (str, pos, nullval, state) local function parseobject(str, pos, nullval, state)
local obj, key, cont local obj, key, cont
local start = pos local start = pos
local npos local npos
local t = {} local t = {}
repeat repeat
key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state) key, obj, cont, npos = pegmatch(ObjectContent, str, pos, nullval, state)
if cont == 'end' then if cont == "end" then
return ErrorUnterminatedCall (str, start, "object", state) return ErrorUnterminatedCall(str, start, "object", state)
end end
pos = npos pos = npos
if cont == 'cont' or cont == 'last' then if cont == "cont" or cont == "last" then
t[key] = obj t[key] = obj
end end
until cont ~= 'cont' until cont ~= "cont"
return pos, setmetatable (t, state.objectmeta) return pos, setmetatable(t, state.objectmeta)
end end
local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray) 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 Object = P("{") * g.Cmt(g.Carg(1) * g.Carg(2), parseobject)
local Value = Space * (Array + Object + SimpleValue) local Value = Space * (Array + Object + SimpleValue)
local ExpectedValue = Value + Space * Err "value expected" local ExpectedValue = Value + Space * Err("value expected")
local ExpectedKey = String + Err "key expected" local ExpectedKey = String + Err("key expected")
local End = P(-1) * g.Cc'end' local End = P(-1) * g.Cc("end")
local ErrInvalid = Err "invalid JSON" 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() ArrayContent = (
local Pair = g.Cg (Space * ExpectedKey * Space * (P":" + Err "colon expected") * ExpectedValue) Value * Space * (P(",") * g.Cc("cont") + P("]") * g.Cc("last") + End + ErrInvalid)
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() + g.Cc(nil) * (P("]") * g.Cc("empty") + End + ErrInvalid)
local DecodeValue = ExpectedValue * g.Cp () ) * 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.version = json.version
jsonlpeg.encode = json.encode jsonlpeg.encode = json.encode
@@ -726,10 +789,10 @@ function json.use_lpeg ()
jsonlpeg.encodeexception = json.encodeexception jsonlpeg.encodeexception = json.encodeexception
jsonlpeg.using_lpeg = true jsonlpeg.using_lpeg = true
function jsonlpeg.decode (str, pos, nullval, ...) function jsonlpeg.decode(str, pos, nullval, ...)
local state = {} local state = {}
state.objectmeta, state.arraymeta = optionalmetatables(...) state.objectmeta, state.arraymeta = optionalmetatables(...)
local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state) local obj, retpos = pegmatch(DecodeValue, str, pos, nullval, state)
if state.msg then if state.msg then
return nil, state.pos, state.msg return nil, state.pos, state.msg
else else
@@ -738,7 +801,9 @@ function json.use_lpeg ()
end end
-- cache result of this function: -- cache result of this function:
json.use_lpeg = function () return jsonlpeg end json.use_lpeg = function()
return jsonlpeg
end
jsonlpeg.use_lpeg = json.use_lpeg jsonlpeg.use_lpeg = json.use_lpeg
return jsonlpeg return jsonlpeg
@@ -749,4 +814,3 @@ if always_use_lpeg then
end end
return json return json

View File

@@ -1,10 +1,12 @@
local std = require("lib.std") local std = require("lib.std")
local symbol = require("lib.symbol")
local M = {} local M = {}
M.__index = M M.__index = M
function M.fes(header, footer) function M.fes(header, footer)
local config = {} local site_config = {} local config = {}
local site_config = {}
local fes_mod = package.loaded.fes local fes_mod = package.loaded.fes
if fes_mod and fes_mod.config then if fes_mod and fes_mod.config then
config = fes_mod.config config = fes_mod.config
@@ -311,17 +313,36 @@ em, i { font-style: italic; }
</body> </body>
</html> </html>
]], ]],
parts = {} parts = {},
} }
return setmetatable(self, M) return setmetatable(self, M)
end end
function M:custom(str) function M:g(str)
table.insert(self.parts, str) table.insert(self.parts, str)
return self return self
end end
function M:extend(name, tbl)
if type(name) ~= "string" then
error("First argument to extend must be a string (namespace name)")
end
if type(tbl) ~= "table" then
error("Second argument to extend must be a table of functions")
end
self[name] = {}
for k, v in pairs(tbl) do
if type(v) ~= "function" then
error("Extension values must be functions, got " .. type(v) .. " for key " .. k)
end
self[name][k] = function(...)
return v(self, ...)
end
end
return self
end
for name, func in pairs(std) do for name, func in pairs(std) do
if type(func) == "function" then if type(func) == "function" then
M[name] = function(self, ...) M[name] = function(self, ...)
@@ -336,8 +357,14 @@ function M:build()
local header = self.header local header = self.header
header = header:gsub("{{TITLE}}", self.title or "Document") header = header:gsub("{{TITLE}}", self.title or "Document")
local favicon_html = self.favicon and ('<link rel="icon" type="image/x-icon" href="' .. self.favicon .. '">') 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>">]]) header = header:gsub(
local footer = self.footer:gsub("{{COPYRIGHT}}", self.copyright or "&#169; The Copyright Holder") "{{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 return header .. table.concat(self.parts, "\n") .. footer
end end

View File

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

View File

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

View File

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

39
main.go
View File

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

View File

@@ -10,6 +10,8 @@ var Doc string
var Port *int var Port *int
var Color *bool var Color *bool
var Static *bool var Static *bool
var Docker *bool
var Verbose *bool
type AppConfig struct { type AppConfig struct {
App struct { App struct {

View File

@@ -9,6 +9,7 @@ import (
"github.com/pkg/browser" "github.com/pkg/browser"
) )
/* open documentation in browser */
func Open() error { func Open() error {
fmt.Println("Opening documentation in browser") fmt.Println("Opening documentation in browser")

View File

@@ -1,6 +1,8 @@
package new package new
import ( import (
"fes/modules/config"
"fes/modules/ui"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -26,7 +28,7 @@ func getName() string {
} }
/* helper function for writing files */ /* helper function for writing files */
func write(path string, format string, args ...interface{}) error { func write(path string, format string, args ...any) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
panic(err) panic(err)
@@ -49,6 +51,22 @@ func Project(dir string) error {
return err 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() name := getName()
write("www/index.lua", `local fes = require("fes") write("www/index.lua", `local fes = require("fes")
@@ -64,5 +82,49 @@ return site`, name)
name = "%s" name = "%s"
version = "0.0.1" version = "0.0.1"
authors = ["%s"]`, dir, name) 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 return nil
} }

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

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

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

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

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

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

View File

@@ -4,390 +4,22 @@ import (
"fes/modules/config" "fes/modules/config"
"fes/modules/ui" "fes/modules/ui"
"fmt" "fmt"
"github.com/pelletier/go-toml/v2" "log"
lua "github.com/yuin/gopher-lua"
"html/template"
"io/fs"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
) )
type reqData struct { var routes map[string]string
path string
params map[string]string
}
func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error { func Start(dir string) {
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 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(entry string, cfg *config.AppConfig, requestData reqData) ([]byte, error) {
L := lua.NewState()
defer L.Close()
libFiles, err := fs.ReadDir(config.Lib, "lib")
if err == nil {
for _, de := range libFiles {
if de.IsDir() || !strings.HasSuffix(de.Name(), ".lua") {
continue
}
path := filepath.Join("lib", de.Name())
fileData, err := config.Lib.ReadFile(path)
if err != nil {
continue
}
L.DoString(string(fileData))
}
}
preloadLuaModule := func(name, path string) {
L.PreloadModule(name, func(L *lua.LState) int {
fileData, err := config.Lib.ReadFile(path)
if err != nil {
panic(err)
}
if err := L.DoString(string(fileData)); err != nil {
panic(err)
}
L.Push(L.Get(-1))
return 1
})
}
preloadLuaModule("lib.std", "lib/std.lua")
preloadLuaModule("lib.symbol", "lib/symbol.lua")
preloadLuaModule("lib.util", "lib/util.lua")
L.PreloadModule("fes", func(L *lua.LState) int {
mod := L.NewTable()
libModules := []string{}
if ents, err := fs.ReadDir(config.Lib, "lib"); err == nil {
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".lua") {
continue
}
libModules = append(libModules, strings.TrimSuffix(e.Name(), ".lua"))
}
}
for _, modName := range libModules {
path := filepath.Join("lib", modName+".lua")
fileData, err := config.Lib.ReadFile(path)
if err != nil {
continue
}
if err := L.DoString(string(fileData)); err != nil {
continue
}
val := L.Get(-1)
L.Pop(1)
tbl, ok := val.(*lua.LTable)
if !ok || tbl == nil {
tbl = L.NewTable()
}
if modName == "fes" {
tbl.ForEach(func(k, v lua.LValue) { mod.RawSet(k, v) })
} else {
mod.RawSetString(modName, tbl)
}
}
mod.RawSetString("app", loadIncludeModules(L, filepath.Join(".", "include")))
if cfg != nil {
site := L.NewTable()
site.RawSetString("version", lua.LString(cfg.App.Version))
site.RawSetString("name", lua.LString(cfg.App.Name))
authors := L.NewTable()
for i, a := range cfg.App.Authors {
authors.RawSetInt(i+1, lua.LString(a))
}
site.RawSetString("authors", authors)
mod.RawSetString("site", site)
}
bus := L.NewTable()
bus.RawSetString("url", lua.LString(requestData.path))
params := L.NewTable()
for k, v := range requestData.params {
params.RawSetString(k, lua.LString(v))
}
bus.RawSetString("params", params)
mod.RawSetString("bus", bus)
mod.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(markdownToHTML(L.ToString(1))))
return 1
}))
L.Push(mod)
return 1
})
if err := L.DoFile(entry); err != nil {
return []byte(""), err
}
if L.GetTop() == 0 {
return []byte(""), nil
}
L.SetGlobal("__fes_result", L.Get(-1))
if err := L.DoString("return tostring(__fes_result)"); err != nil {
L.GetGlobal("__fes_result")
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}
if s := L.ToString(-1); s != "" {
return []byte(s), nil
}
return []byte(""), nil
}
func generateArchiveIndex(fsPath string, urlPath string) (string, error) {
info, err := os.Stat(fsPath)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("not a directory")
}
ents, err := os.ReadDir(fsPath)
if err != nil {
return "", err
}
type entryInfo struct {
name string
isDir bool
href string
size int64
mod time.Time
}
var list []entryInfo
for _, e := range ents {
n := e.Name()
full := filepath.Join(fsPath, n)
st, err := os.Stat(full)
if err != nil {
continue
}
isd := st.IsDir()
displayName := n
if isd {
displayName = n + "/"
}
href := path.Join(urlPath, n)
if isd && !strings.HasSuffix(href, "/") {
href = href + "/"
}
size := int64(-1)
if !isd {
size = st.Size()
}
list = append(list, entryInfo{name: displayName, isDir: isd, href: href, size: size, mod: st.ModTime()})
}
sort.Slice(list, func(i, j int) bool {
if list[i].isDir != list[j].isDir {
return list[i].isDir
}
return strings.ToLower(list[i].name) < strings.ToLower(list[j].name)
})
urlPath = basePath(strings.TrimPrefix(urlPath, "/archive"))
var b strings.Builder
b.WriteString("<html>\n<head><title>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</title></head>\n<body>\n<h1>Index of ")
b.WriteString(template.HTMLEscapeString(urlPath))
b.WriteString("</h1><hr><pre>")
if urlPath != "/archive" && urlPath != "/archive/" {
up := path.Dir(urlPath)
if up == "." {
up = "/archive"
}
if !strings.HasSuffix(up, "/") {
up = "/archive" + filepath.Dir(up) + "/"
}
b.WriteString(`<a href="` + template.HTMLEscapeString(up) + `">../</a>` + "\n")
} else {
b.WriteString(`<a href="../">../</a>` + "\n")
}
nameCol := 50
for _, ei := range list {
escapedName := template.HTMLEscapeString(ei.name)
dateStr := ei.mod.Local().Format("02-Jan-2006 15:04")
var sizeStr string
if ei.isDir {
sizeStr = "-"
} else {
sizeStr = fmt.Sprintf("%d", ei.size)
}
spaces := 1
if len(escapedName) < nameCol {
spaces = nameCol - len(escapedName)
}
line := `<a href="` + template.HTMLEscapeString(ei.href) + `">` + escapedName + `</a>` + strings.Repeat(" ", spaces) + dateStr + strings.Repeat(" ", 19-len(sizeStr)) + sizeStr + "\n"
b.WriteString(line)
}
b.WriteString("</pre><hr></body>\n</html>")
return b.String(), nil
}
func generateNotFoundData(cfg *config.AppConfig) []byte {
notFoundData := []byte(`
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>fes</center>
</body>
</html>
`)
if _, err := os.Stat(filepath.Join("www", "404.lua")); err == nil {
if nf, err := loadLua("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 = buf
}
}
return notFoundData
}
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
}
func parseConfig() config.AppConfig {
tomlDocument, err := os.ReadFile("Fes.toml")
if err != nil {
ui.Error("failed to read Fes.toml", err)
os.Exit(1)
}
docStr := fixMalformedToml(string(tomlDocument))
var cfg config.AppConfig
if err := toml.Unmarshal([]byte(docStr), &cfg); err != nil {
ui.Warning("failed to parse Fes.toml", err)
cfg.App.Authors = []string{"unknown"}
cfg.App.Name = "unknown"
cfg.App.Version = "unknown"
}
return cfg
}
func readArchive(w http.ResponseWriter, route string) {
fsPath := "." + route
if info, err := os.Stat(fsPath); err == nil && info.IsDir() {
if page, err := generateArchiveIndex(fsPath, route); err == nil {
w.Write([]byte(page))
}
}
}
func Start(dir string) error {
if err := os.Chdir(dir); err != nil { if err := os.Chdir(dir); err != nil {
return ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err) ui.Error(fmt.Sprintf("failed to change directory to %s", dir), err)
} }
cfg := parseConfig() ui.Log("running root=%s, port=%d.", filepath.Clean(dir), *config.Port)
notFoundData := generateNotFoundData(&cfg)
routes := loadDirs() routes := loadDirs()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -405,10 +37,10 @@ func Start(dir string) error {
route = r.URL.Path route = r.URL.Path
if strings.HasPrefix(route, "/archive") { if strings.HasPrefix(route, "/archive") {
readArchive(w, route) err = readArchive(w, route)
} else { } else {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte(notFoundData)) w.Write([]byte("not found :("))
} }
return return
} }
@@ -422,10 +54,11 @@ func Start(dir string) error {
var data []byte var data []byte
if strings.HasSuffix(route, ".lua") { if strings.HasSuffix(route, ".lua") {
data, err = loadLua(route, &cfg, reqData{path: r.URL.Path, params: params}) data, err = render(route, reqData{path: r.URL.Path, params: params})
} else if strings.HasSuffix(route, ".md") { } else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route) data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data))) data = []byte(markdownToHTML(string(data)))
data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
} else { } else {
data, err = os.ReadFile(route) data, err = os.ReadFile(route)
} }
@@ -436,7 +69,7 @@ func Start(dir string) error {
w.Write(data) w.Write(data)
}) })
ui.Log("Server initialized")
fmt.Printf("Server is running on http://localhost:%d\n", *config.Port) log.Fatal(http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil))
return http.ListenAndServe(fmt.Sprintf(":%d", *config.Port), nil)
} }

View File

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

View File

@@ -4,54 +4,114 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"fes/modules/config" "fes/modules/config"
"fes/modules/version"
"github.com/fatih/color" "github.com/fatih/color"
) )
const (
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) { func Path(path string, err error) {
path = strings.TrimPrefix(path, "/") path = strings.TrimPrefix(path, "/")
if path == "" { if path == "" {
path = "(null)" path = "(null)"
} }
fmt.Printf(" > %s ", path)
if err == nil { if err == nil {
OK("ok") OK("Route: %s - ok", path)
return
} else if errors.Is(err, config.ErrRouteMiss) { } else if errors.Is(err, config.ErrRouteMiss) {
WARN(config.ErrRouteMiss.Error()) WARN("Route: %s - %s", path, config.ErrRouteMiss.Error())
} else { } else {
ERROR("bad") ERROR("Route: %s - bad", path)
} }
} }
// System warning with prefix
func Warning(msg string, err error) error { func Warning(msg string, err error) error {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.MagentaString("warning"), err) WARN("%s: %v", msg, err)
return err return err
} }
// System error with prefix
func Error(msg string, err error) error { func Error(msg string, err error) error {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.RedString("error"), err) ERROR("%s: %v", msg, err)
return err return err
} }
// Fatal system error
func Fatal(msg string, err error) error { func Fatal(msg string, err error) error {
fmt.Printf("%s: %s: %v\n", version.PROGRAM_NAME, color.RedString("fatal"), err) FATAL("%s: %v", msg, err)
panic(err) return err
} }
func OK(msg string) { // Log on Verbose
color.Green(msg) func LogVerbose(msg string, args ...any) {
} if *config.Verbose {
Log(msg, args...)
func WARN(msg string) { }
color.Magenta(msg)
}
func ERROR(msg string) {
color.Red(msg)
} }

View File

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

88
scripts/test_all Executable file
View File

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

View File

@@ -0,0 +1,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

@@ -1,15 +1,10 @@
local fes = require("fes") local fes = require("fes")
local std = fes.std
local site = fes.fes() local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD") site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
site:h1("Hello, World!") site:h1("Hello, World!")
site:note( site:a("/archive", fes.std.h2("To the file room!"))
fes.app.foo.render()
)
return site 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,5 +1,5 @@
[app] [app]
name = "advanced" name = "best"
version = "0.0.1" version = "0.0.1"
authors = ["vx-clutch"] 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

@@ -1,17 +1,10 @@
local fes = require("fes") local fes = require("fes")
local std = fes.std
local site = fes.fes() local site = fes.fes()
site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD") site.copyright = fes.util.copyright("https://fsd.vxserver.dev", "fSD")
This is what an error looks like
site:h1("Hello, World!") site:h1("Hello, World!")
site:note(fes.util.cc {
std.h2("Files"),
std.ul {
std.a("/archive", "to the file room!"),
}
})
return site 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.