Compare commits

..

54 Commits

Author SHA1 Message Date
vx-clutch
7a6625fea7 First version of Gemini formatting 2026-03-01 21:51:37 -05:00
vx-clutch
6b87f3794b Update todos 2026-03-01 21:51:27 -05:00
vx-clutch
865e22deb7 Move TODO -> todo 2026-03-01 11:46:25 -05:00
1dd76cf1d2 save 2026-02-24 15:59:26 -05:00
ebd14a1b36 update build flags
update build flags
2026-02-16 21:48:56 -05:00
f8287de023 add note back into the standard library 2026-02-16 21:43:05 -05:00
472e27b1fa add gemini support 2026-02-16 21:00:04 -05:00
94818e25fe WPI 2026-02-14 15:50:05 -05:00
8bfe979093 extract out protocol 2026-02-14 08:26:56 -05:00
d340c55e8c Merge branch 'api-change' of https://git.vxserver.dev/fsd/fes into api-change 2026-02-13 21:41:20 -05:00
4bd920b09f update api to use "blocks" for future protocols 2026-02-13 21:41:02 -05:00
b2dfe99326 update api to use "block" for future protocol expantion 2026-02-13 21:40:08 -05:00
ac30aea1fa fix typo 2026-02-01 10:16:19 -05:00
eb5d793802 update util 2026-02-01 09:59:33 -05:00
4889f1afae build static binary 2026-02-01 09:52:59 -05:00
b1ef556eb5 add command runner util 2026-01-31 16:56:18 -05:00
7fc4498dd7 makefile copies instead of moves 2026-01-28 10:21:27 -05:00
6deaf47565 fix install overwrote local bin 2026-01-28 10:19:57 -05:00
7f956d8bf0 update install to move ./fes instead of using go install
install rebuilds and makes the -V1 output devel
2026-01-28 10:18:17 -05:00
73b2ad0f11 fix root for the '.' case 2026-01-26 18:19:05 -05:00
0994a28e77 update documentation 2026-01-22 21:52:15 -05:00
1b6d27da99 Update README.md 2026-01-20 13:01:37 -05:00
87a84d6443 Update README.md 2026-01-20 13:01:13 -05:00
fedb515df1 rewrite README 2026-01-20 13:00:55 -05:00
383ba0d831 Merge branch 'rewrite' 2026-01-20 12:48:41 -05:00
af57f1a49d change the not found content 2026-01-20 12:47:58 -05:00
03af8d61c7 format 2026-01-20 12:47:44 -05:00
e36f5bc579 Merge pull request 'rewrite' (#1) from rewrite into main
Reviewed-on: #1
2026-01-19 16:17:56 -05:00
1e15e20175 add brewfile 2026-01-19 16:17:31 -05:00
81ab5b252b fix back on archive 2026-01-16 16:46:46 -05:00
746da2e21f report bug 2026-01-16 16:11:19 -05:00
e097d428b3 testing script and source 2026-01-16 16:08:18 -05:00
7e3af14059 rewrite server module 2026-01-16 16:08:10 -05:00
bfb5725880 update example 2026-01-16 16:07:54 -05:00
1ee8d2d71d remove site.lua 2026-01-16 16:07:46 -05:00
cf59c6d021 move COPYING to LICENSE 2026-01-16 16:07:33 -05:00
de69397aa5 start rewrite 2026-01-15 12:23:13 -05:00
2c2dc57453 WIP broken stage 2026-01-06 15:40:30 -05:00
85bd564164 release 0.3.0 2026-01-04 16:27:49 -05:00
0a0b1fa8c3 large changes 2026-01-04 16:27:39 -05:00
608a083861 patch: fix the back option for archives 2026-01-03 16:51:16 -05:00
50a45b6a82 update archive example 2026-01-03 16:50:33 -05:00
19752a0c89 change logging format 2026-01-03 09:53:07 -05:00
5192919645 update gitignore 2026-01-02 10:49:04 -05:00
f763f57001 rewrite Dockerfile 2026-01-02 10:06:27 -05:00
629fd06be0 exit beta 2026-01-01 23:06:30 -05:00
bedcfe781d updated logging 2026-01-01 23:05:29 -05:00
9364df2645 new year bump 2025-12-31 13:09:37 -05:00
4eaead6abc hint for running project 2025-12-31 12:16:59 -05:00
4abf2969ca new hint ui function 2025-12-31 12:16:51 -05:00
1c229f1b3e update examples 2025-12-28 20:17:46 -05:00
5a733b8642 create default site 2025-12-28 17:07:08 -05:00
11ab1630be maint: annotate source code 2025-12-28 16:39:33 -05:00
5fabd0233d fix: print usage to err instead of out 2025-12-28 16:38:40 -05:00
50 changed files with 1781 additions and 1199 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -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

@@ -8,12 +8,12 @@ deps:
$(GO) mod download $(GO) mod download
build: deps build: deps
$(GO) build -ldflags "-X fes/modules/version.gitCommit=$(shell git rev-parse --short HEAD)" -o fes CGO_ENABLED=0 $(GO) build -trimpath -ldflags "-X fes/modules/version.gitCommit=$(shell git rev-parse --short HEAD) -s -w -buildid=" -o fes
@echo "Fes is now built to ./fes" @echo "Fes is now built to ./fes"
lint: lint:
$(GO) vet ./... $(GO) vet ./...
$(GO) fmt ./... $(GO) fmt ./...
install: install: build
$(GO) install fes @cp fes /usr/local/bin

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

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

View File

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

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,5 +0,0 @@
[app]
name = "hello"
version = "0.0.1"
authors = ["vx-clutch"]

View File

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

@@ -1,7 +0,0 @@
services:
hello:
image: git.vxserver.dev/fsd/fes:latest
ports:
- "3000:3000"
volumes:
- ./app:/app

View File

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

View File

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

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

View File

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

View File

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

View File

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

3
go.mod
View File

@@ -5,7 +5,6 @@ go 1.25.4
require ( require (
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/yuin/gopher-lua v1.1.1 github.com/yuin/gopher-lua v1.1.1
) )
@@ -13,5 +12,5 @@ require (
require ( 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
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.32.0 // indirect
) )

6
go.sum
View File

@@ -7,8 +7,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
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=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
@@ -16,5 +14,5 @@ github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

View File

@@ -276,10 +276,6 @@ return site</code></pre>
<td><code>-p &lt;port&gt;</code></td> <td><code>-p &lt;port&gt;</code></td>
<td>Set the server port.</td> <td>Set the server port.</td>
</tr> </tr>
<tr>
<td><code>-static</code></td>
<td>Render and save all pages. (this feature is yet to be implemented)</td>
</tr>
<tr> <tr>
<td><code>-version</code></td> <td><code>-version</code></td>
<td>Print the version.</td> <td>Print the version.</td>
@@ -305,7 +301,7 @@ return site</code></pre>
<td>Generate a site object</td> <td>Generate a site object</td>
</tr> </tr>
<tr> <tr>
<td><code>:custom()</code></td> <td><code>:g()</code></td>
<td>Add a custom string to the site body</td> <td>Add a custom string to the site body</td>
</tr> </tr>
<tr> <tr>
@@ -323,30 +319,6 @@ return site</code></pre>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr>
<td><code>std.fes_version()</code></td>
<td>Get the current version of fes.</td>
</tr>
<tr>
<td><code>std.site_version()</code></td>
<td>Get the current version of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_name()</code></td>
<td>Get the current name of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_title()</code></td>
<td>Get the current name of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.site_authors()</code></td>
<td>Get a table of the authors of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr>
<td><code>std.join</code></td>
<td>Get a table of the authors of the site, defined in <code>Fes.toml</code>.</td>
</tr>
<tr> <tr>
<td><code>std.a(link: string, str: string)</code></td> <td><code>std.a(link: string, str: string)</code></td>
<td>Returns an anchor tag.</td> <td>Returns an anchor tag.</td>
@@ -448,7 +420,7 @@ return site</code></pre>
<td>Returns a div of class <code>class</code> with content of <code>content</code>.</td> <td>Returns a div of class <code>class</code> with content of <code>content</code>.</td>
</tr> </tr>
<tr> <tr>
<td><code>std.spa(content: string, class: string)</code></td> <td><code>std.span(content: string, class: string)</code></td>
<td>Returns a span of class <code>class</code> with content of <code>content</code>.</td> <td>Returns a span of class <code>class</code> with content of <code>content</code>.</td>
</tr> </tr>
<tr> <tr>
@@ -486,15 +458,179 @@ return site</code></pre>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><code>symbol.copyright</code></td> <td><code>symbol.legal.copyright</code></td>
<td>&#169;</td> </tr> <td>&copy;</td> </tr>
<tr> <tr>
<td><code>Registered Trademark</code></td> <td><code>symbol.legal.registered_trademark</code></td>
<td>&#174;</td> <td>&reg;</td>
</tr> </tr>
<tr> <tr>
<td><code>Trademark</code></td> <td><code>symbol.legal.trademark</code></td>
<td>&#8482;</td> <td>&trade;</td>
</tr>
<tr>
<td><code>symbol.currency.euro</code></td>
<td>&euro;</td>
</tr>
<tr>
<td><code>symbol.currency.pound</code></td>
<td>&pound;</td>
</tr>
<tr>
<td><code>symbol.currency.yen</code></td>
<td>&yen;</td>
</tr>
<tr>
<td><code>symbol.currency.cent</code></td>
<td>&cent;</td>
</tr>
<tr>
<td><code>symbol.currency.dollar</code></td>
<td>$</td>
</tr>
<tr>
<td><code>symbol.currency.plus_minus</code></td>
<td>&plusmn;</td>
</tr>
<tr>
<td><code>symbol.currency.multiply</code></td>
<td>&times;</td>
</tr>
<tr>
<td><code>symbol.currency.divide</code></td>
<td>&divide;</td>
</tr>
<tr>
<td><code>symbol.currency.not_equal</code></td>
<td>&ne;</td>
</tr>
<tr>
<td><code>symbol.currency.less_equal</code></td>
<td>&le;</td>
</tr>
<tr>
<td><code>symbol.currency.greater_equal</code></td>
<td>&ge;</td>
</tr>
<tr>
<td><code>symbol.currency.infinity</code></td>
<td>&infin;</td>
</tr>
<tr>
<td><code>symbol.currency.approx</code></td>
<td>&asymp;</td>
</tr>
<tr>
<td><code>symbol.arrows.left</code></td>
<td>&larr;</td>
</tr>
<tr>
<td><code>symbol.arrows.right</code></td>
<td>&rarr;</td>
</tr>
<tr>
<td><code>symbol.arrows.up</code></td>
<td>&uarr;</td>
</tr>
<tr>
<td><code>symbol.arrows.down</code></td>
<td>&darr;</td>
</tr>
<tr>
<td><code>symbol.arrows.left_right</code></td>
<td>&harr;</td>
</tr>
<tr>
<td><code>symbol.punctuation.left_double_quote</code></td>
<td>&ldquo;</td>
</tr>
<tr>
<td><code>symbol.punctuation.right_double_quote</code></td>
<td>&rdquo;</td>
</tr>
<tr>
<td><code>symbol.punctuation.left_single_quote</code></td>
<td>&lsquo;</td>
</tr>
<tr>
<td><code>symbol.punctuation.right_single_quote</code></td>
<td>&rsquo;</td>
</tr>
<tr>
<td><code>symbol.punctuation.ellipsis</code></td>
<td>&hellip;</td>
</tr>
<tr>
<td><code>symbol.punctuation.em_dash</code></td>
<td>&mdash;</td>
</tr>
<tr>
<td><code>symbol.punctuation.en_dash</code></td>
<td>&ndash;</td>
</tr>
<tr>
<td><code>symbol.whitespace.non_breaking</code></td>
<td>&nbsp;</td>
</tr>
<tr>
<td><code>symbol.whitespace.thin</code></td>
<td>&thinsp;</td>
</tr>
<tr>
<td><code>symbol.symbols.degree</code></td>
<td>&deg;</td>
</tr>
<tr>
<td><code>symbol.symbols.micro</code></td>
<td>&micro;</td>
</tr>
<tr>
<td><code>symbol.symbols.section</code></td>
<td>&sect;</td>
</tr>
<tr>
<td><code>symbol.symbols.paragraph</code></td>
<td>&para;</td>
</tr>
<tr>
<td><code>symbol.symbols.check</code></td>
<td>&check;</td>
</tr>
<tr>
<td><code>symbol.symbols.cross</code></td>
<td>&cross;</td>
</tr>
<tr>
<td><code>symbol.symbols.bullet</code></td>
<td>&bull;</td>
</tr>
<tr>
<td><code>symbol.symbols.middle_dot</code></td>
<td>&middot;</td>
</tr>
<tr>
<td><code>symbol.symbols.broken_bar</code></td>
<td>&brvbar;</td>
</tr>
<tr>
<td><code>symbol.html.less_than</code></td>
<td>&lt;</td>
</tr>
<tr>
<td><code>symbol.html.greater_than</code></td>
<td>&gt;</td>
</tr>
<tr>
<td><code>symbol.html.ampersand</code></td>
<td>&amp;</td>
</tr>
<tr>
<td><code>symbol.html.double_quote</code></td>
<td>&quot;</td>
</tr>
<tr>
<td><code>symbol.html.single_quote</code></td>
<td>&apos;</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -514,6 +650,14 @@ return site</code></pre>
<td><code>util.copyright(link: string, holder: string)</code></td> <td><code>util.copyright(link: string, holder: string)</code></td>
<td>Used when setting the website copyright holder.</td> <td>Used when setting the website copyright holder.</td>
</tr> </tr>
<tr>
<td><code>util.ls(dir)</code></td>
<td>Returns a table of the files in the given directory</td>
</tr>
<tr>
<td><code>util.run(cmd)</code></td>
<td>Returns the output of the provided command. stdout only.</td>
</tr>
</tbody> </tbody>
</table> </table>
<h3>Dkjson</h3> <h3>Dkjson</h3>
@@ -553,28 +697,6 @@ return site</code></pre>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h3>Site</h3>
<table> <thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>site.version</code></td>
<td>The version of the website found in the Fes.toml</td>
</tr>
<tr>
<td><code>site.name</code></td>
<td>The name of the website found in the Fes.toml</td>
</tr>
<tr>
<td><code>site.authors</code></td>
<td>A table of all authors defined in Fes.toml</td>
</tr>
</tbody>
</table>
<h3>App</h3> <h3>App</h3>
Fes's <code>app</code> module is a special table Fes's <code>app</code> module is a special table
because it contains user defined functions. These because it contains user defined functions. These
@@ -627,7 +749,7 @@ return hello</pre></code> This can be called from another with,
</section> </section>
<footer> <footer>
<p>Last updated: 2025-12-27</p> <p>Last updated: 2026-01-22</p>
</footer> </footer>
</main> </main>
</body> </body>

View File

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

View File

@@ -1,214 +1,150 @@
local M = {} local M = {}
function M.fes_version() M.proto = "http"
local fes_mod = package.loaded.fes M.__fes_banner_set = false
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 local function isHttp()
end -- return M.proto == "http"
return "" return false
end end
function M.site_version() local function isGemini()
local fes_mod = package.loaded.fes -- return M.proto == "gemini"
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.version then return true
return fes_mod.config.site.version
end
return ""
end end
function M.a(link, str) M.p = function(s)
link = link or "https://example.com" s = s or ""
str = str or link if isHttp() then
return '<a href="' .. link .. '">' .. str .. "</a>" return "<p>" .. s .. "</p>"
elseif isGemini() then
return s
end
end end
function M.ha(link, str) M.h = function(level, s)
link = link or "https://example.com" level = tonumber(level) or 1
str = str or link if level < 1 then level = 1 end
return '<a class="hidden" href="' .. link .. '">' .. str .. "</a>" if level > 6 then level = 6 end
s = s or ""
if isHttp() then
return "<h" .. level .. ">" .. s .. "</h" .. level .. ">"
elseif isGemini() then
return "\n" .. string.rep("#", level) .. " " .. s .. "\n"
end
end end
function M.external(link, str) M.codeblock = function(s)
return '<a target="_blank" href="' .. link .. '">' .. str .. "</a>" s = s or ""
if isHttp() then
return "<pre><code>" .. s .. "</code></pre>"
elseif isGemini() then
return "```\n" .. s .. "\n```"
end
end end
function M.note(str) M.inline = function(s)
return '<div class="note">' .. str .. "</div>" s = s or ""
if isHttp() then
return "<code>" .. s .. "</code>"
elseif isGemini() then
return "`" .. s .. "`"
end
end end
function M.muted(str) M.link = function(url, text)
return '<div class="muted">' .. str .. "</div>" url = url or ""
text = text or url
if isHttp() then
return "<a href=\"" .. url .. "\">" .. text .. "</a>"
elseif isGemini() then
return "=> " .. url .. " " .. text
end
end end
function M.callout(str) M.list = function(items, ordered)
return '<div class="callout">' .. str .. "</div>"
end
function M.h1(str)
return "<h1>" .. (str or "") .. "</h1>"
end
function M.h2(str)
return "<h2>" .. (str or "") .. "</h2>"
end
function M.h3(str)
return "<h3>" .. (str or "") .. "</h3>"
end
function M.h4(str)
return "<h4>" .. (str or "") .. "</h4>"
end
function M.h5(str)
return "<h5>" .. (str or "") .. "</h5>"
end
function M.h6(str)
return "<h6>" .. (str or "") .. "</h6>"
end
function M.p(str)
return "<p>" .. (str or "") .. "</p>"
end
function M.pre(str)
return "<pre>" .. (str or "") .. "</pre>"
end
function M.code(str)
return "<pre><code>" .. (str or "") .. "</code></pre>"
end
function M.ul(items)
items = items or {} items = items or {}
local html = "<ul>" if isHttp() then
for _, item in ipairs(items) do local tag = ordered and "ol" or "ul"
html = html .. "<li>" .. tostring(item) .. "</li>" local out = "<" .. tag .. ">"
for _, v in ipairs(items) do
out = out .. "<li>" .. v .. "</li>"
end
out = out .. "</" .. tag .. ">"
return out
elseif isGemini() then
local out = {}
for i, v in ipairs(items) do
if ordered then
table.insert(out, i .. ". " .. v)
else
table.insert(out, "* " .. v)
end
end
return table.concat(out, "\n")
end end
html = html .. "</ul>"
return html
end end
function M.ol(items) M.blockquote = function(s)
items = items or {} s = s or ""
local html = "<ol>" if isHttp() then
for _, item in ipairs(items) do return "<blockquote>" .. s .. "</blockquote>"
html = html .. "<li>" .. tostring(item) .. "</li>" elseif isGemini() then
return "> " .. string.gsub(s, "\n", "\n> ")
end end
html = html .. "</ol>"
return html
end end
function M.tl(items) M.rule = function()
items = items or {} if isHttp() then
local html = '<ul class="tl">' return "<hr />"
for _, item in ipairs(items) do elseif isGemini() then
html = html .. "<li>" .. tostring(item) .. "</li>" return "---"
end end
html = html .. "</ul>"
return html
end end
function M.blockquote(str) M.image = function(alt, src)
return "<blockquote>" .. (str or "") .. "</blockquote>"
end
function M.hr()
return "<hr>"
end
function M.img(src, alt)
src = src or ""
alt = alt or "" alt = alt or ""
return '<img src="' .. src .. '" alt="' .. alt .. '">' src = src or ""
if isHttp() then
return "<img src=\"" .. src .. "\" alt=\"" .. alt .. "\" />"
elseif isGemini() then
return "=> " .. src .. " " .. alt
end
end end
function M.strong(str) M.file = function(text, url)
return "<strong>" .. (str or "") .. "</strong>" text = text or ""
url = url or ""
if isHttp() then
return "<a href=\"" .. url .. "\" download>" .. text .. "</a>"
elseif isGemini() then
return "=> " .. url .. " " .. text
end
end end
function M.em(str) M.note = function(text)
return "<em>" .. (str or "") .. "</em>" text = text or ""
if isHttp() then
return "<div class=\"note\">" .. text .. "</div>"
elseif isGemini() then
return "\n" .. text .. "\n"
end
end end
function M.br() M.banner = function (text)
return "<br>" text = text or ""
end
function M.div(content, class) if M.__fes_banner_set then
content = content or "" error("Page already contains header")
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<div" .. class_attr .. ">" .. content .. "</div>"
end
function M.span(content, class)
content = content or ""
class = class or ""
local class_attr = class ~= "" and (' class="' .. class .. '"') or ""
return "<span" .. class_attr .. ">" .. content .. "</span>"
end
-- HTML escaping utility
function M.escape(str)
str = tostring(str or "")
str = str:gsub("&", "&amp;")
str = str:gsub("<", "&lt;")
str = str:gsub(">", "&gt;")
str = str:gsub('"', "&quot;")
str = str:gsub("'", "&#39;")
return str
end
-- Get site name from config
function M.site_name()
local fes_mod = package.loaded.fes
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.name then
return fes_mod.config.site.name
end
return "" return ""
end end
-- Get site title from config M.__fes_banner_set = true
function M.site_title() if isHttp() then
local fes_mod = package.loaded.fes return "<div class=\"banner\">" .. text .. "</div>"
if fes_mod and fes_mod.config and fes_mod.config.site and fes_mod.config.site.title then elseif isGemini() then
return fes_mod.config.site.title return text
end 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)
return '<span class="highlight">' .. (str or "") .. "</span>"
end
function M.banner(str)
return '<div class="banner">' .. (str or "") .. "</div>"
end
function M.center(str)
return '<div class="center">' .. (str or "") .. "</div>"
end
function M.nav(link, str)
link = link or "example.com"
str = str or link
return '<a class="nav" href="' .. link .. '">' .. str .. "</a>"
end
function M.rl(r, l)
r = r or ""
l = l or ""
return string.format('<span class="left">%s</span><span class="right">%s</span>', r, l)
end 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

@@ -1,14 +1,31 @@
local std = require("lib.std")
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 end
function M.copyright(link, holder) function M.ls(dir)
return symbol.copyright .. " " .. std.external(link, holder) 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
function M.run(cmd)
cmd = cmd or "echo nil"
local p = io.popen(cmd)
if not p then
return nil
end
local r = p:read("*a"):gsub("%s+$", "")
p:close()
return r
end end
return M return M

37
main.go
View File

@@ -2,10 +2,10 @@ package main
import ( import (
"embed" "embed"
"errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"runtime"
"github.com/fatih/color" "github.com/fatih/color"
@@ -13,6 +13,7 @@ import (
"fes/modules/doc" "fes/modules/doc"
"fes/modules/new" "fes/modules/new"
"fes/modules/server" "fes/modules/server"
"fes/modules/ui"
"fes/modules/version" "fes/modules/version"
) )
@@ -29,18 +30,22 @@ func init() {
config.Docker = flag.Bool("docker", false, "Create a docker project") config.Docker = flag.Bool("docker", false, "Create a docker project")
config.Lib = lib config.Lib = lib
config.Doc = documentation config.Doc = documentation
config.Verbose = flag.Bool("verbose", false, "Enable verbose logging")
config.Proto = flag.String("proto", "", "Force protocol")
} }
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")
@@ -89,16 +94,16 @@ func main() {
os.Exit(1) os.Exit(1)
} }
case "run": case "run":
if err := server.Start(dir); err != nil { ui.Log("Fes is starting")
if errors.Is(err, os.ErrNotExist) { if *config.Port == 3000 {
fmt.Fprintf(os.Stderr, "%s does not exist\n", dir) ui.WARNING("Using default port, this may lead to conflicts with other services")
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 version=%s, commit=%s, just started", version.VERSION, version.GetCommit())
runtime.ReadMemStats(&m)
ui.Log("FRE memory usage when created %v Mb", m.TotalAlloc/1024/1024)
server.Start(dir)
default: default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd) fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
flag.Usage() flag.Usage()

View File

@@ -11,6 +11,8 @@ var Port *int
var Color *bool var Color *bool
var Static *bool var Static *bool
var Docker *bool var Docker *bool
var Verbose *bool
var Proto *string
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")

196
modules/gemini/gemini.go Normal file
View File

@@ -0,0 +1,196 @@
package gemini
import (
"bufio"
"crypto/tls"
"net"
"net/url"
"strings"
"sync"
)
const (
StatusSuccess = 20
StatusTemporaryFail = 40
StatusPermanentFail = 50
StatusNotFound = 51
StatusBadRequest = 59
)
type Handler interface {
ServeGemini(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeGemini(w ResponseWriter, r *Request) {
f(w, r)
}
type ServeMux struct {
mu sync.RWMutex
handlers map[string]Handler
}
func NewServeMux() *ServeMux {
return &ServeMux{
handlers: make(map[string]Handler),
}
}
func (m *ServeMux) Handle(pattern string, h Handler) {
m.mu.Lock()
m.handlers[pattern] = h
m.mu.Unlock()
}
func (m *ServeMux) HandleFunc(pattern string, f func(ResponseWriter, *Request)) {
m.Handle(pattern, HandlerFunc(f))
}
func (m *ServeMux) ServeGemini(w ResponseWriter, r *Request) {
m.mu.RLock()
defer m.mu.RUnlock()
path := r.URL.Path
for pattern, handler := range m.handlers {
if strings.HasPrefix(path, pattern) {
handler.ServeGemini(w, r)
return
}
}
w.WriteHeader(StatusNotFound, "not found")
}
var DefaultServeMux = NewServeMux()
func Handle(pattern string, h Handler) {
DefaultServeMux.Handle(pattern, h)
}
func HandleFunc(pattern string, f func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, f)
}
type Request struct {
URL *url.URL
Conn net.Conn
}
type ResponseWriter interface {
Write([]byte) (int, error)
WriteHeader(status int, meta string)
}
type response struct {
conn net.Conn
wroteHeader bool
}
func (r *response) WriteHeader(status int, meta string) {
if r.wroteHeader {
return
}
r.conn.Write([]byte(
strings.TrimSpace(
strings.Join([]string{
strings.TrimSpace(
strings.Join([]string{
string(rune(status/10 + '0')),
string(rune(status%10 + '0')),
}, ""),
),
meta,
}, " "),
) + "\r\n"))
r.wroteHeader = true
}
func (r *response) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(StatusSuccess, "text/gemini")
}
return r.conn.Write(b)
}
type Server struct {
Addr string
Handler Handler
TLSConfig *tls.Config
}
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error {
handler := s.Handler
if handler == nil {
handler = DefaultServeMux
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return err
}
cfg := s.TLSConfig
if cfg == nil {
cfg = &tls.Config{}
}
cfg.Certificates = []tls.Certificate{cert}
ln, err := tls.Listen("tcp", s.Addr, cfg)
if err != nil {
return err
}
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go s.serve(conn, handler)
}
}
func (s *Server) serve(conn net.Conn, handler Handler) {
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return
}
if len(line) > 1026 {
conn.Write([]byte("59 request too long\r\n"))
return
}
line = strings.TrimSpace(line)
u, err := url.Parse(line)
if err != nil {
conn.Write([]byte("59 bad request\r\n"))
return
}
req := &Request{
URL: u,
Conn: conn,
}
rw := &response{
conn: conn,
}
handler.ServeGemini(rw, req)
}
func ListenAndServeTLS(addr, certFile, keyFile string, h Handler) error {
s := &Server{
Addr: addr,
Handler: h,
}
return s.ListenAndServeTLS(certFile, keyFile)
}

View File

@@ -2,6 +2,7 @@ package new
import ( import (
"fes/modules/config" "fes/modules/config"
"fes/modules/ui"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -73,14 +74,9 @@ local site = fes.fes()
-- site.copyright = fes.util.copyright("https://example.com", "%s") -- site.copyright = fes.util.copyright("https://example.com", "%s")
site:h1("Hello, World!") site:h(1, "Hello, World!")
return site`, name) return site`, name)
write("Fes.toml", `[app]
name = "%s"
version = "0.0.1"
authors = ["%s"]`, dir, name)
write("README.md", strings.ReplaceAll(`# %s write("README.md", strings.ReplaceAll(`# %s
$$$$$$ $$$$$$
@@ -111,8 +107,120 @@ All commands are run from the root of the project, from a terminal:
| :------------------------ | :----------------------------------------------- | | :------------------------ | :----------------------------------------------- |
| $$fes run .$$ | Runs the project at $$.$$ | | $$fes run .$$ | Runs the project at $$.$$ |
## Gemini
Fes supports many different protocols. One of which is the Gemini protocol; this
protocol has specific encryption standards which makes the providing $$key.pem$$
and a $$cert.pem$$ files. *Please* regenerate these files by running this command.
$$$$$$
openssl req -x509 -nodes -days 365 \
-newkey rsa:2048 \
-keyout key.pem \
-out cert.pem \
-subj "/C=US/ST=State/L=City/O=Local Development/OU=Localhost/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1" \
-addext "basicConstraints=CA:FALSE" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth"
$$$$$$
## What to learn more? ## What to learn more?
Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).`, "$$", "`"), dir, dir) Check out [Fes's docs](https://docs.vxserver.dev/static/fes.html).`, "$$", "`"), dir, dir)
write("key.pem", `-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCfl+m81U9+WLBn
PiJ1ZADnZC+BecXArguAdnbkgDlmfqe1eU6M0PBmXXFHEvJYOh2oYQZ7DMbuLLvo
DdjomXM4yb9Axu2KDHfflTcw3wHD3850ganf9rwVG460gkVfUGaRiokyCvEJKd05
BxKmx8Zh6G2tpetaDKm72ONYQFyaBCelYMzaBRpJ2kcPqk/gEhUSvQKVF4oDREm+
sTEkCTWsjUesiG0393t3psFa/SlZKsNXNrb+N6y9SElKVlQOT28r9YGiDfy5PiAQ
iFtQse2gqC4IhG2MGi0b4mu4Qa3+d2u3jsmn6Gqt92hKrbFOpz+Ci5GQ+zKXnE+j
/h40l1J3h/aTM5/qS7malAtXySMVzLQnJzai6IL+nBKbIOMCzP1Bns64Oo7YuuhV
Cx/sovrvgFF+8j4nLXOBgrx8llYB5cvgVYkNmccXtPzd7hZI6PHgZRGOd0uBKlZ+
OHeKhgdnz/a+pCUBwJe8JAzX7fzoZ+HLy26ADx2Uywh0Rjfj4gVOSZXaGS0ne0p5
PciN0xsWQdaAWPcVe6+YYRKS6l6qaWuGaPwVsZSvNgIMMPOgUn7+s2Bxh51DhqZT
4MDFUJWXFmTnyhHg3Cq7pWJR7pv2erv5q32b8iah5o+CP4W3FeZX1CcBuiIsrimh
vZZIa5W2vzpkLHbWgI2XGbx8yKRXowIDAQABAoICAENZb5lyB5cRRHh9XztdFYiQ
3f9s7UhP6qiu1aO+fPrFDm9mHwEMF7eLTYep9j3HYMazE3IQRU8z76SRW21lfJuF
gEGM8aeldV0UcnMcWXlY1J6ULaVHUb4yn/mLVE1R98cJyLYmqeutEB/F3VgmzJB6
7vYuI/EfkO2mLOMMXkfc4wJGpIyJRLvP8tcoj4bG+r+qphFXGrYgNmLUEiHcBRup
j4q/FCBfP2qSI90LI0zu3/rJK1aDFlHW1J8baWOUoBzUAX3rGzDth8iSUr7uJ5L6
Blsvz68lSM4QslbS2OOfcATJrE5Apex+kTOas023BPVJgwfFCmey3mUdk4+sIG+I
YpvrfUHBAbhRt1itGjjx/BH3HVtkwKsm3nNNgE8qS0qmziGVa60amLng0OgD4SOH
QKWrotcojYlWHywdHNAmiaWP+ZV9U0u4T59dkHhjqBoPuY64kNddRCLYItszqOmC
tamnflcoFPThdBnDpnXhLX+MnvQKIO2K2QTKjdYrkSMvEC/m96H49Vf4w+Fe3coc
SLsyMqHmYSG3+QtFN3JSmqDdBLQ+bFK0KZOk3IE2UK+lvabmtHuGLkYguur/fOnG
FvxcC+MIHHFrtSfqfvIrXeGFqe2Fb6DIKdMJFPV8iP5Px91A0GAGeabyktyya1n5
kk5nMJY3A7rTmnDO2PUBAoIBAQDSd+pvNGDf/We74+NODKQBcleCfF0NNzvRIuYH
9vel5jhyD28Y30XQS7+YBo3Y5Yo5pVlJ425Z5CveZOmr4VImuYG8LTbbGYY8ns3c
R/IdP1ByAcIa1eRGQNcKHid7RW4aEoFdvW3jZNj6/ukbJwd9ZHOt4HEUv5MOObn7
VXiXiwHjVy3X4uD5j0bHS4owd7S3Ygg0OneUzX3XJ+8qUgfU9B+lD3SbtsI4baoU
qUKcQc/0yXKvJvoz9oX2tjQOHqGrGwHdootc2CLnr0PMI7fKBwT3k63B+24xEGcx
OAPc5tUuiqIewiCn1oZbeCxmNnnimkAjDfG2KXMXj1R1G96BAoIBAQDCHnTNQhXH
tcfwnxj86E+Zd7ORS7iH/5eWvnspt2pnNCpufLFQlbdiL3d9ysSN9nw3+y15/o83
R79s7fLdC0+blc6q/ZQEdB1C+8YQa0nTlqqlZWVbRPjQUV+SfGssaZFEO4W7QWh3
hJL14HbWUFC3XSdMAeKTNOcgvfVvQ9M8HlJI1ZPo4Pe4rGNFIEsKUz/6HVE7HFgQ
OwcLSxZlSQSQ6/YErP/XJoyQUPjHldAjphV/rn28cvh2quoI35fbBuZu5pyKLXTg
RKbdWbeoIZTthRojpqnfqnIR+rPXIIiaMYhStdPshNUxLL7DX+/3yI05qq8R+yqa
2gLb2mJltuwjAoIBAF0WQI/ywK4Q7CKEBnLs0FT7d4z06EsCFOjI4KjBKIMtseVw
whhkGAKqnhDlRTObQmmAol81wgbsDiMMyvUEcUtDXQgXj12UinShYDd/cqxQ5omm
EW3BEHeqEfIdqCSzbqEFckY9lC6w2e8Zc4xY1M028psC28DrgmUWTxXEldOg3bLp
ShNj+1Eld46J8JLDPyCksTA4c89Sm8ffl75GDcS4PI7KqS59xKUki8cbnaRyz0Fb
H+gr+xmkfVfC+n8MOUDubwLR84Wa6sVCFWBio9UtCZteq8lSJUh6EsoIFl1LkxpE
orOr9LmG/mHSYwDKM1pwEtHuRuvkpUzUTeyF6QECggEAbm/vWZtgUsdbocyR5cix
CImuUlo2+MBz2KIz5c7grShjf4pXQpZ6x1Rj8d/7JRz3HM482Cv4BKZABNP3GMTH
nKeE9YjgvgvlXedpjovLa6JLIV/nYx6BQ9sXuXopaxIAQEZw1dDngx+ckGAMm+8D
jN5lbfugkMlHOTx5Nrzqn0hM3f0McjATHzCMJZayuoQUYNJvFWcRvuImJsmoSyVY
gK6Nv6lAwIHA9JXsg3f6+10Q3BxEkoMCUlj4XuX+OfDaBnwS0RX9aV4FZOcW8oNw
fBT+gwvdl08cKJht2lU7AiZt/UhO8j+8HobrXLHnDxw9JHKzuVIgsgqYF8ZNtrpz
6wKCAQEAqJc9xfNYL2jyJTVBvqX34Uct9xV1fq7z45B3gpPHj4tc0dBOIwPKSsu8
rGyefPuMDvbecJoaXqRZGQEcv03ecLVtHHDCsJcWavth9sCQJW9mnOxjOnLHdGnl
h2yIV4iVzNEx5XA1+0JRAAwFMXrC9LUZDTT61opjohMJSEySo/HVekoWG9A0bQGn
Ui+JbtLeuuCwwAGSgdAhVkjr27ANyFtjR63KVrDvSLbYFyZXKxrRYnZWs92Ho/g6
oTLnUSmkbLlhsUDjknAaNfp+enETSoPHcpkd7MeqVw2oYrLw6MCo4h2tBsIgtZEd
h6CgX4Gc3+bhvKx36XYxVwScPJUWsQ==
-----END PRIVATE KEY-----`)
write("cert.pem", `-----BEGIN CERTIFICATE-----
MIIFmzCCA4OgAwIBAgIUZGaMDLeCAQplfhhjXb2HaL6xpFYwDQYJKoZIhvcNAQEL
BQAwXTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
MQwwCgYDVQQKDANPcmcxDTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9z
dDAeFw0yNjAyMjQwMjUwMzVaFw0yNzAyMjQwMjUwMzVaMF0xCzAJBgNVBAYTAlVT
MQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEMMAoGA1UECgwDT3JnMQ0w
CwYDVQQLDARVbml0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEB
AQUAA4ICDwAwggIKAoICAQCfl+m81U9+WLBnPiJ1ZADnZC+BecXArguAdnbkgDlm
fqe1eU6M0PBmXXFHEvJYOh2oYQZ7DMbuLLvoDdjomXM4yb9Axu2KDHfflTcw3wHD
3850ganf9rwVG460gkVfUGaRiokyCvEJKd05BxKmx8Zh6G2tpetaDKm72ONYQFya
BCelYMzaBRpJ2kcPqk/gEhUSvQKVF4oDREm+sTEkCTWsjUesiG0393t3psFa/SlZ
KsNXNrb+N6y9SElKVlQOT28r9YGiDfy5PiAQiFtQse2gqC4IhG2MGi0b4mu4Qa3+
d2u3jsmn6Gqt92hKrbFOpz+Ci5GQ+zKXnE+j/h40l1J3h/aTM5/qS7malAtXySMV
zLQnJzai6IL+nBKbIOMCzP1Bns64Oo7YuuhVCx/sovrvgFF+8j4nLXOBgrx8llYB
5cvgVYkNmccXtPzd7hZI6PHgZRGOd0uBKlZ+OHeKhgdnz/a+pCUBwJe8JAzX7fzo
Z+HLy26ADx2Uywh0Rjfj4gVOSZXaGS0ne0p5PciN0xsWQdaAWPcVe6+YYRKS6l6q
aWuGaPwVsZSvNgIMMPOgUn7+s2Bxh51DhqZT4MDFUJWXFmTnyhHg3Cq7pWJR7pv2
erv5q32b8iah5o+CP4W3FeZX1CcBuiIsrimhvZZIa5W2vzpkLHbWgI2XGbx8yKRX
owIDAQABo1MwUTAdBgNVHQ4EFgQUcyq+ZZWmAUejOPEQpHpl4hOllHswHwYDVR0j
BBgwFoAUcyq+ZZWmAUejOPEQpHpl4hOllHswDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAgEANwdjxlPHqvOOyNTFJcFhcNff3862/74CyCiglrstk/ea
txjyHxXFwXOtyAtm0tqaQPpzHWFM5MDqVD7Qip4aZrPcRvvfJ8zxXuakrgy7oI6i
Wl4/BXzvIoxh8MyFVC7VdGmuv11fq091RaNPlnz5lH9Qxhb6QCGbk73PjTXxD/7A
yusNiXuJvx3oRhLLGuksotFKEnngOq21Jla3ZPUmJTLAZdk/joXITk5Q64ZBp5uD
rpxFZDtdHTBuL3nmNPUHixsxQRFzCSloufRlatdI2ldDc+d2WLLFhkpdM35Oedf3
Hib0VXPgJ8j/w6RK+oYKMP27RojMFXALolzRBSQu3Zbd1bD1qVhh/dO5Uyn4E3mb
2LW5+a9zuabD9wt8smSU9V5ZYw5hcR0ANN7xYmaMvGFKrNPNkI3m0c2dyj22vgg0
zQqeMze4KxZqDf/gFifJ4UIybBAgc/p1uOIceFTSYplVXJQIgFACn9l6c4Zo0GZD
H48iKQLwlxDdUSM8aHtItWxRddLCL7lYNKtu2/qu8I9NKkjx4/Kn3I9Is/f37EIn
rF3ygUg3ee5zo3fvMjlhCNs6djm+ANx2KzdZkS7UU/jb0o+OSEvoMIgZh5NNgUlC
aVR3opS3PTqwu4plN0Evh5kr3nEOx0vcIK726DwnpgNRhcG9cNcSi2UB4NYCWaw=
-----END CERTIFICATE-----`)
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
} }

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

@@ -0,0 +1,117 @@
package server
import (
"errors"
"fmt"
"io"
"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 io.Writer, route string, protcol Protocols) error {
switch protcol {
case HTTP:
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
}
}
case GEMINI:
panic(errors.New("TODO"))
default:
panic(errors.New("Invalid Protocol"))
}
return nil
}

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

@@ -0,0 +1,83 @@
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 {
ui.LogVerbose("reading www/")
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 {
ui.LogVerbose("reading static/")
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 {
ui.LogVerbose("reading archive/")
if err := handleDir(entries, "archive", routes, "/archive", true); err != nil {
ui.Warning("failed to handle archive directory", err)
}
}
return routes
}

59
modules/server/gemini.go Normal file
View File

@@ -0,0 +1,59 @@
package server
import (
"fes/modules/config"
"fes/modules/gemini"
"fes/modules/ui"
"os"
"strings"
)
func geminiHandler(w gemini.ResponseWriter, r *gemini.Request) {
ui.LogVerbose("Received %s", r.URL.Path)
route, ok := Routes[r.URL.Path]
var err error = nil
/* defer won't update paramaters unless we do this. */
defer func() {
ui.Path(route, err)
}()
if !ok {
err = config.ErrRouteMiss
route = r.URL.Path
if strings.HasPrefix(route, "/archive") {
err = readArchive(w, route, GEMINI)
} else {
w.WriteHeader(gemini.StatusNotFound, "StatusNotFound")
}
return
}
params := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
var data []byte
if strings.HasSuffix(route, ".lua") {
data, err = render(route, reqData{path: r.URL.Path, params: params}, GEMINI)
} else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data)))
data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
} else {
ui.LogVerbose("serving unrecognized file")
data, err = os.ReadFile(route)
}
if err != nil {
w.WriteHeader(-1, err.Error())
}
w.Write(data)
}

67
modules/server/http.go Normal file
View File

@@ -0,0 +1,67 @@
package server
import (
"fes/modules/config"
"fes/modules/ui"
"fmt"
"net/http"
"os"
"strings"
)
func httpHandler(w http.ResponseWriter, r *http.Request) {
ui.LogVerbose("Received %s", r.URL.Path)
route, ok := Routes[r.URL.Path]
var err error = nil
/* defer won't update paramaters unless we do this. */
defer func() {
ui.Path(route, err)
}()
if !ok {
err = config.ErrRouteMiss
route = r.URL.Path
if strings.HasPrefix(route, "/archive") {
err = readArchive(w, route, HTTP)
} else {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>fes</center>
</body>
</html>`))
}
return
}
params := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
var data []byte
if strings.HasSuffix(route, ".lua") {
data, err = render(route, reqData{path: r.URL.Path, params: params}, HTTP)
} else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data)))
data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
} else {
ui.LogVerbose("serving unrecognized file")
data, err = os.ReadFile(route)
}
if err != nil {
http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError)
}
w.Write(data)
}

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

@@ -0,0 +1,152 @@
package server
import (
"fes/modules/config"
"io/fs"
"path/filepath"
"strings"
lua "github.com/yuin/gopher-lua"
)
type reqData struct {
path string
params map[string]string
}
func render(luapath string, requestData reqData, protocol Protocols) ([]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 {
_ = 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)
}
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 {
fileData, err := config.Lib.ReadFile("lib/fes.lua")
if err != nil {
panic(err)
}
if err := L.DoString(string(fileData)); err != nil {
panic(err)
}
mod := L.Get(-1)
L.Pop(1)
tbl, ok := mod.(*lua.LTable)
if !ok {
panic("fes module did not return table")
}
if err := L.CallByParam(lua.P{
Fn: L.GetGlobal("require"),
NRet: 1,
Protect: true,
}, lua.LString("lib.std")); err != nil {
panic(err)
}
stdMod := L.Get(-1)
L.Pop(1)
stdTbl, ok := stdMod.(*lua.LTable)
if !ok {
panic("lib.std did not return table")
}
proto := func() string {
switch protocol {
case HTTP:
return "http"
case GEMINI:
return "gemini"
default:
return "http"
}
}()
proto = "gemini"
stdTbl.RawSetString("proto", lua.LString(proto))
tbl.RawSetString("std", stdTbl)
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)
tbl.RawSetString("bus", bus)
tbl.RawSetString("markdown_to_html", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(markdownToHTML(L.ToString(1))))
return 1
}))
L.Push(tbl)
return 1
})
if err := L.DoFile(luapath); err != nil {
return []byte(""), err
}
if L.GetTop() == 0 {
return []byte(""), nil
}
val := L.Get(-1)
L.Pop(1)
if val == lua.LNil {
return []byte(""), nil
}
if err := L.CallByParam(lua.P{
Fn: L.GetGlobal("tostring"),
NRet: 1,
Protect: true,
}, val); err != nil {
return nil, err
}
s := L.ToString(-1)
L.Pop(1)
if s != "" {
return []byte(s), nil
}
return []byte(""), nil
}

View File

@@ -2,442 +2,76 @@ package server
import ( import (
"fes/modules/config" "fes/modules/config"
"fes/modules/gemini"
"fes/modules/ui" "fes/modules/ui"
"fmt" "fmt"
"github.com/pelletier/go-toml/v2"
lua "github.com/yuin/gopher-lua"
"html/template"
"io/fs"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sync"
"strings"
"time"
) )
type reqData struct { type Protocols int
path string
params map[string]string
}
func handleDir(entries []os.DirEntry, dir string, routes map[string]string, base string, isStatic bool) error { const (
for _, entry := range entries { HTTP Protocols = iota
path := filepath.Join(dir, entry.Name()) GEMINI
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 { var Routes map[string]string
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) { func Start(dir string) {
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() root := filepath.Clean(dir)
notFoundData := generateNotFoundData(&cfg)
routes := loadDirs()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if root == "." {
route, ok := routes[r.URL.Path] if res, err := filepath.Abs(root); err == nil {
root = filepath.Base(res)
} else {
ui.Error("failed to get absolute path", err)
}
}
var err error = nil ui.Log("running root=%s, port=%d.", root, *config.Port)
/* defer won't update paramaters unless we do this. */ ui.LogVerbose("start loading directories")
defer func() { Routes = loadDirs()
ui.Path(route, err)
http.HandleFunc("/", httpHandler)
gemini.HandleFunc("/", geminiHandler)
var wg sync.WaitGroup
errs := make(chan error, 2)
wg.Add(2)
go func() {
defer wg.Done()
errs <- http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", *config.Port), nil)
}() }()
if !ok { go func() {
err = config.ErrRouteMiss defer wg.Done()
route = r.URL.Path errs <- gemini.ListenAndServeTLS(fmt.Sprintf("0.0.0.0:%d", *config.Port-1035), "cert.pem", "key.pem", nil)
}()
if strings.HasPrefix(route, "/archive") { ui.Log("Server initialized")
readArchive(w, route) wg.Wait()
} else { close(errs)
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(notFoundData)) var collected []error
for err := range errs {
if err != nil {
collected = append(collected, err)
} }
}
if len(collected) > 0 {
fmt.Printf("errors: %v\n", collected)
return return
} }
params := make(map[string]string)
for k, v := range r.URL.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
var data []byte
if strings.HasSuffix(route, ".lua") {
data, err = loadLua(route, &cfg, reqData{path: r.URL.Path, params: params})
} else if strings.HasSuffix(route, ".md") {
data, err = os.ReadFile(route)
data = []byte(markdownToHTML(string(data)))
data = []byte("<style>body {max-width: 80ch;}</style>\n" + string(data))
} else {
data, err = os.ReadFile(route)
}
if err != nil {
http.Error(w, fmt.Sprintf("Error loading page: %v", err), http.StatusInternalServerError)
}
w.Write(data)
})
fmt.Printf("Server is running on http://localhost:%d\n", *config.Port)
return http.ListenAndServe(fmt.Sprintf("0.0.0.0:%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 = "2.0.0"
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

33
test/README.md Normal file
View File

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

32
test/cert.pem Normal file
View File

@@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFmzCCA4OgAwIBAgIUZGaMDLeCAQplfhhjXb2HaL6xpFYwDQYJKoZIhvcNAQEL
BQAwXTELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5
MQwwCgYDVQQKDANPcmcxDTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9z
dDAeFw0yNjAyMjQwMjUwMzVaFw0yNzAyMjQwMjUwMzVaMF0xCzAJBgNVBAYTAlVT
MQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEMMAoGA1UECgwDT3JnMQ0w
CwYDVQQLDARVbml0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEB
AQUAA4ICDwAwggIKAoICAQCfl+m81U9+WLBnPiJ1ZADnZC+BecXArguAdnbkgDlm
fqe1eU6M0PBmXXFHEvJYOh2oYQZ7DMbuLLvoDdjomXM4yb9Axu2KDHfflTcw3wHD
3850ganf9rwVG460gkVfUGaRiokyCvEJKd05BxKmx8Zh6G2tpetaDKm72ONYQFya
BCelYMzaBRpJ2kcPqk/gEhUSvQKVF4oDREm+sTEkCTWsjUesiG0393t3psFa/SlZ
KsNXNrb+N6y9SElKVlQOT28r9YGiDfy5PiAQiFtQse2gqC4IhG2MGi0b4mu4Qa3+
d2u3jsmn6Gqt92hKrbFOpz+Ci5GQ+zKXnE+j/h40l1J3h/aTM5/qS7malAtXySMV
zLQnJzai6IL+nBKbIOMCzP1Bns64Oo7YuuhVCx/sovrvgFF+8j4nLXOBgrx8llYB
5cvgVYkNmccXtPzd7hZI6PHgZRGOd0uBKlZ+OHeKhgdnz/a+pCUBwJe8JAzX7fzo
Z+HLy26ADx2Uywh0Rjfj4gVOSZXaGS0ne0p5PciN0xsWQdaAWPcVe6+YYRKS6l6q
aWuGaPwVsZSvNgIMMPOgUn7+s2Bxh51DhqZT4MDFUJWXFmTnyhHg3Cq7pWJR7pv2
erv5q32b8iah5o+CP4W3FeZX1CcBuiIsrimhvZZIa5W2vzpkLHbWgI2XGbx8yKRX
owIDAQABo1MwUTAdBgNVHQ4EFgQUcyq+ZZWmAUejOPEQpHpl4hOllHswHwYDVR0j
BBgwFoAUcyq+ZZWmAUejOPEQpHpl4hOllHswDwYDVR0TAQH/BAUwAwEB/zANBgkq
hkiG9w0BAQsFAAOCAgEANwdjxlPHqvOOyNTFJcFhcNff3862/74CyCiglrstk/ea
txjyHxXFwXOtyAtm0tqaQPpzHWFM5MDqVD7Qip4aZrPcRvvfJ8zxXuakrgy7oI6i
Wl4/BXzvIoxh8MyFVC7VdGmuv11fq091RaNPlnz5lH9Qxhb6QCGbk73PjTXxD/7A
yusNiXuJvx3oRhLLGuksotFKEnngOq21Jla3ZPUmJTLAZdk/joXITk5Q64ZBp5uD
rpxFZDtdHTBuL3nmNPUHixsxQRFzCSloufRlatdI2ldDc+d2WLLFhkpdM35Oedf3
Hib0VXPgJ8j/w6RK+oYKMP27RojMFXALolzRBSQu3Zbd1bD1qVhh/dO5Uyn4E3mb
2LW5+a9zuabD9wt8smSU9V5ZYw5hcR0ANN7xYmaMvGFKrNPNkI3m0c2dyj22vgg0
zQqeMze4KxZqDf/gFifJ4UIybBAgc/p1uOIceFTSYplVXJQIgFACn9l6c4Zo0GZD
H48iKQLwlxDdUSM8aHtItWxRddLCL7lYNKtu2/qu8I9NKkjx4/Kn3I9Is/f37EIn
rF3ygUg3ee5zo3fvMjlhCNs6djm+ANx2KzdZkS7UU/jb0o+OSEvoMIgZh5NNgUlC
aVR3opS3PTqwu4plN0Evh5kr3nEOx0vcIK726DwnpgNRhcG9cNcSi2UB4NYCWaw=
-----END CERTIFICATE-----

52
test/key.pem Normal file
View File

@@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCfl+m81U9+WLBn
PiJ1ZADnZC+BecXArguAdnbkgDlmfqe1eU6M0PBmXXFHEvJYOh2oYQZ7DMbuLLvo
DdjomXM4yb9Axu2KDHfflTcw3wHD3850ganf9rwVG460gkVfUGaRiokyCvEJKd05
BxKmx8Zh6G2tpetaDKm72ONYQFyaBCelYMzaBRpJ2kcPqk/gEhUSvQKVF4oDREm+
sTEkCTWsjUesiG0393t3psFa/SlZKsNXNrb+N6y9SElKVlQOT28r9YGiDfy5PiAQ
iFtQse2gqC4IhG2MGi0b4mu4Qa3+d2u3jsmn6Gqt92hKrbFOpz+Ci5GQ+zKXnE+j
/h40l1J3h/aTM5/qS7malAtXySMVzLQnJzai6IL+nBKbIOMCzP1Bns64Oo7YuuhV
Cx/sovrvgFF+8j4nLXOBgrx8llYB5cvgVYkNmccXtPzd7hZI6PHgZRGOd0uBKlZ+
OHeKhgdnz/a+pCUBwJe8JAzX7fzoZ+HLy26ADx2Uywh0Rjfj4gVOSZXaGS0ne0p5
PciN0xsWQdaAWPcVe6+YYRKS6l6qaWuGaPwVsZSvNgIMMPOgUn7+s2Bxh51DhqZT
4MDFUJWXFmTnyhHg3Cq7pWJR7pv2erv5q32b8iah5o+CP4W3FeZX1CcBuiIsrimh
vZZIa5W2vzpkLHbWgI2XGbx8yKRXowIDAQABAoICAENZb5lyB5cRRHh9XztdFYiQ
3f9s7UhP6qiu1aO+fPrFDm9mHwEMF7eLTYep9j3HYMazE3IQRU8z76SRW21lfJuF
gEGM8aeldV0UcnMcWXlY1J6ULaVHUb4yn/mLVE1R98cJyLYmqeutEB/F3VgmzJB6
7vYuI/EfkO2mLOMMXkfc4wJGpIyJRLvP8tcoj4bG+r+qphFXGrYgNmLUEiHcBRup
j4q/FCBfP2qSI90LI0zu3/rJK1aDFlHW1J8baWOUoBzUAX3rGzDth8iSUr7uJ5L6
Blsvz68lSM4QslbS2OOfcATJrE5Apex+kTOas023BPVJgwfFCmey3mUdk4+sIG+I
YpvrfUHBAbhRt1itGjjx/BH3HVtkwKsm3nNNgE8qS0qmziGVa60amLng0OgD4SOH
QKWrotcojYlWHywdHNAmiaWP+ZV9U0u4T59dkHhjqBoPuY64kNddRCLYItszqOmC
tamnflcoFPThdBnDpnXhLX+MnvQKIO2K2QTKjdYrkSMvEC/m96H49Vf4w+Fe3coc
SLsyMqHmYSG3+QtFN3JSmqDdBLQ+bFK0KZOk3IE2UK+lvabmtHuGLkYguur/fOnG
FvxcC+MIHHFrtSfqfvIrXeGFqe2Fb6DIKdMJFPV8iP5Px91A0GAGeabyktyya1n5
kk5nMJY3A7rTmnDO2PUBAoIBAQDSd+pvNGDf/We74+NODKQBcleCfF0NNzvRIuYH
9vel5jhyD28Y30XQS7+YBo3Y5Yo5pVlJ425Z5CveZOmr4VImuYG8LTbbGYY8ns3c
R/IdP1ByAcIa1eRGQNcKHid7RW4aEoFdvW3jZNj6/ukbJwd9ZHOt4HEUv5MOObn7
VXiXiwHjVy3X4uD5j0bHS4owd7S3Ygg0OneUzX3XJ+8qUgfU9B+lD3SbtsI4baoU
qUKcQc/0yXKvJvoz9oX2tjQOHqGrGwHdootc2CLnr0PMI7fKBwT3k63B+24xEGcx
OAPc5tUuiqIewiCn1oZbeCxmNnnimkAjDfG2KXMXj1R1G96BAoIBAQDCHnTNQhXH
tcfwnxj86E+Zd7ORS7iH/5eWvnspt2pnNCpufLFQlbdiL3d9ysSN9nw3+y15/o83
R79s7fLdC0+blc6q/ZQEdB1C+8YQa0nTlqqlZWVbRPjQUV+SfGssaZFEO4W7QWh3
hJL14HbWUFC3XSdMAeKTNOcgvfVvQ9M8HlJI1ZPo4Pe4rGNFIEsKUz/6HVE7HFgQ
OwcLSxZlSQSQ6/YErP/XJoyQUPjHldAjphV/rn28cvh2quoI35fbBuZu5pyKLXTg
RKbdWbeoIZTthRojpqnfqnIR+rPXIIiaMYhStdPshNUxLL7DX+/3yI05qq8R+yqa
2gLb2mJltuwjAoIBAF0WQI/ywK4Q7CKEBnLs0FT7d4z06EsCFOjI4KjBKIMtseVw
whhkGAKqnhDlRTObQmmAol81wgbsDiMMyvUEcUtDXQgXj12UinShYDd/cqxQ5omm
EW3BEHeqEfIdqCSzbqEFckY9lC6w2e8Zc4xY1M028psC28DrgmUWTxXEldOg3bLp
ShNj+1Eld46J8JLDPyCksTA4c89Sm8ffl75GDcS4PI7KqS59xKUki8cbnaRyz0Fb
H+gr+xmkfVfC+n8MOUDubwLR84Wa6sVCFWBio9UtCZteq8lSJUh6EsoIFl1LkxpE
orOr9LmG/mHSYwDKM1pwEtHuRuvkpUzUTeyF6QECggEAbm/vWZtgUsdbocyR5cix
CImuUlo2+MBz2KIz5c7grShjf4pXQpZ6x1Rj8d/7JRz3HM482Cv4BKZABNP3GMTH
nKeE9YjgvgvlXedpjovLa6JLIV/nYx6BQ9sXuXopaxIAQEZw1dDngx+ckGAMm+8D
jN5lbfugkMlHOTx5Nrzqn0hM3f0McjATHzCMJZayuoQUYNJvFWcRvuImJsmoSyVY
gK6Nv6lAwIHA9JXsg3f6+10Q3BxEkoMCUlj4XuX+OfDaBnwS0RX9aV4FZOcW8oNw
fBT+gwvdl08cKJht2lU7AiZt/UhO8j+8HobrXLHnDxw9JHKzuVIgsgqYF8ZNtrpz
6wKCAQEAqJc9xfNYL2jyJTVBvqX34Uct9xV1fq7z45B3gpPHj4tc0dBOIwPKSsu8
rGyefPuMDvbecJoaXqRZGQEcv03ecLVtHHDCsJcWavth9sCQJW9mnOxjOnLHdGnl
h2yIV4iVzNEx5XA1+0JRAAwFMXrC9LUZDTT61opjohMJSEySo/HVekoWG9A0bQGn
Ui+JbtLeuuCwwAGSgdAhVkjr27ANyFtjR63KVrDvSLbYFyZXKxrRYnZWs92Ho/g6
oTLnUSmkbLlhsUDjknAaNfp+enETSoPHcpkd7MeqVw2oYrLw6MCo4h2tBsIgtZEd
h6CgX4Gc3+bhvKx36XYxVwScPJUWsQ==
-----END PRIVATE KEY-----

BIN
test/static/seal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

46
test/www/index.lua Normal file
View File

@@ -0,0 +1,46 @@
local fes = require("fes")
local site = fes.fes()
local std = fes.std
local u = fes.util
site.copyright = std.link("https://fsdproject.org/", "fSD")
site:banner(table.concat {
std.h(1, "Testing Page"),
})
site:note(table.concat {
std.h(1, "Example syntax features"),
std.h(2, "Paragraphs"),
std.p("This is a paragraph."),
std.h(2, "Codeblocks"),
std.codeblock([[
#include <stdio.h>
int main() {
puts("Hello, World!");
return 0;
}]]),
std.h(2, "Inline Codeblocks"),
std.inline([[puts("Hello, World!");]]),
std.h(2, "Links"),
std.link("geminiprotocol.net/"),
std.h(2, "Lists"),
std.list {
"Item 1",
"Item 2",
"Item 3",
},
std.h(2, "Blockquotes"),
std.blockquote([["UNIX is very simple" - Dennis Ritchie
"GNU's Not UNIX" - Richard Stallman]]),
std.h(2, "Rules"),
std.rule(),
std.h(2, "Images"),
std.image("fluffy baby seal", "/static/seal.png"),
std.h(2, "Files"),
std.file("seal.png", "/static/seal.png"),
})
return site

2
todo Normal file
View File

@@ -0,0 +1,2 @@
gemini: Add command-line options
http: https://git.erock.io