complete rework

This commit is contained in:
2025-11-26 17:24:20 +02:00
parent f79b0dc5a0
commit 4947880c4a
53 changed files with 4625 additions and 883 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*]
charset = utf-8
end_of_line = lf
tab_width = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true

11
.gitignore vendored
View File

@@ -1,7 +1,10 @@
/* /*
!/example
!/src !/src
!/deno.json !/lib
!/Dockerfile /!decl
!/.editorconfig
!/.gitignore !/.gitignore
!/README.md
!/Makefile
!/Dockerfile

View File

@@ -1,6 +1,21 @@
FROM denoland/deno:alpine as prod FROM alpine AS build
ENV NODE_ENV=production RUN apk add --no-cache lua5.4-dev lua5.4-luv make gcc pkgconf util-linux-misc
WORKDIR /build
COPY src ./src/
COPY lib ./lib/
COPY Makefile ./Makefile
COPY mklua ./mklua
# ADD https://git.topcheto.eu/topchetoeu/mklua/releases/download/latest/mklua ./mklua
RUN ln -s /usr/lib/liblua-5.4.so.0 /usr/lib/liblua.so.5.4
RUN ./mklua
RUN make MKLUA=./mklua
# ADD https://git.topcheto.eu/topchetoeu/mklua/releases/download/latest/mklua ./mklua
FROM alpine
EXPOSE 8080 EXPOSE 8080
@@ -11,24 +26,11 @@ VOLUME /config
STOPSIGNAL SIGINT STOPSIGNAL SIGINT
HEALTHCHECK --interval=30s --start-period=1s CMD curl localhost:8080/.well-known/keepalive | grep "awake and alive" HEALTHCHECK --interval=30s --start-period=1s CMD curl localhost:8080/.well-known/keepalive | grep "OK"
ENTRYPOINT deno run\ ENTRYPOINT ./bin/website
--allow-read=config.dev.yml\
--allow-read=config.test.yml\
--allow-net=0.0.0.0:8080\
--allow-sys=homedir\
--allow-read=./\
--allow-read=/plugins\
--allow-read=/views\
--allow-read=/static\
--allow-read=/config\
/app/src/main.ts\
/app/config.yml\
$(find /config -name "*.yml")
WORKDIR /app WORKDIR /app
COPY deno.json ./ RUN apk add --no-cache lua5.4 lua5.4-luv
RUN deno install
COPY src ./src/ COPY --from=build ./bin/website .

50
Makefile Normal file
View File

@@ -0,0 +1,50 @@
# Generated by mklua
MKLUA ?= mklua
CC ?= cc
OUTPUT ?= website
BINDIR := bin
LIBDIR := lib
SRCDIR := src
OUTPUT := $(BINDIR)/$(OUTPUT)
SOURCES := $(wildcard $(LIBDIR)/*.c)
OBJECTS := $(SOURCES:%.c=$(BINDIR)/%.o)
MKLUA_FLAGS += --path "$(SRCDIR)/?.lua;$(SRCDIR)/?/init.lua" --cpath ""
MKLUA_FLAGS += --entry
MKLUA_ENTRY ?= website-main
MKLUA_OUT := $(OUTPUT).c
LIBS := $(shell $(MKLUA) $(MKLUA_FLAGS) $(MKLUA_ENTRY) --libs)
DEPS := $(shell $(MKLUA) $(MKLUA_FLAGS) $(MKLUA_ENTRY) --deps)
CCARGS_LUA ?= $(shell pkg-config --cflags lua5.4)
LDARGS_LUA ?= $(shell pkg-config --libs lua5.4)
CCARGS += $(CCARGS_LUA) $(foreach x,$(LIBS),-I"$(dir $x)")
LDARGS += $(LDARGS_LUA)
ifeq ($(DEBUG), yes)
CCARGS += -g
MKLUA_FLAGS += -g
endif
.PHONY: all clean
all: $(OUTPUT)
clean:
rm -fr $(BINDIR) $(MKLUA_OUT) deps/luv
compile_flags.txt:
printf -- '$(foreach v,$(CCARGS) $(LDARGS),\n$v)' > compile_flags.txt
$(OUTPUT): $(LIBS) $(MKLUA_OUT) $(SOURCES) | $(dir $(OUTPUT))
$(CC) $(CCARGS) $(LDARGS) $^ -o $@
$(MKLUA_OUT): $(DEPS) | $(dir $(MKLUA_OUT))
$(MKLUA) $(MKLUA_FLAGS) $(MKLUA_ENTRY) -o $@
%/:
mkdir -p $@

View File

@@ -1,67 +0,0 @@
# My website platform
I use this custom layer on top of PUG for my personal web page. It allows you to write full HTML pages, as well as simple markdown pages.
## How to use?
Using Deno, just running `src/main.ts` with an argument to the config file will start the server. The config consists of the following fields:
- ignore: consists of a list of paths that should be invisible to the public
- plugins: a list of plugins that should be loaded
- libs: a path to the folder which contains the plugins
- views: a path to the folder which contains the views
- maxCache: how many seconds should pass, before a cached page is discarded
- debug: If true, will output more information
- static: If provided, will be a path which contains statically-served resources
- notFound: The name of the not found page
- defaultPage: The name of the page template (the template for a file)
- defaultIndex: The name of the index template (the template for a folder)
- defaultTitle: If the page has no title, this will be used instead
- Everything else: Will become a default value for a global variable
### Structure
There is one folder - the views folder, which contains the files that will be rendered and served by the server. Resolving a view is not too different from resolving a NodeJS module - the server will search for these paths, in this order:
- ${viewsDir}/${path}/index.pug
- ${viewsDir}/${path}/index.md
- ${viewsDir}/${path}.pug
- ${viewsDir}/${path}.md
- ${viewsDir}/${notFound}.pug
- ${viewsDir}/${notFound}.md
- (built-in fallback error message)
The first path that is found will be used as the rendered file. If the found file is an index file, it will be rendered with the index base, otherwise, the page base will be used instead.
First things first, the server will resolve the metadata that the view provides (if any). That metadata will be overlaid over the config and will be used to resolve the base of the page. After that, our view will be rendered, and the output will be put in the metadata option "text". Of course, having a base page that is not `pug` would be useless, as markdown has no templating capabilities.
In a pug view, you will be able to access all metadata and config as global variables. The metadata will be overlaid over the global config.
In a JS view, you will be able to access the following relevant modules, which will allow you to get information about the current view:
- !lib
- View - the base class for all views
- config - the global config
- isIgnored - a function that checks whether the given path is ignored
- list - a utility function that converts an unknown value to a set of strings
- getPlugin - gets the function that calls a plugin (we will talk about them later)
- getLocals - gets an object, representing the current running metadata
- resolveView - from a given URL, returns the corresponding View object
- resolveViewFromUri - from a given filesystem path, returns the corresponding View object
- type Plugin
- type Locals
- default.View - same as View
- default.config - same as config
- default.isIgnored - same as isIgnored
- default.locals - a getter that calls getLocals
### Plugins
Plugins are used to provide additional reusable functionality for the views. They can be put in the configured plugins folder. Due to how the import system works, all plugins that will be used need to be listed in the config (they will be preloaded). A plugin must have its default export a function. That function will take a view as its first argument, and additional user-defined arguments. The function may return anything
Invoking plugins in Pug code can be done with the additionally exposed function `invoke(name, ...args)`. The current view will be passed as the first argument of the plugin.
## Deploying
It is as simple as building, using the included Dockerfile.

View File

@@ -1,23 +0,0 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
},
"imports": {
"@std/http": "jsr:@std/http@^1.0.9",
"@std/path": "jsr:@std/path@^1.0.7",
"@std/yaml": "jsr:@std/yaml@^1.0.5",
"highlight.js": "npm:highlight.js@^11.10.0",
"markdown-it": "npm:markdown-it@^14.1.0",
"markdown-it-front-matter": "npm:markdown-it-front-matter@^0.2.4",
"pug": "npm:pug@^3.0.3",
"!lib": "./src/lib.ts"
},
"lint": {
"rules": {
"exclude": [ "no-explicit-any", "no-cond-assign", "require-await", "no-regex-spaces", "no-empty" ]
}
},
"fmt": {
"indentWidth": 4
}
}

View File

@@ -1,13 +0,0 @@
plugins: []
libs: data/plugins
views: data/views
static: data/static
notFound: .templ/not-found
defaultPage: .templ/page
defaultIndex: .templ/index
debug: true
maxCache: 30
ignore:
- /.**

View File

@@ -1,13 +0,0 @@
libs: /plugins
views: /views
static: /static
notFound: .templ/not-found
defaultPage: .templ/page
defaultIndex: .templ/index
plugins: []
debug: false
maxCache: 86400
ignore:
- /.**

View File

@@ -1,5 +0,0 @@
plugins:
- breadcrumbs
- dir
- navbar
debug: false

15
lib/main.c Normal file
View File

@@ -0,0 +1,15 @@
#include <lauxlib.h>
#include <lua.h>
#include <lualib.h>
#include <stdbool.h>
extern int mklua_entry(lua_State *ctx, int argc, const char **argv);
extern int luaopen_seek(lua_State *ctx);
int main(int argc, const char **argv) {
lua_State *ctx = luaL_newstate();
luaL_openlibs(ctx);
luaL_requiref(ctx, "seek", luaopen_seek, false);
return mklua_entry(ctx, argc, argv);
}

92
lib/seek.c Normal file
View File

@@ -0,0 +1,92 @@
// For some fucking reason, libuv hasn't implemented lseek() (WHY??)
// Here, a sync version is implemented (as i cannot be bothered with callbacks)
#include <errno.h>
#include <lauxlib.h>
#include <lua.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#ifdef _WIN32
#include <io.h>
#include <windows.h>
#define off_t LONGLONG
#define _LARGEFILE_SOURCE
#endif
static int luafunc_seek(lua_State *ctx) {
int fd = lua_tointeger(ctx, 1);
off_t offset = lua_isnoneornil(ctx, 2) ? 0 : luaL_checkinteger(ctx, 2);
int whence = SEEK_CUR;
if (!lua_isnoneornil(ctx, 3)) {
const char *swhence = luaL_checkstring(ctx, 3);
if (!strcmp(swhence, "set")) {
whence = SEEK_SET;
}
else if (!strcmp(swhence, "cur")) {
whence = SEEK_CUR;
}
else if (!strcmp(swhence, "end")) {
whence = SEEK_END;
}
else {
luaL_typeerror(ctx, 3, "'set', 'cur' or 'end'");
}
}
#ifdef _WIN32
HANDLE fh;
LARGE_INTEGER new_offset;
fh = (HANDLE)_get_osfhandle(fd);
if (fh == (HANDLE)-1) {
errno = EBADF;
return -1;
}
DWORD method;
switch (whence) {
case SEEK_SET:
method = FILE_BEGIN;
break;
case SEEK_CUR:
method = FILE_CURRENT;
break;
case SEEK_END:
method = FILE_END;
break;
default:
errno = EINVAL;
return -1;
}
LARGE_INTEGER distance;
distance.QuadPart = offset;
if (!SetFilePointerEx(fh, distance, &new_offset, method)) {
lua_pushnil(ctx);
lua_pushstring(ctx, strerror(EINVAL));
return 2;
}
off_t offs = new_offset.QuadPart;
#else
off_t offs = lseek(fd, offset, whence);
if (offs < 0) {
lua_pushnil(ctx);
lua_pushstring(ctx, strerror(errno));
return 2;
}
#endif
lua_pushinteger(ctx, offs);
return 1;
}
int luaopen_seek(lua_State *ctx) {
lua_pushcfunction(ctx, luafunc_seek);
return 1;
}

View File

@@ -1,102 +0,0 @@
import { parse as parseYaml } from "@std/yaml";
import { list } from "./main.ts";
import { normalize } from "@std/path/posix";
interface Config {
ignore: Set<string>;
plugins: Set<string>;
maxCache?: number;
debug: boolean;
track?: boolean;
static?: string;
libs?: string;
views?: string;
notFound: string;
defaultPage?: string;
defaultIndex?: string;
defaultTitle?: string;
}
function merge<T>(...objs: T[]): T {
if (objs.length === 0) throw new Error("Expected at least one object to merge");
let res = objs[0];
for (const obj of objs.slice(1)) {
if (obj == null) res = undefined as T;
else if (Array.isArray(obj)) {
if (Array.isArray(res)) res.push(...obj);
else res = obj;
}
else if (typeof obj === "object") {
if (typeof res !== "object" || res == null) {
res = obj;
}
else {
for (const key in obj) {
if (key in res) res[key] = merge(res[key], obj[key]);
else res[key] = obj[key];
}
}
}
else res = obj;
}
return res;
}
async function readConfig() {
const objs = await Promise.all(Deno.args.map(async v => parseYaml(await Deno.readTextFile(v)) as Config));
const res = merge(...objs);
res.ignore = new Set(list(res.ignore).map(v => normalize(v)));
res.plugins = new Set(list(res.plugins).map(v => normalize(v)));
return res;
}
const config = await readConfig();
export default config;
export let isIgnored = (path: string): boolean => {
const regexs: string[] = [];
for (let predicate of config.ignore) {
predicate = normalize(predicate);
let absolute;
if (predicate === "/") {
isIgnored = () => true;
return true;
}
if (predicate.startsWith("/")) {
absolute = true;
predicate = predicate.slice(1);
}
if (predicate.endsWith("/")) {
predicate = predicate.slice(0, -1);
}
if (predicate.startsWith("./") ||
predicate.startsWith("../") ||
predicate == ".." ||
predicate == ".") throw new Error(`'${predicate}' in ignore list includes relative specifiers`);
const body = predicate.split("/")
.filter(v => v !== "")
.map(v => v.replace(/\*\*|[.*+?^${}()|[\]\\]/g, val => {
if (val === "*") return "[^/]*";
else if (val === "**") return ".*";
else return "\\" + val;
}))
.join("/");
regexs.push(absolute ? "(^/" + body + "/)" : "(/" + body + "/)");
}
const regex = new RegExp(regexs.join("|"));
isIgnored = path => regex.test(normalize("/" + path + "/"));
return isIgnored(path);
};

11
src/debug-entry.lua Normal file
View File

@@ -0,0 +1,11 @@
return function (...)
require "utils.printing";
pprint(require "lldebugger");
require "lldebugger".start();
local args = { ... };
return require "lldebugger".call(function ()
require "website-main" (table.unpack(args));
end);
end

View File

61
src/highlight/init.lua Normal file
View File

@@ -0,0 +1,61 @@
local slot = require "template".slot;
local e = require "template".elements;
require "highlight.lang.c";
require "highlight.lang.lua";
require "highlight.lang.python";
require "highlight.lang.py";
require "highlight.lang.javascript";
require "highlight.lang.js";
--- @param text string
--- @return template
return function (lang, text)
--- @type boolean, (fun(i: integer, src: string): integer?, string?, string?)?
local ok, tokens = pcall(require, "highlight.lang." .. lang);
if not ok then
return e.pre { class = "hl", e.code { text } }
end
local prefix = text:match "^([\t ]+)";
if prefix then
local lines = {};
for line in text:sub(#prefix + 1):gmatch "[^\n]*" do
if line:sub(1, #prefix) == prefix then
line = line:sub(#prefix + 1);
end
table.insert(lines, (line:gsub("%s+$", "")));
end
text = table.concat(lines, "\n");
end
return e.pre { class = "hl",
e.code {
slot(function (self, emit)
--- @type string?
local curr_tag;
local curr = {};
local function commit()
if #curr > 0 then
emit(e.span { class = "hl-" .. curr_tag, table.concat(curr) });
curr = {};
end
end
for _, data, tag in tokens, text do
if tag ~= curr_tag then
commit();
curr_tag = tag;
end
table.insert(curr, data);
end
commit();
end);
}
};
end

View File

@@ -0,0 +1,16 @@
--- @param list string[]
return function (list)
table.sort(list, function (a, b)
return #a > #b;
end);
--- @param src string
--- @param i integer
return function (src, i)
for j = 1, #list do
if src:sub(i, i + #list[j] - 1) == list[j] then
return list[j];
end
end
end;
end

47
src/highlight/lang/c.lua Normal file
View File

@@ -0,0 +1,47 @@
local tokenizer = require "highlight.tokenizer";
local keywords = require "highlight.keywords";
local match_string = require "highlight.string";
return tokenizer {
{ "white", "%s+" },
{ "comment", "%/%*.-%*%/" },
{ "comment", "%/%/[^\n]*" },
{ "preproc", "#[^\n]*" },
{ "string", match_string("\"", "\\") },
{ "string", match_string("\'", "\\") },
{ "number", "0[xX][0-9a-fA-F]+" },
{ "number", "%.[0-9]+e[0-9]+" },
{ "number", "%.[0-9]+" },
{ "number", "[0-9]+%.[0-9]*e[0-9]+" },
{ "number", "[0-9]+%.?[0-9]*" },
{ "keyword", keywords {
"int", "short", "long", "char",
"unsigned", "signed",
"typedef", "struct", "union",
"return", "break", "continue", "goto", "case",
"switch", "do", "while", "for", "if", "else",
"static", "voilatile", "const", "inline",
}, "[a-zA-Z0-9_]" },
{ "operator", keywords {
"+", "-", "*", "/", "%", "^",
"&", "~", "|", "<<", ">>", "/",
"==", "!=", "<=", ">=", "<", ">", "=",
"+=", "-=", "*=", "/=", "&=", "|=", "^=", "<<=", ">>=",
"(", ")", "{", "}", "[", "]",
";", ":", ",", ".", "->",
"&&", "||", "!", "?",
"++", "--",
} },
{ "preproc", "[A-Z_][A-Z0-9_]*", "%a" },
{ "call", "([a-zA-Z_][a-zA-Z0-9_]*)%s*%(", "%a" },
{ "type", "[a-zA-Z_][a-zA-Z0-9_]*_t", "%a" },
{ "identifier", "[a-zA-Z_][a-zA-Z0-9_]*" },
{ "invalid", "." },
};

View File

@@ -0,0 +1,49 @@
local tokenizer = require "highlight.tokenizer";
local keywords = require "highlight.keywords";
local match_string = require "highlight.string";
return tokenizer {
{ "white", "%s+" },
{ "comment", "%/%*.-%*%/" },
{ "comment", "%/%/[^\n]*" },
{ "string", match_string("\"", "\\") },
{ "string", match_string("\'", "\\") },
{ "string", match_string("`", "\\") },
{ "string", match_string("/", "\\") },
{ "number", "0[xX][0-9a-fA-F]+" },
{ "number", "0[bB][01]+" },
{ "number", "%.[0-9]+e[0-9]+" },
{ "number", "%.[0-9]+" },
{ "number", "[0-9]+%.[0-9]*e[0-9]+" },
{ "number", "[0-9]+%.?[0-9]*" },
{ "keyword", keywords {
"var", "let", "const",
"return", "break", "continue",
"do", "while",
"switch", "case",
"for", "in", "of",
"if", "else",
"function", "class", "static",
"async", "yield", "await",
"true", "false", "null", "undefined", "this", "super",
}, "[a-zA-Z0-9_]" },
{ "operator", keywords {
"+", "-", "*", "/", "%", "^", "@",
"&", "~", "|", "<<", ">>", "//",
"==", "!=", "<=", ">=", "<", ">", "=",
"+=", "-=", "*=", "/=", "&=", "|=", "^=", "<<=", ">>=", "&&=", "||=",
"(", ")", "{", "}", "[", "]",
";", ":", ",", ".", "...", "=>",
"||", "&&", "!",
} },
{ "object", "([a-zA-Z_][a-zA-Z0-9_]*)%s*%." },
{ "type", "[A-Z][a-zA-Z0-9_]*" },
{ "call", "([a-zA-Z_][a-zA-Z0-9_]*)%s*%(" },
{ "identifier", "[a-zA-Z_][a-zA-Z0-9_]*" },
{ "invalid", "." },
};

View File

@@ -0,0 +1 @@
return require "highlight.lang.javascript";

View File

@@ -0,0 +1,44 @@
local tokenizer = require "highlight.tokenizer";
local keywords = require "highlight.keywords";
local match_string = require "highlight.string";
return tokenizer {
{ "white", "%s+" },
{ "comment", "(%-%-%[(%=*)%[.-%]%2%])" },
{ "comment", "%-%-[^\n]*" },
{ "bigstring", "%[(%=*)%[.-%]%1%]" },
{ "string", match_string("\"", "\\") },
{ "string", match_string("\'", "\\") },
{ "number", "0[xX][0-9a-fA-F]+" },
{ "number", "%.[0-9]+e[0-9]+" },
{ "number", "%.[0-9]+" },
{ "number", "[0-9]+%.[0-9]*e[0-9]+" },
{ "number", "[0-9]+%.?[0-9]*" },
{ "keyword", keywords {
"local", "global",
"return", "break", "goto",
"and", "or", "not",
"while", "do",
"for", "in",
"if", "elseif", "else", "then",
"repeat", "until",
"function", "end",
"true", "false", "nil", "self",
}, "[a-zA-Z0-9_]" },
{ "operator", keywords {
"+", "-", "*", "/", "%", "^", "#",
"&", "~", "|", "<<", ">>", "//",
"==", "~=", "<=", ">=", "<", ">", "=",
"(", ")", "{", "}", "[", "]", "::",
";", ":", ",", ".", "..", "...",
} },
{ "object", "([a-zA-Z_][a-zA-Z0-9_]*)%s*[.:]" },
{ "call", "([a-zA-Z_][a-zA-Z0-9_]*)%s*[(\"']" },
{ "identifier", "[a-zA-Z_][a-zA-Z0-9_]*" },
{ "invalid", "." },
};

View File

@@ -0,0 +1 @@
return require "highlight.lang.python";

View File

@@ -0,0 +1,47 @@
local tokenizer = require "highlight.tokenizer";
local keywords = require "highlight.keywords";
local match_string = require "highlight.string";
return tokenizer {
{ "white", "%s+" },
{ "comment", "%#[^\n]*" },
{ "string", "(f)[\"']" },
{ "string", match_string("\"", "\\") },
{ "string", match_string("\'", "\\") },
{ "number", "0[xX][0-9a-fA-F]+" },
{ "number", "%.[0-9]+e[0-9]+" },
{ "number", "%.[0-9]+" },
{ "number", "[0-9]+%.[0-9]*e[0-9]+" },
{ "number", "[0-9]+%.?[0-9]*" },
{ "keyword", keywords {
"async", "class", "def", "del", "global", "nonlocal", "lambda",
"return", "break", "continue", "goto", "yield", "await", "assert", "pass",
"try", "except", "finally", "raise",
"with", "while", "for", "if", "elif", "else",
"is", "in", "as",
"static", "voilatile", "const", "inline",
"and", "or", "not",
"True", "False", "None", "self",
}, "[a-zA-Z0-9_]" },
{ "operator", keywords {
"+", "-", "*", "/", "%", "^",
"&", "~", "|", "<<", ">>", "/",
"==", "!=", "<=", ">=", "<", ">", "=",
"+=", "-=", "*=", "/=", "&=", "|=", "^=", "<<=", ">>=",
"(", ")", "{", "}", "[", "]",
";", ":", ",", ".", "->",
"&&", "||", "!", "?",
} },
{ "type", "[A-Z][a-zA-Z0-9_]*" },
{ "call", "([a-zA-Z_][a-zA-Z0-9_]*)%s*%(", "%a" },
{ "identifier", "[a-zA-Z_][a-zA-Z0-9_]*" },
{ "invalid", "." },
};

21
src/highlight/string.lua Normal file
View File

@@ -0,0 +1,21 @@
return function (quote, escape)
--- @param src string
--- @param i integer
return function (src, i)
if src:sub(i, i) ~= quote then return false end
local start = i;
i = i + 1;
while i <= #src do
if src:sub(i, i) == escape then
i = i + 2;
elseif src:sub(i, i) == quote then
i = i + 1;
return src:sub(start, i - 1);
else
i = i + 1;
end
end
end
end

View File

@@ -0,0 +1,45 @@
--- @alias hl_pattern string | fun(src: string, i: integer): string? Either a lua pattern (automatically prefixed by ^), or a function that returns the size of the matched token at i
--- @param matchers { [1]: string, [2]: hl_pattern, [3]?: hl_pattern }[] 1 - tag, 2 - match pattern, 3 - end exclusion pattern
--- @return fun(src: string, i?: integer): integer?, string?, string?
return function (matchers)
return function (src, i)
if not i then i = 1 end
if not src then return nil end
if i > #src then return nil end
for j = 1, #matchers do
local tag = matchers[j][1];
local pat = matchers[j][2];
local end_pat = matchers[j][3];
local str;
if type(pat) == "string" then
str = src:match("^" .. pat, i);
else
str = pat(src, i);
end
--- @cast str string?
if str and i + #str <= #src and end_pat then
if type(end_pat) == "string" then
if src:find("^" .. end_pat, i + #str) then
str = nil;
end
else
if end_pat(src, i + #str) then
str = nil;
end
end
end
if str then
return i + #str, str, tag;
end
end
return nil;
end
end

509
src/http/init.lua Normal file
View File

@@ -0,0 +1,509 @@
local tcp = require "sync.tcp";
local url = require "utils.url";
local sync = require "sync";
local uv = require "luv";
local stream = require "sync.stream"
local codes_msgs = {
[100] = "Continue",
[101] = "Switching Protocols",
[102] = "Processing",
[103] = "Early Hints",
[200] = "OK",
[201] = "Created",
[202] = "Accepted",
[203] = "Non-Authorative Information",
[204] = "No Content",
[205] = "Reset Content",
[206] = "Partial Content",
[207] = "Multi-Status",
[208] = "Already Reported",
[209] = "IM Used",
[300] = "Multiple Choices",
[301] = "Moved Permanently",
[302] = "Found",
[303] = "See Other",
[304] = "Not Modified",
[305] = "Use Proxy",
[306] = "Switch Proxy",
[307] = "Temporary Redirect",
[308] = "Premanent Redirect",
[400] = "Bad Request",
[401] = "Unauthorized",
[402] = "Payment Required",
[403] = "Forbidden",
[404] = "Not Found",
[405] = "Method Not Allowed",
[406] = "Not Acceptable",
[407] = "Proxy Authentication Required",
[408] = "Request Timeout",
[409] = "Conflict",
[410] = "Gone",
[411] = "Length Required",
[412] = "Precondition Failed",
[413] = "Payload Too Large",
[414] = "URI Too Long",
[415] = "Unsupported Media Type",
[416] = "Range Not Satisfiable",
[417] = "Expectation Failed",
[418] = "I'm a teapot",
[421] = "Misdirected Request",
[422] = "Unprocessable Content",
[423] = "Locked",
[424] = "Failed Dependency",
[425] = "Too Early",
[426] = "Upgrade Required",
[428] = "Precondition Required",
[429] = "Too Many Requests",
[431] = "Request Header Fields Too Large",
[451] = "Unavailable For Legal Reasons",
[500] = "Internal Server Error",
[501] = "Not Implemented",
[502] = "Bad Gateway",
[503] = "Service Unavailable",
[504] = "Gateway Timeout",
[505] = "HTTP Version Not Supported",
[506] = "Variant Also Negotiates (",
[507] = "Insufficient Storage",
[508] = "Loop Detected",
[510] = "Not Extended",
[511] = "Network Authentication Required",
};
local getaddrinfo = sync.wrap(uv.getaddrinfo);
--- @alias http_body (fun(): string?, string?) | stream | string | nil
--- @class http_headers
--- @field _map table<string, string[]>
local headers_index = {};
--- @return (fun(self: http_headers, prev?: string): string?, ...: string?), http_headers
function headers_index:iter()
--- @param prev string?
--- @param self http_headers
return function (self, prev)
local key, val = next(self._map, prev);
if not val then return nil end
return key, table.unpack(val);
end, self;
end
--- @param name string
function headers_index:get(name)
local val = self._map[name:lower()];
if not val then return end
return table.unpack(val);
end
--- @param name string
--- @return string ...
function headers_index:add(name, ...)
name = name:lower();
if not self._map[name] then self._map[name] = {} end
local val = self._map[name];
for i = 1, select("#", ...) do
assert(select(i, ...), "element can't be nil");
table.insert(val, (select(i, ...)));
end
return table.unpack(val, 1);
end
--- @param name string
--- @return string ...
function headers_index:set(name, ...)
self._map[name:lower()] = { ... };
return ...;
end
--- @param name string
--- @return string ...
function headers_index:del(name, ...)
name = name:lower();
local val = self._map[name];
self._map[name] = nil;
if val then return table.unpack(val) end
end
local headers_meta = { __index = headers_index };
--- @class http_headers_lib
local headers = {};
--- @return http_headers
function headers.new()
return setmetatable({ _map = {} }, headers_meta);
end
--- @param init table<string, string | string[]>
function headers.of(init)
local res = headers.new();
for k, v in pairs(init) do
assert(type(k) == "string", "headers init key must be string");
if type(v) == "string" then
res:set(k, v);
else
res:set(k, table.unpack(v));
end
end
return res;
end
--- @param stream stream
local function http_read_headers(stream)
local res = headers.new();
while true do
local line, err = stream:read "L";
if not line then return nil, err or "EOF" end
if line == "\r\n" then return res end
local key, val = line:match "^(.-): ?(.*)\r\n$";
if not key then return nil, "unexpected header format" end
key = key:lower();
if val:find ", " then
local i = 1;
repeat
local l = val:find(", ", i);
res:add(key, val:sub(i, l and (l - 1) or -1));
if l then i = l + 1; end
until not l;
else
res:add(key, val);
end
end
end
--- @param str stream
--- @param headers http_headers
--- @return stream?, string?
local function http_read_body(str, headers)
--- @type stream?
local str = str;
local len = tonumber((headers:get "content-length"));
local chunked = false;
local encoding = { headers:get "transfer-encoding" };
for i = 1, #encoding do
if encoding[i] == "chunked" then
chunked = true;
else
return nil, "unknown transfer encoding '" .. encoding[i] .. "'";
end
end
local _read;
if chunked then
function _read(cb)
if not str then return nil, "closed" end
while true do
local line, err;
line, err = str:read "L";
if not line then return nil, err or "EOF" end
local len = line:match "^([%da-zA-Z]+)\r\n$";
if not len then return nil, "malformed chunked encoding" end
len = tonumber(len, 16);
if len == 0 then break end
line, err = str:read(len);
if not line then return nil, err or "EOF" end
local term, err = str:read(2);
if not term then return nil, err or "EOF" end
if term ~= "\r\n" then return nil, "malformed chunked encoding" end
local ok, err = cb(line);
if ok == nil then return nil, err end
if not ok then break end
end
return true;
end
elseif len then
function _read(cb)
if not str then return nil, "closed" end
local res, err = str:read(len);
if not res then
if not err then return true end
return nil, err;
end
len = 0;
local ok, err = cb(res);
if ok == nil then return nil, err end
return true;
end
else
return nil;
end
return stream.new(
_read,
function (data)
return nil, "read-only";
end,
function ()
str = nil;
end,
true
);
end
--- @return string | "EOF" | nil method
--- @return string? path
--- @return http_headers? headers
local function http_read_req(stream)
local line, err = stream:read "L";
if not line then
if err then return nil, err end
return "EOF";
end
local type, path, version = line:match "^(%S-) (%S-) HTTP/(%S-)\r\n$";
if not type then return nil, "unexpected format" end
if version ~= "1.1" then return nil, "only HTTP 1.1 supported" end
local headers, err = http_read_headers(stream);
if not headers then return nil, err end
return type, path, headers;
end
--- @return integer | "EOF" | nil code
--- @return http_headers | string? headers
local function http_read_res(stream)
local line, err = stream:read "L";
if not line then
if err then return nil, err end
return nil;
end
local version, code = line:match "^HTTP/(%S-) (%S-) (.-)\r\n$";
if not version then return nil, "unexpected format" end
if version ~= "1.1" then return nil, "only HTTP 1.1 supported" end
local headers, err = http_read_headers(stream);
if not headers then return nil, err end
return code, headers;
end
--- @param stream stream
--- @param key? string
--- @param ... string
local function http_write_header(stream, key, ...)
if not key then
return stream:write "\r\n";
else
for i = 1, select("#", ...) do
local res, err = stream:write(("%s: %s\r\n"):format(key, (select(i, ...))));
if not res then return nil, err end
end
return true;
end
end
--- @param stream stream
--- @param headers http_headers
local function http_write_headers(stream, headers)
local it = headers:iter();
local function iterator(stream, headers, it, k, ...)
if not k then
return http_write_header(stream);
end
local _, err = http_write_header(stream, k, ...);
if not _ then return nil, err end
return iterator(stream, headers, it, it(headers, k));
end
return iterator(stream, headers, it, it(headers, nil));
end
--- @param str stream
local function http_setup_body(str, body)
if not body then return true end
local _, err = str:write(("transfer-encoding: chunked\r\n"));
if not _ then return nil, err end
return stream.new(
function () return nil, "write-only" end,
function (data)
if str == nil then return nil, "closed" end
if #data ~= 0 then
return str:write(("%x\r\n"):format(#data), data, "\r\n");
end
end,
function ()
if str then
local ok, err = str:write("0\r\n\r\n");
--- @diagnostic disable-next-line
str = nil;
return ok, err;
end
end
);
end
--- @param stream stream
--- @param method string
--- @param path string
--- @param headers http_headers
--- @param body? false
--- @return true?, string?
--- @overload fun(stream: stream, method: string, path: string, headers: http_headers, body: true): stream?, string?
local function http_write_req(stream, method, path, headers, body)
local _, err = stream:write(("%s %s HTTP/1.1\r\n"):format(method, path));
if not _ then return nil, err end
local body_str, err = http_setup_body(stream);
if not body_str then return nil, err end
local _, err = http_write_headers(stream, headers);
if not _ then return nil, err end
return body_str;
end
--- @param stream stream
--- @param code integer
--- @param headers http_headers
--- @param body? false
--- @return true?, string?
--- @overload fun(stream: stream, code: integer, headers: http_headers, body: true): stream?, string?
local function http_write_res(stream, code, headers, body)
local _, err = stream:write(("HTTP/1.1 %d %s\r\n"):format(code, codes_msgs[code] or "Unknown"));
if not _ then return nil, err end
local body_str, err = http_setup_body(stream, body);
if not body_str then return nil, err end
local _, err = http_write_headers(stream, headers);
if not _ then return nil, err end
return body_str;
end
local function http_write_body(stream, body)
if type(body) == "function" then
local str <close> = stream;
--- @cast str stream
while true do
local data, err = body();
if not data then
if err then return nil, err end
break;
end
local _, err = str:write(data);
if not _ then return nil, err end
end
elseif type(body) == "string" then
local str <close> = stream;
--- @cast str stream
str:write(body);
elseif body then
local str <close> = stream;
--- @cast str stream
while true do
local data, err = body:read "c";
if not data or #data == 0 then
if err then return nil, err end
break;
end
local _, err = str:write(data);
if not _ then return nil, err end
end
end
return true;
end
--- @param arg { url: string, method?: string, headers?: http_headers, body?: http_body }
local function http_fetch(arg)
local parsed = assert(url.parse(arg.url));
if not parsed.scheme then return nil, "scheme must be specified" end
if parsed.scheme ~= "http" then return nil, "only http supported" end
if not parsed.host then return nil, "host must be specified" end
if parsed.username then return nil, "username and password not supported" end
--- @type uv.aliases.getaddrinfo_rtn?, string?
local dns_res, err = getaddrinfo(parsed.host, nil, nil);
if not dns_res then return nil, err or "unable to resolve host" end
local path = url.encode_url(parsed.path);
local params = url.encode_body(parsed.params);
if #params > 0 then path = path .. "?" .. params end
if not arg.headers then
arg.headers = headers.new();
end
if not arg.headers:get "host" then
arg.headers:add("host", parsed.host);
end
local conn;
for i = 1, #dns_res do
conn, err = tcp():connect(dns_res[i].addr, parsed.port or dns_res[i].port or 80);
if conn then break end
end
if not conn then return nil, err end
local body_out, err = http_write_req(conn, arg.method or "GET", path, arg.headers, arg.body ~= nil);
if not body_out then return nil, err end
if arg.body then
local _, err = http_write_body(body_out, arg.body);
if not _ then return nil, err end
end
local code, headers = http_read_res(conn);
if not code then return nil, headers end
local body, err = http_read_body(conn, headers --[[@as http_headers]]);
if not body and err then return nil, err end
return code, headers, body;
end
--- @param stream stream
--- @param code? integer
--- @param hdrs? http_headers
--- @param body? http_body
local function http_respond(stream, code, hdrs, body)
local body_out, err = http_write_res(stream, code or 200, hdrs or headers.new(), body ~= nil);
if not body_out then return nil, err end
if body then
local _, err = http_write_body(body_out, body);
if not _ then return nil, err end
end
return true;
end
return {
read_req = http_read_req,
read_res = http_read_res,
read_body = http_read_body,
write_req = http_write_req,
write_res = http_write_res,
fetch = http_fetch,
respond = http_respond,
headers = headers,
};

View File

@@ -1,14 +0,0 @@
import View, { getLocals, type Locals } from "./views/View.ts";
import config, { isIgnored } from "./config.ts";
import { list } from "./main.ts";
import { getPlugin, type Plugin } from "./plugins.ts";
import { resolveViewFromUri, resolveView } from "./views/resolution.ts";
export { View, config, isIgnored, list, getPlugin, resolveView, resolveViewFromUri, getLocals, type Plugin, type Locals };
export default {
View,
config,
isIgnored,
get locals() { return getLocals(); }
}

View File

@@ -1,114 +0,0 @@
import "./plugins.ts";
import config from "./config.ts";
import { serveDir } from "@std/http";
import { normalize, resolve } from "@std/path/posix";
import { renderView } from "./views/resolution.ts";
import { loadPlugins } from "./plugins.ts";
export function list(data: string | Iterable<string> | null | undefined): string[] {
if (Array.isArray(data)) return data.flatMap(list);
else if (typeof data === "string") return String(data ?? "")
.split(/[,;]/)
.map(v => v.trim())
.filter(v => v !== "");
else return [...(data ?? [])].flatMap(list);
}
export default async function main() {
await loadPlugins();
Deno.addSignalListener("SIGINT", async () => {
console.log("Stopping server");
server.shutdown();
Deno.exit();
});
const server = Deno.serve({ port: 8080 }, async req => {
try {
const url = new URL(req.url);
if (url.pathname === "/.well-known/keepalive") {
return new Response("awake and alive");
}
if (config.static != null) {
const res = await serveDir(req, {
showIndex: false,
fsRoot: resolve(config.static),
showDotfiles: false,
quiet: true,
});
if (res.status !== 404) return res;
}
const path = normalize("/" + url.pathname);
const view = (renderView(path, { params: url.searchParams }, true))?.text;
if (view != null) return new Response(view, { headers: { "content-type": "text/html" } });
if (config.notFound != null && req.method === "GET") {
const view = (renderView(config.notFound, { params: url.searchParams }, true))?.text;
if (view != null) return new Response(view, { headers: { "content-type": "text/html" }, status: 404 });
}
return new Response("Not found", { status: 404 });
}
catch (e) {
console.error(String(e));
if (config.debug) {
return new Response("Internal error: " + String(e), { status: 500 });
}
else return new Response("Internal error");
}
});
}
if (import.meta.main) {
main();
}
// const app = express();
// export const lib = { View, PugView, MarkdownView, JSView, list, merge };
// app.use('/.well-known/keepalive', (req, res) => void res.send("awake and alive"));
// if (config.static != null) app.use(express.static(config.static));
// if (config.views != null) app.get("/*", (req, res, next) => {
// const path = normalize("/" + req.path);
// // for (const prefix of config.ignore) {
// // if (path.startsWith(prefix)) {
// // return next();
// // }
// // }
// const view = View.render(path, { query: req.query, params: req.params }, true);
// if (view == null) next();
// // else if (view.cached) res.sendStatus(304);
// else res.send(view.text);
// });
// if (config.views != null) app.use((req, res) => {
// res.status(404);
// if (req.method === "GET") {
// console.log(config.notFound);
// const view = View.render(config.notFound, { query: req.query, params: req.params }, true, true);
// if (view == null) res.send("Not found :/");
// // else if (view.cached) res.sendStatus(304);
// else res.send(view.text);
// }
// else res.send("Not found :/");
// });
// else app.use((req, res) => {
// res.sendStatus(404);
// });
// app.listen(8080, () => console.log("Listening!"));
// // Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts

488
src/markdown.lua Normal file
View File

@@ -0,0 +1,488 @@
--- @diagnostic disable: cast-local-type
local e = require "template".elements;
local slot = require "template".slot;
local highlight = require "highlight";
local parse;
local PREC_TEXT = 3;
local PREC_ELEMENT = 2;
local PREC_PARAGRAPH = 1;
--- @param src string
--- @param i integer
--- @param indent string
--- @param detect? boolean
local function skip_white(src, i, indent, detect)
local j = i;
local detected;
while true do
if j == 1 or src:sub(j - 1, j - 1) == "\n" then
if not src:find("^" .. indent, j) then return i end
j = j + #indent;
if detect then
detected = src:match("^[\t ]+", j);
if not detected then return i end
j = j + #detected;
end
end
local _, e;
_, e = src:find("^[ \t]*", j);
if not e then break end
j = e + 1;
_, e = src:find("^\n", j);
if not e then break end
j = e + 1;
end
return j, detected or indent;
end
--- @param src string
--- @param i integer
--- @param indent string
--- @param eof string
local function parse_raw(src, i, indent, eof)
local j = i;
local parts = {};
while true do
if j == 1 or src:sub(j - 1, j - 1) == "\n" then
if not src:find("^" .. indent, j) then return i end
j = j + #indent;
end
local s, e = src:find(eof, j);
if not s or not e then return i end
local nl_i = src:find("\n", j) or (#src + 1);
if e <= nl_i then
local n = 0;
for i = s - 1, 1, -1 do
if src:sub(i, i) == "\\" then
n = n + 1;
else
break;
end
end
if n >= 2 then
table.insert(parts, ("\\"):rep(n // 2));
end
if n % 2 ~= 0 then
table.insert(parts, src:sub(j, s - n - 1));
table.insert(parts, src:sub(s, e));
j = e + 1;
else
table.insert(parts, src:sub(j, s - n - 1));
j = e + 1;
break;
end
else
table.insert(parts, src:sub(j, nl_i - 1) .. "\n");
j = nl_i + 1;
end
end
return j, table.concat(parts);
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_paragraph(src, i, indent)
local j = i;
local data;
j, data = parse(src, j, indent, PREC_TEXT, "\n\n", true);
if not data then return i end
return j, e.p { data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_heading(src, i, indent)
local j = i;
local data;
local prefix = src:match("^#+", j);
if not prefix then return i end
j = j + #prefix;
j, data = parse(src, j, indent, PREC_TEXT, "\n", true);
if not data then return i end
if #prefix == 1 then
return j, e.h1 { data };
elseif #prefix == 2 then
return j, e.h2 { data };
elseif #prefix == 3 then
return j, e.h3 { data };
elseif #prefix == 4 then
return j, e.h4 { data };
elseif #prefix == 5 then
return j, e.h5 { data };
else
return j, e.h6 { data };
end
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_bold(src, i, indent)
local j = i;
local data;
if not src:find("^%*%*", j) then return i end
j = j + 2;
j, data = parse(src, j, indent, PREC_TEXT, "%*%*");
if not data then return i end
return j, e.strong { data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_italic(src, i, indent)
local j = i;
local data;
if not src:find("^%*", j) then return i end
j = j + 1;
j, data = parse(src, j, indent, PREC_TEXT, "%*");
if not data then return i end
return j, e.em { data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_strike(src, i, indent)
local j = i;
local data;
if not src:find("^~", j) then return i end
j = j + 1;
j, data = parse(src, j, indent, PREC_TEXT, "~");
if not data then return i end
return j, e.s { data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_link(src, i, indent)
local j = i;
local data, url;
if not src:find("^%[", j) then return i end
j = j + 1;
j, data = parse(src, j, indent, PREC_TEXT, "%]");
if not data then return i end
j, indent = skip_white(src, j, indent);
if not indent then return i end
if not src:find("^%(", j) then return i end
j = j + 1;
j, url = parse_raw(src, j, indent, "%)");
if not url then return i end
return j, e.a { href = url, data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_img(src, i, indent)
local j = i;
local alt, url;
if not src:find("^!%[", j) then return i end
j = j + 2;
j, alt = parse_raw(src, j, indent, "%]");
if not alt then return i end
j, indent = skip_white(src, j, indent);
if not indent then return i end
if not src:find("^%(", j) then return i end
j = j + 1;
j, url = parse_raw(src, j, indent, "%)");
if not url then return i end
return j, e.img { src = url, alt = alt };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_code(src, i, indent)
local j = i;
local data;
if not src:find("^`", j) then return i end
j = j + 1;
j, data = parse_raw(src, j, indent, "`");
if not j then return i end
return j, e.code { data };
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_codeblock(src, i, indent)
local j = i;
local lang, data;
if not src:find("^```", j) then return i end
j = j + 3;
j, lang = parse_raw(src, j, indent, "\n");
if not lang then return i end
j, data = parse_raw(src, j, indent, "```");
if not data then return i end
return j, highlight(lang, data);
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_bulletlist(src, i, indent)
local j = i;
local parts = {};
while true do
if not src:find("^-[\t ]", j) then break end
j = j + 2;
local data;
j, data = parse(src, j, indent, PREC_ELEMENT, nil, true, true);
if not data then return i end
table.insert(parts, e.li { data });
j, indent = skip_white(src, j, indent);
if not indent then break end
end
if #parts == 0 then return i end
return j, e.ul(parts);
end
--- @param src string
--- @param i integer
--- @param indent string
local function parse_numlist(src, i, indent)
local j = i;
local parts = {};
while true do
local num = src:match("^(%d+)%.[\t ]", j);
if not num then break end
j = j + #num + 2;
local data;
j, data = parse(src, j, indent, PREC_ELEMENT, nil, true, true);
if not data then return i end
table.insert(parts, e.li { data });
j, indent = skip_white(src, j, indent);
if not indent then break end
end
if #parts == 0 then return i end
return j, e.ol(parts);
end
local function parse_one(src, i, indent, precedence)
local res;
if precedence <= PREC_ELEMENT and not res then i, res = parse_heading(src, i, indent) end
if precedence <= PREC_ELEMENT and not res then i, res = parse_bulletlist(src, i, indent) end
if precedence <= PREC_ELEMENT and not res then i, res = parse_numlist(src, i, indent) end
if precedence <= PREC_PARAGRAPH and not res then i, res = parse_paragraph(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_bold(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_italic(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_strike(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_img(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_link(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_codeblock(src, i, indent) end
if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_code(src, i, indent) end
-- if precedence ~= PREC_PARAGRAPH and not res then i, res = parse_word(src, i) end
return i, res;
end
--- @param src string
--- @param i integer
--- @param indent string
--- @param precedence integer
--- @param eof? string
--- @param lax_eof? boolean
--- @return integer, template?
function parse(src, i, indent, precedence, eof, lax_eof, detect)
local j = i;
local elements = {};
local last_space = false;
while true do
if detect then
local _j, _indent = skip_white(src, j, indent, detect);
if not _indent then
break;
elseif _indent ~= indent then
indent = _indent;
detect = false;
end
if _j > j then
j = _j;
if not last_space then
table.insert(elements, " ");
end
last_space = false;
end
else
local _j, _indent = skip_white(src, j, indent, detect);
if not _indent then break end
indent = _indent;
if _j > j then
j = _j;
if not last_space then
table.insert(elements, " ");
end
last_space = false;
end
end
if j > #src then
if not lax_eof then return i end
break;
end
local eof_s, eof_e;
if eof then
eof_s, eof_e = src:find(eof, j);
end
eof_s = eof_s or #src + 1;
eof_e = eof_e or #src + 1;
local word = {};
while j < eof_s do
local c = src:sub(j, j);
if c == "\\" then
j = j + 1;
c = src:sub(j, j);
table.insert(word, c);
if not c:match "%s" then
j = j + 1;
end
break;
end
local res;
j, res = parse_one(src, j, indent, precedence);
if res then
if eof then
eof_s, eof_e = src:find(eof, j);
end
eof_s = eof_s or #src + 1;
eof_e = eof_e or #src + 1;
if #word > 0 then
table.insert(elements, table.concat(word));
word = {};
end
table.insert(elements, res);
break;
end
if c:match "%s" then
table.insert(word, " ");
last_space = true;
break;
end
j = j + 1;
table.insert(word, c);
precedence = PREC_TEXT;
end
if #word > 0 then
table.insert(elements, table.concat(word));
end
if j >= eof_s then
j = eof_e + 1;
break;
end
end
return j, slot(elements);
end
--- @param raw string
--- @return template
--- @return table meta
return function (raw)
local i = 1;
local res;
local meta = {};
i = skip_white(raw, i, "");
if raw:match "^%-%-%-" then
i = i + 4;
local raw_meta;
i, raw_meta = parse_raw(raw, i, "", "%-%-%-");
if not raw_meta then
i = 1;
else
assert(load(raw_meta, "=<md-meta>", "t", setmetatable({}, {
__index = function (_, k)
if meta[k] then
return meta[k];
else
return _ENV[k];
end
end,
__newindex = meta,
})))();
end
end
return slot(function (self, emit)
while i <= #raw do
i = skip_white(raw, i, "");
i, res = parse_one(raw, i, "", PREC_PARAGRAPH);
if not res then error "invalid markdown syntax" end
emit(res);
end
end), meta;
end

81
src/mime-types.lua Normal file
View File

@@ -0,0 +1,81 @@
return {
aac = "audio/aac",
abw = "application/x-abiword",
apng = "image/apng",
arc = "application/x-freearc",
avif = "image/avif",
avi = "video/x-msvideo",
azw = "application/vnd.amazon.ebook",
bin = "application/octet-stream",
bmp = "image/bmp",
bz = "application/x-bzip",
bz2 = "application/x-bzip2",
cda = "application/x-cdf",
csh = "application/x-csh",
css = "text/css",
csv = "text/csv",
doc = "application/msword",
docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
eot = "application/vnd.ms-fontobject",
epub = "application/epub+zip",
gz = "application/gzip",
gif = "image/gif",
htm = "text/html",
html = "text/html",
ico = "image/vnd.microsoft.icon",
ics = "text/calendar",
jar = "application/java-archive",
jpg = "image/jpeg",
jpeg = "image/jpeg",
js = "text/javascript",
json = "application/json",
jsonld = "application/ld+json",
md = "text/markdown",
mid = "audio/midi",
midi = "audio/midi",
mjs = "text/javascript",
mp3 = "audio/mpeg",
mp4 = "video/mp4",
mpeg = "video/mpeg",
mpkg = "application/vnd.apple.installer+xml",
odp = "application/vnd.oasis.opendocument.presentation",
ods = "application/vnd.oasis.opendocument.spreadsheet",
odt = "application/vnd.oasis.opendocument.text",
oga = "audio/ogg",
ogv = "video/ogg",
ogx = "application/ogg",
opus = "audio/ogg",
otf = "font/otf",
png = "image/png",
pdf = "application/pdf",
php = "application/x-httpd-php",
ppt = "application/vnd.ms-powerpoint",
pptx = "application/vnd.openxmlformats-officedocument.presentationml.presentation",
rar = "application/vnd.rar",
rtf = "application/rtf",
sh = "application/x-sh",
svg = "image/svg+xml",
tar = "application/x-tar",
tiff = "image/tiff",
tif = "image/tiff",
ts = "video/mp2t",
ttf = "font/ttf",
txt = "text/plain",
vsd = "application/vnd.visio",
wav = "audio/wav",
weba = "audio/webm",
webm = "video/webm",
webmanifest = "application/manifest+json",
webp = "image/webp",
woff = "font/woff",
woff2 = "font/woff2",
xhtml = "application/xhtml+xml",
xls = "application/vnd.ms-excel",
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xml = "application/xml",
xul = "application/vnd.mozilla.xul+xml",
zip = "application/zip",
["3gp"] = "video/3gpp",
["3g2"] = "video/3gpp2",
["7z"] = "application/x-7z-compressed",
};

View File

@@ -1,24 +0,0 @@
import { resolve } from "@std/path/posix";
import type View from "./views/View.ts";
import config from "./config.ts";
export type Plugin = (this: View, ...args: unknown[]) => unknown;
const plugins = new Map<string, Plugin>();
let loaded = false;
export function getPlugin(name: string) {
const res = plugins.get(name);
if (res == null) throw new Error(`Plugin ${name} not loaded (maybe you forgot to include it in the config?)`);
else return res;
}
export async function loadPlugins() {
if (loaded) return;
loaded = true;
for (const plugin of config.plugins) {
const func = (await import(resolve(config.libs + "/" + plugin + ".ts"))).default;
plugins.set(plugin, func);
}
}

77
src/resolve.lua Normal file
View File

@@ -0,0 +1,77 @@
local fs = require "sync.fs";
local http = require "http";
local markdown = require "markdown";
--- @class page
--- @field template template
--- @field meta table
--- @field config table
--- @field path string
--- @field file string
--- @param config table
---@param path string
local function resolve(config, path)
local file = package.searchpath(path, config.luapages, "/");
if file then
--- @return page
return function ()
local f = assert(fs.open(file));
local data = assert(f:read "a");
assert(f:close());
--- @type page
local t = assert(load(data, "@" .. file, "t"))();
if not t then error(file .. " must export a function") end
if type(t) == "function" then
return {
meta = {},
template = t,
path = path,
file = file,
config = config,
}
else
return {
meta = t.meta,
template = t.template,
path = path,
file = file,
config = config,
};
end
end, file;
end
local file = package.searchpath(path, config.pages, "/");
if file then
--- @return page
return function ()
local f = assert(fs.open(file));
local data = assert(f:read "a");
assert(f:close());
local res, meta = markdown(data);
local base = require(meta.base or config.pages_base);
local args = { content = { res } };
if meta.args then
for k, v in pairs(meta.args) do
args[k] = v;
end
end
return {
meta = meta,
template = base(args),
path = path,
file = file,
config = config,
};
end, file;
end
end
return resolve;

5
src/sync/entry.lua Normal file
View File

@@ -0,0 +1,5 @@
local uv = require "luv";
_ENV.uv = uv;
return require "sync".main_wrap;

203
src/sync/fs.lua Normal file
View File

@@ -0,0 +1,203 @@
local uv = require "luv";
local sync = require "sync";
--- @type fun(fd: integer, pos?: integer, whence?: "set" | "cur" | "end"): integer?, string?
local seek = require "seek";
local stream = require "sync.stream"
--- @class file
--- @field _fd integer
local file_index = {};
local read = sync.wrap(uv.fs_read, "fromcb");
local write = sync.wrap(uv.fs_write, "dummytrue");
local open = sync.wrap(uv.fs_open, "fromcb");
local close = sync.wrap(uv.fs_close, "dummytrue");
local opendir = sync.wrap(uv.fs_opendir, "fromcb");
local readdir = sync.wrap(uv.fs_readdir, "fromcb");
local closedir = sync.wrap(uv.fs_closedir, "fromcb");
local stat = sync.wrap(uv.fs_stat, "fromcb");
--- @param fmt "c" | "a" | "l" | "L" | integer
function file_index:read(fmt)
if not self._fd then return nil, "closed" end
if fmt == "c" then fmt = 1024 end
if fmt == "a" then
local curr_pos, err = seek(self._fd);
if not curr_pos then return nil, err end
local end_pos = seek(self._fd, nil, "end");
if not end_pos then return nil, err end
return read(self._fd, end_pos - curr_pos, curr_pos);
elseif fmt == "l" or fmt == "L" then
local parts = {};
while true do
local data, err = read(self._fd, 1024, nil);
if not data then return nil, err end
local i = data:find "\n";
if i then
if fmt == "l" then
table.insert(parts, data:sub(1, i - 1));
else
table.insert(parts, data:sub(1, i));
end
local _, err = seek(self._fd, -(#data - i) + 1);
if not _ then return nil, err end
break;
else
table.insert(parts, data);
if #data == 0 then break end
end
end
return table.concat(parts);
else
local data, err = read(self._fd, fmt, nil);
if not data then return nil, err end
if #data == 0 then return nil end
return data;
end
end
--- @param ... string | integer
function file_index:write(...)
if not self._fd then return nil, "closed" end
return write(self._fd, table.concat { ... }, nil);
end
function file_index:close()
local fd = self._fd;
if fd then
self._fd = nil;
return close(fd);
else
return true;
end
end
--- @param self file
local function safe_close(self)
local fd = self._fd;
if fd then
self._fd = nil;
print("WARNING: file " .. fd .. " closed by GC. Please, close your files!");
uv.fs_close(fd);
end
end
function file_index:stream()
return stream.new(
function (cb)
while true do
if not self._fd then return nil, "closed" end
local data, err = read(self._fd, 1024, nil);
if not data then return nil, err end
local ok, err = cb(data);
if not ok and err then return nil, err end
if ok == false then break end
end
return true;
end,
function (data)
if not self._fd then return nil, "closed" end
return write(self._fd, data, nil);
end,
function ()
safe_close(self);
end,
true
)
end
local file_meta = {
__index = file_index,
__gc = safe_close,
__close = file_index.close,
};
--- @return file
local function wrap_file(fd)
return setmetatable({ _fd = fd }, file_meta)
end
--- @class fs_lib
local fs = {};
--- @param path string
--- @param flags? string
--- @param mode? integer
function fs.open(path, flags, mode)
--- @type integer?, string?
local fd, err = open(path, flags or "r", mode or 0);
if not fd then return nil, err end
return wrap_file(fd);
end
--- @param fd integer
function fs.from_fd(fd)
return wrap_file(fd);
end
--- @param path string
function fs.readdir(path)
--- @type luv_dir_t?, string?
local fd, err = opendir(path);
if not fd then return nil, err end
-- local gc_tag = setmetatable({ fd = fd }, {
-- __gc = function (self)
-- uv.fs_closedir(self.fd);
-- end
-- });
--- @type uv.aliases.fs_readdir_entries[]?
local buff = nil;
--- @type integer?
local buff_i = 1;
return function ()
-- local gc_tag = gc_tag;
if not buff_i then return nil end
if buff and buff_i > #buff then
buff = nil;
end
if not buff then
local err;
buff, err = readdir(fd);
--- @cast err string?
if not buff then
buff = nil;
buff_i = nil;
--- @type true?, string?
local _, err_close = closedir(fd);
if not _ then return nil, err_close end
if err then
return nil, err;
else
return nil;
end
else
buff_i = 1;
end
end
--- @cast buff uv.aliases.fs_readdir_entries[]
local name, type = buff[buff_i].name, buff[buff_i].type;
buff_i = buff_i + 1;
return name, type;
end;
end
--- @return uv.aliases.fs_stat_table
function fs.stat(path)
return stat(path);
end
fs.stdout = fs.from_fd(0);
fs.stdin = fs.from_fd(1);
fs.stderr = fs.from_fd(2);
return fs;

204
src/sync/init.lua Normal file
View File

@@ -0,0 +1,204 @@
local tag = require "utils.tag"
local fork_traces = setmetatable({}, { __mode = "k" });
local close_tag = tag "close";
local function handle_resume(th, ok, ...)
if not ok then
print("error in thread " .. tostring(th) .. ": " .. ...);
uv.stop();
end
end
local function resume_safe(th, ...)
if coroutine.status(th) == "dead" then
print(debug.traceback("tried (somehow) to resume a dead thread", 1));
uv.stop();
return;
end
local ok, tag = handle_resume(th, coroutine.resume(th, ...));
if ok and close_tag == close_tag then
coroutine.close(th);
end
end
local function wrap_gen_handle(res, ...)
if ... == nil then
return ...;
else
return res;
end
end
--- @param func function
--- @param flow? "fromcb" | "fromfunc" | "dummytrue"
local function wrap(func, flow)
flow = flow or "fromcb";
return function (...)
local args = table.pack(...);
local res;
local cb_thread;
local function clear_cb(...)
cb_thread = nil;
return ...;
end
local function finish(...)
if ... == nil then return ... end
if res then
local _res = res;
res = nil;
return table.unpack(_res, 1, _res.n);
else
cb_thread = coroutine.running();
return clear_cb(coroutine.yield());
end
end
local function finish_fromfunc(...)
local err;
if res and res[1] ~= nil then
err = res[1];
res = nil;
else
cb_thread = coroutine.running();
err = coroutine.yield();
end
if err ~= nil then return clear_cb(nil, err) end
return clear_cb(...);
end
local function ret_cb(...)
if cb_thread then
resume_safe(cb_thread, ...);
else
res = table.pack(...);
end
end
if flow == "fromcb" then
args[args.n + 1] = function (err, ...)
if err then return ret_cb(nil, err) end
ret_cb(...);
end
return finish(func(table.unpack(args, 1, args.n + 1)));
elseif flow == "dummytrue" then
args[args.n + 1] = function (err, ...)
if err then return ret_cb(nil, err) end
ret_cb(true, ...);
end
return finish(func(table.unpack(args, 1, args.n + 1)));
elseif flow == "fromfunc" then
args[args.n + 1] = function (err)
return ret_cb(err);
end
return finish_fromfunc(func(table.unpack(args, 1, args.n + 1)));
end
end
end
--- @param func function
--- @param dummy_true? boolean
--- @return function
local function wrap_gen(func, dummy_true)
return function (...)
local args = { ... };
local cb_thread;
local buff = nil;
local function clear_cb(...)
cb_thread = nil;
return ...;
end
local function real_cb(...)
if not cb_thread then
buff = buff or {};
table.insert(buff, table.pack(...));
else
resume_safe(cb_thread, ...);
end
end
if dummy_true then
args[select("#", ...) + 1] = function (err, ...)
if err then
real_cb(nil, err);
else
real_cb(true, ...);
end
end
else
args[select("#", ...) + 1] = function (err, ...)
if err then
real_cb(nil, err);
else
real_cb(...);
end
end
end
local function res(...)
if buff and #buff > 0 then
local res = table.remove(buff, 1);
return table.unpack(res, 1, res.n);
else
cb_thread = coroutine.running();
return clear_cb(coroutine.yield());
end
end
return wrap_gen_handle(res, func(table.unpack(args)));
end
end
local function sleep(timeout)
local timer = uv.new_timer();
assert(wrap(timer.start, "dummytrue")(timer, timeout, 0));
timer:stop();
timer:close();
end
local function int_fork(no_trace, func, ...)
local th;
th = coroutine.create(function (fork_at, entry, ...)
xpcall(entry, function (err)
print(debug.traceback(tostring(err), 2) .. fork_at);
end, ...);
return close_tag;
end);
if no_trace then
resume_safe(th, "", func, ...);
else
local prev_trace = fork_traces[coroutine.running()];
local curr_trace = "fork at\n" .. debug.traceback(nil, 2):match("\n(.*)");
if prev_trace then curr_trace = prev_trace .. "\n" .. curr_trace end
fork_traces[th] = curr_trace;
resume_safe(th, "\n" .. curr_trace, func, ...);
end
return th;
end
local function fork(func, ...)
return int_fork(false, func, ...);
end
local function main_wrap(func)
return function (...)
int_fork(true, func, ...);
assert(uv.run());
end
end
return {
sleep = sleep,
wrap = wrap,
wrap_gen = wrap_gen,
fork = fork,
main_wrap = main_wrap,
};

181
src/sync/stream.lua Normal file
View File

@@ -0,0 +1,181 @@
local uv = require "luv";
local sync = require "sync";
--- @class stream
--- @field _read fun(cb: fun(data: string): boolean?, string?): true?, string?
--- @field _write fun(data: string): true?, string?
--- @field _close fun()
--- @field _cache string
--- @field _pos integer
--- @field _gc_safe boolean
local stream_index = {};
local read_start = sync.wrap_gen(uv.read_start, false);
local read_stop = uv.read_stop;
local write = sync.wrap(uv.write, "dummytrue");
local close = sync.wrap(uv.close, "dummytrue");
--- @param self stream
--- @param fmt "c" | "a" | "l" | "L" | integer
function stream_index:read(fmt)
local data = {};
local iter;
if fmt == "c" then
if self._cache then
local res = self._cache;
self._cache = nil;
return res;
else
local res;
local _, err = self._read(function (data)
res = data;
return false;
end);
if not _ then return nil, err end
return res;
end
elseif fmt == "a" then
function iter(chunk)
table.insert(data, chunk);
return true;
end
elseif fmt == "l" or fmt == "L" then
function iter(chunk)
local i = chunk:find "\n";
if i then
if fmt == "L" then
table.insert(data, chunk:sub(1, i));
else
table.insert(data, chunk:sub(1, i - 1));
end
self._cache = chunk:sub(i + 1);
return false;
end
table.insert(data, chunk);
return true;
end
else
if fmt == 0 then return "" end
local n = 0;
function iter(chunk)
n = n + #chunk;
if n == fmt then
table.insert(data, chunk);
return false;
elseif n > fmt then
table.insert(data, chunk:sub(1, fmt - n - 1));
self._cache = chunk:sub(fmt - n);
n = n - #self._cache;
return false;
end
table.insert(data, chunk);
return true;
end
end
local cache = self._cache;
self._cache = nil;
if not cache or iter(cache) then
local _, err = self._read(iter);
if not _ then return nil, err end
end
if #data == 0 then return nil end
if self._cache == "" then self._cache = nil end
return table.concat(data);
end
--- @param self stream
--- @param ... string | integer
function stream_index:write(...)
return self._write(table.concat { ... });
end
function stream_index:close()
self._gc_safe = nil;
return self._close();
end
--- @param self stream
local function safe_close(self)
if self._gc_safe == nil then return end
if self._gc_safe then
self:_close();
else
print(debug.traceback("warning: stream leaked"));
end
self._gc_safe = nil;
end
local stream_meta = {
__index = stream_index,
__gc = safe_close,
__close = stream_index.close,
};
--- @param read fun(cb: fun(data: string): boolean?, string?): true?, string?
--- @param write fun(data: string): true?, string?
--- @param close fun(sync?: boolean): true?, string?
--- @param gc_safe? boolean
--- @return stream
local function new(read, write, close, gc_safe)
return (setmetatable({
_read = read,
_write = write,
_close = close,
_cache = nil,
_gc_safe = gc_safe or false,
_pos = 0,
}, stream_meta));
end
--- @param read stream
--- @param write stream
local function combine(read, write)
return new(read._read, write._write, function ()
local ok_1, err_1 = read:close();
local ok_2, err_2 = write:close();
if not ok_1 then return nil, err_1 end
if not ok_2 then return nil, err_2 end
return true;
end, read._gc_safe and write._gc_safe);
end
local function from_uv(hnd)
return new(
function (cb)
local it, err = read_start(hnd);
if not it then return nil, err end
while true do
local chunk, err = it();
if not chunk then
if not err then break end
return nil, err;
end
local ok, err = cb(chunk);
if ok == nil then return nil, err end
if not ok then break end
end
local _, err = read_stop(hnd);
if not _ then return nil, err end
return true;
end,
function (data)
return write(hnd, data);
end,
function ()
return close(hnd);
end,
true
);
end
return { new = new, combine = combine, from_uv = from_uv, meta = stream_meta };

70
src/sync/tcp.lua Normal file
View File

@@ -0,0 +1,70 @@
local uv = require "luv";
local sync = require "sync";
local stream = require "sync.stream";
local tcp_bind = uv.tcp_bind;
local tcp_connect = sync.wrap(uv.tcp_connect, "fromfunc");
local listen = sync.wrap_gen(uv.listen, true);
local accept = uv.accept;
local wrap;
--- @class tcp_socket: stream
--- @field _hnd uv_tcp_t
local tcp_index = setmetatable({}, { __index = stream.meta.__index });
--- @param self tcp_socket
--- @param addr string
--- @param port integer
function tcp_index:bind(addr, port)
return tcp_bind(self._hnd, addr, port);
end
--- @param self tcp_socket
--- @param addr string
--- @param port integer
function tcp_index:connect(addr, port)
--- @type true?, string?
local _, err = tcp_connect(self._hnd, addr, port);
if not _ then return nil, err end
return self;
end
--- @param self tcp_socket
--- @param backlog? integer
--- @return (fun(): stream?, string?)?
--- @return string? err
function tcp_index:listen(backlog)
local it, err = listen(self._hnd, backlog or 32);
if not it then return nil, err end
return function ()
local _, err = it();
if not _ then return nil, err end
local client = uv.new_tcp();
local _, err = accept(self._hnd, client);
if not _ then return nil, err end
return wrap(client);
end
end
local tcp_meta = {
__index = tcp_index,
__gc = tcp_index.close,
__close = tcp_index.close,
};
--- @return tcp_socket
function wrap(socket)
local res = stream.from_uv(socket);
setmetatable(res, tcp_meta);
--- @cast res tcp_socket
res._hnd = socket;
return res;
end
return function ()
return wrap(uv.new_tcp());
end

119
src/template.lua Normal file
View File

@@ -0,0 +1,119 @@
--- @alias template fun(page: page, f: fun(...: string))
--- @alias emitable nil | string | emitable[] | template
--- @alias slotable nil | string | emitable[] | (fun(self: page, emit: fun(val?: emitable)): emitable?)
local html_entities = {
["&"] = "&amp;",
["\""] = "&quot;",
["<"] = "&lt;",
[">"] = "&gt;",
}
--- @param f fun(...: string)
--- @param val? emitable
local function emit(ctx, f, val)
if type(val) == "function" then
val(ctx, f);
elseif type(val) == "string" then
f((val:gsub("[&\"<>]", html_entities)));
elseif type(val) == "table" then
for i = 1, #val do
emit(ctx, f, val[i]);
end
elseif val ~= nil then
error("invalid emit value " .. type(val));
end
end
--- @param arg? slotable
--- @return template
local function slot(arg)
if type(arg) == "function" then
return function (ctx, f)
return emit(ctx, f, arg(ctx, function (t) return emit(ctx, f, t) end));
end
else
return function (ctx, f)
return emit(ctx, f, arg);
end
end
end
--- @param name string
--- @param no_body? boolean
--- @return fun(arg: string | { [integer]: emitable, [string]: string | integer }): template
local function element(name, no_body)
return function (arg)
local parts = { name };
local attribs = {};
if type(arg) == "string" then arg = { arg } end
for k in pairs(arg) do
if type(k) == "string" then
table.insert(attribs, k);
end
end
table.sort(attribs);
for i = 1, #attribs do
table.insert(parts, ("%s=%q"):format(attribs[i], tostring(arg[attribs[i]]):gsub("[&\"<>]", html_entities)));
end
local tag = table.concat(parts, " ");
if no_body then
return function (ctx, f)
f("<", tag, "/>");
end
else
return function (ctx, f)
f("<", tag, ">");
emit(ctx, f, arg);
f("</", name, ">");
end
end
end
end
local elements = {
html = element "html",
head = element "head",
body = element "body",
style = element "style",
link = element "link",
script = element "script",
meta = element "meta",
title = element "title",
div = element "div",
pre = element "pre",
p = element "p",
span = element "span",
code = element "code",
ul = element "ul",
ol = element "ol",
li = element "li",
h1 = element "h1",
h2 = element "h2",
h3 = element "h3",
h4 = element "h4",
h5 = element "h5",
h6 = element "h6",
em = element "em",
strong = element "strong",
s = element "s",
a = element "a",
button = element "button",
input = element("input", true),
img = element("img", true),
};
return { element = element, slot = slot, elements = elements };

185
src/utils/args.lua Normal file
View File

@@ -0,0 +1,185 @@
local args = {};
--- @alias consumers table<string, (fun(next: fun(): string?)) | string>
--- @param consumers consumers
--- @param name string
local function get_consumer(consumers, name)
local consumer = nil;
local path = {};
local n = 0;
while true do
local curr = consumer or name;
if path[curr] then
local path_arr = {};
for k, v in next, path do
path_arr[v] = k;
end
error("Alias to '" .. curr .. "' is recursive: " .. table.concat(path_arr, " -> "));
end
consumer = consumers[curr];
if consumer == nil then
local prefix;
if n == 0 then
prefix = "Unknown flag";
else
prefix = "Unknown alias";
end
if #curr == 1 then
error(prefix .. " '-" .. curr .. "'");
else
error(prefix .. " '--" .. curr .. "'");
end
elseif type(consumer) == "function" then
return consumer;
end
path[curr] = n;
n = n + 1;
end
end
local digest_next;
--- @param consumers consumers
--- @param name string
--- @param next fun(): string?
local function digest_arg(consumers, name, next)
get_consumer(consumers, name)(next);
return digest_next(consumers, next);
end
--- @param consumers consumers
--- @param next fun(): string?
--- @param only_rest? boolean
local function digest_rest(consumers, next, only_rest)
if not consumers[1] then
local arg = next();
if not arg then return nil end
error("Invalid argument '" .. arg .. "'");
end
local consumed = false;
local function our_consumer()
consumed = true;
return next();
end
consumers[1](our_consumer);
if not consumed then
local arg = next();
if not arg then return nil end
error("Invalid argument '" .. arg .. "'");
end
if only_rest then
return digest_rest(consumers, next, true);
else
return digest_next(consumers, next);
end
end
--- @param consumers consumers
--- @param next fun(): string?
function digest_next(consumers, next)
local arg = next();
if not arg then return nil end
if arg == "--" then
return digest_rest(consumers, next, true);
end
local name, val;
name, val = arg:match "^%-%-(.-)=(.+)$";
if name then
local function our_next()
our_next = next;
return val;
end
return digest_arg(consumers, name, function () return our_next() end);
end
name = arg:match "^%-%-(.-)$";
if name then
return digest_arg(consumers, name, next);
end
name = arg:match "^%-(.-)$";
if name then
for c in name:gmatch "." do
digest_arg(consumers, c, next);
end
return false;
end
local function our_next()
our_next = next;
return val;
end
return digest_rest(consumers, function () return our_next() end, false);
end
--- @param consumers consumers
--- @returns fun(...: string)
function args.parser(consumers)
return function (...)
local args = { ... };
local i = 0;
local function next()
i = i + 1;
return args[i];
end
return digest_next(consumers, next);
end
end
function args.simple(settings)
return function (...)
local consumers = {};
local data = {};
for name, setting_val in pairs(settings) do
local real_name = name;
if name == 1 then
real_name = "<argument>";
end
if setting_val == "bool" or setting_val == "flag" then
consumers[name] = function ()
data[name] = true;
end
elseif setting_val == "string" then
consumers[name] = function (next)
data[name] = assert(next(), "Expected string for '" .. real_name .. "'");
end
elseif setting_val == "integer" then
consumers[name] = function (next)
local raw = assert(next(), "Expected integer for '" .. real_name .. "'");
local val = assert(tonumber(raw, 10), "Expected integer for '" .. real_name .. "'");
data[name] = val;
end
elseif type(setting_val) == "function" then
consumers[name] = function (next)
data[name] = setting_val(next);
end
end
end
args.parser(consumers)(...);
return data;
end
end
return args;

17
src/utils/asserted.lua Normal file
View File

@@ -0,0 +1,17 @@
local function handle(...)
local ok, err = ...;
if not ok and err then error(err, 2) end
return ...;
end
--- @generic T
--- @param func? T
--- @param ... any
--- @return T
return function (func, ...)
func = assert(func, ...);
return function (...)
return handle(func(...));
end
end

39
src/utils/base64.lua Normal file
View File

@@ -0,0 +1,39 @@
local base64 = {};
local masks = { 0x1, 0x3, 0x7, 0xF, 0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF };
local function extract(v, from, width)
return (v >> from) & masks[width];
end
--- @param str string
function base64.encode(str)
local alphabet = {
[0] = "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"+", "/", "="
};
local parts = {};
local k, n = 1, #str;
local lastn = n % 3;
for i = 1, n-lastn, 3 do
local a, b, c = str:byte(i, i + 2);
local v = a * 0x10000 + b * 0x100 + c;
parts[k] = alphabet[extract(v, 18, 6)] .. alphabet[extract(v, 12, 6)] .. alphabet[extract(v, 6, 6)] .. alphabet[extract(v, 0, 6)];
k = k + 1;
end
if lastn == 2 then
local a, b = str:byte(n - 1, n);
local v = a * 0x10000 + b * 0x100;
parts[k] = alphabet[extract(v, 18, 6)] .. alphabet[extract(v, 12, 6)] .. alphabet[extract(v, 6, 6)] .. alphabet[64];
elseif lastn == 1 then
local v = str:byte(n) * 0x10000;
parts[k] = alphabet[extract(v, 18, 6)] .. alphabet[extract(v, 12, 6)] .. alphabet[64] .. alphabet[64];
end
return table.concat(parts);
end
return base64;

260
src/utils/json.lua Normal file
View File

@@ -0,0 +1,260 @@
local tag = require "utils.tag";
local json = {};
-- Internal functions.
local function kind_of(obj)
if type(obj) ~= "table" then return type(obj) end
if obj[json.array] then return "array" end
local i = 1;
for _ in pairs(obj) do
if obj[i] ~= nil then
i = i + 1;
else
return "table";
end
end
if i == 1 then
return "table";
else
return "array";
end
end
local function escape_str(s)
local in_char = { "\\", "\"", "/", "\b", "\f", "\n", "\r", "\t" };
local out_char = { "\\", "\"", "/", "b", "f", "n", "r", "t" };
for i, c in ipairs(in_char) do
s = s:gsub(c, "\\" .. out_char[i]);
end
return s
end
-- Returns pos, did_find; there are two cases:
-- 1. Delimiter found: pos = pos after leading space + delim; did_find = true.
-- 2. Delimiter not found: pos = pos after leading space; did_find = false.
-- This throws an error if err_if_missing is true and the delim is not found.
local function skip_delim(str, pos, delim, err_if_missing)
pos = string.find(str, "%S", pos) or pos;
if string.sub(str, pos, pos) ~= delim then
if err_if_missing then
error(table.concat { "Expected ", delim, " near position ", pos });
end
return pos, false;
end
return pos + 1, true;
end
local esc_map = { b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", v = "\v" };
-- Expects the given pos to be the first character after the opening quote.
-- Returns val, pos; the returned pos is after the closing quote character.
local function parse_str_val(str, pos, check)
if pos > #str then error("End of input found while parsing string") end
local quote = str:sub(pos, pos);
if check then
if quote ~= "\"" and quote ~= "'" then
return nil, pos;
else
pos = pos + 1;
end
else
pos = pos + 1;
end
local res = {};
while true do
local c = str:sub(pos, pos);
if c == quote then
return table.concat(res), pos + 1;
elseif c == "\\" then
local unicode = str:match("^\\u([%da-zA-Z][%da-zA-Z][%da-zA-Z][%da-zA-Z])", pos);
if unicode then
table.insert(res, require "utf8".char(tonumber(unicode, 16)));
pos = pos + 6;
else
c = str:sub(pos + 1, pos + 1);
res[#res + 1] = esc_map[c] or c;
pos = pos + 2;
end
else
res[#res + 1] = c;
pos = pos + 1;
end
end
end
-- Returns val, pos; the returned pos is after the number's final character.
local function parse_num_val(str, pos)
local num_str = string.match(str, "^-?%d+%.?%d*[eE]?[+-]?%d*", pos);
local val = tonumber(num_str);
if not val then error(table.concat { "Error parsing number at position ", pos, "." }) end
return val, pos + #num_str;
end
local function parse_impl(str, pos, end_delim)
pos = pos or 1;
if pos > #str then error("Reached unexpected end of input") end
pos = str:find("%S", pos) or pos;
local c = str:sub(pos, pos);
local delim_found;
if c == "{" then
pos = str:find("%S", pos + 1) or pos + 1;
local key;
local obj = {};
c = str:sub(pos, pos);
if c == "}" then
return obj, pos + 1;
else
while true do
pos = str:find("%S", pos) or pos;
key, pos = parse_str_val(str, pos, true);
if key == nil then error("Expected a string key") end
pos = skip_delim(str, pos, ":", true); -- true -> error if missing.
obj[key], pos = parse_impl(str, pos);
pos, delim_found = skip_delim(str, pos, "}");
if delim_found then return obj, pos end
pos, delim_found = skip_delim(str, pos, ",");
if not delim_found then error("Expected semicolon or comma") end
end
end
elseif c == "[" then
pos = pos + 1
local arr = {};
arr[json.array] = true;
local val;
delim_found = true;
while true do
val, pos = parse_impl(str, pos, "]");
if val == nil then return arr, pos end
if not delim_found then error "Comma missing between array items" end
arr[#arr + 1] = val;
pos, delim_found = skip_delim(str, pos, ",");
end
elseif c == "\"" or c == "\'" then -- Parse a string.
return parse_str_val(str, pos, false);
elseif c == "-" or c:find("%d") then -- Parse a number.
return parse_num_val(str, pos);
elseif c == end_delim then -- End of an object or array.
return nil, pos + 1;
elseif str:sub(pos, pos + 3) == "null" then
return json.null, pos + 4;
elseif str:sub(pos, pos + 3) == "true" then
return true, pos + 4;
elseif str:sub(pos, pos + 4) == "false" then
return false, pos + 5;
else
error(table.concat { "Invalid json syntax starting at position ", pos, ": ", str:sub(pos, pos + 25) });
end
end
local function stringify_impl(obj, all, indent_str, n)
local s = {}; -- We'll build the string as an array of strings to be concatenated.
local kind = kind_of(obj); -- This is 'array' if it's an array or type(obj) otherwise.
if kind == "array" then
for i, val in ipairs(obj) do
s[i] = stringify_impl(val, all, indent_str, n + 1);
end
if not indent_str then
return "[" .. table.concat(s, ",") .. "]";
elseif #s == 0 then
return "[]";
else
local indent = "\n" .. string.rep(indent_str, n + 1);
return table.concat {
"[",
indent, table.concat(s, "," .. indent),
"\n", string.rep(indent_str, n), "]"
};
end
elseif kind == "table" then
for k, v in pairs(obj) do
local sep = indent_str and ": " or ":";
local val = stringify_impl(v, all, indent_str, n + 1);
if val ~= nil then
if type(k) == "string" then
s[#s + 1] = stringify_impl(k) .. sep .. val;
elseif type(k) == "number" then
s[#s + 1] = "\"" .. k .. "\"" .. sep .. val;
elseif type(k) == "boolean" then
s[#s + 1] = "\"" .. k .. "\"" .. sep .. val;
end
end
end
if not indent_str then
return "{" .. table.concat(s, ",") .. "}";
elseif #s == 0 then
return "{}";
else
local indent = "\n" .. string.rep(indent_str, n + 1);
return table.concat {
"{",
indent, table.concat(s, "," .. indent),
"\n", string.rep(indent_str, n), "}"
};
end
return "{" .. table.concat(s, ",") .. "}";
elseif kind == "string" then
return "\"" .. escape_str(obj) .. "\"";
elseif kind == "number" then
return tostring(obj);
elseif kind == 'boolean' then
return tostring(obj);
elseif kind == "nil" then
return "null";
elseif all then
return tostring(obj);
else
return nil;
end
end
-- local indent_str = " ";
function json.stringify(obj, indent_str)
if indent_str == true then
indent_str = " ";
end
return stringify_impl(obj, false, indent_str, 0);
end
function json.pretty(obj)
return stringify_impl(obj, true, " ", 0);
end
--- This is a one-off table to represent the null value.
json.null = tag "null";
--- Used to differentiate JSON arrays and object
json.array = tag "array";
---@param str string
---@return unknown
function json.parse(str)
local obj = parse_impl(str, 1);
return obj;
end
return json;

447
src/utils/mqtt.lua Normal file
View File

@@ -0,0 +1,447 @@
---@diagnostic disable: cast-local-type
local ack_codes = {
"Connection Refused: unacceptable protocol version",
"Connection Refused: identifier rejected",
"Connection Refused: server unavailable",
"Connection Refused: bad user name or password",
"Connection Refused: not authorized",
};
local function read_uint8(data, i)
return string.unpack("!1> I1", data, i);
end
local function read_uint16(data, i)
return string.unpack("!1> I2", data, i);
end
local function read_utf8(data, i)
return string.unpack("!1> s2", data, i);
end
local function to_read(next_chunk)
local remaining = nil;
return function (n)
if n == 0 then return "" end
if next_chunk == nil then return nil end
local res = {};
local i;
if remaining ~= nil then
i = #remaining;
res[1] = remaining;
else
i = 0;
end
while i < n do
local curr = next_chunk();
if curr == nil then
remaining = nil;
next_chunk = nil;
local str = table.concat(res, "");
if str == "" then
return nil;
else
return "";
end
end
i = i + #curr;
res[#res + 1] = curr;
end
local last = table.remove(res);
local overshot = i - n;
local used = #last - overshot;
remaining = last:sub(used + 1);
res[#res + 1] = last:sub(1, used);
local val = table.concat(res, "");
assert(#val == n, "Incorrect len");
return val;
end
end
local function raw_packet_reader(next_chunk)
local reader = to_read(next_chunk);
local function read_byte()
local char = reader(1);
if char == nil then return nil end
return (string.unpack("I1", char, 1));
end
return function()
if reader == nil then return nil end
local byte = read_byte();
if byte == nil then
reader = nil;
return nil;
end
local type, flags = byte >> 4, byte & 0xF;
local len, n = 0, 0;
repeat
local curr = read_byte();
if not curr then error "Unexpected EOF" end
len = len | (curr & 0x7F) << n;
n = n + 7
until (curr & 0x80) == 0;
local body = reader(len);
return type, flags, body;
end
end
local function read_pk_connect(buff)
local i, name, version, flags, keepalive_max, client_id, will_topic, will_msg, username, password;
name, i = read_utf8(buff, 1);
if name ~= "MQIsdp" then
error(table.concat { "Protocol name is not 'MQIsdp', found '", name, "' instead" });
end
version, i = read_uint8(buff, i);
if version ~= 3 then
error(table.concat { "Protocol version is not '3', found '", version, "' instead" });
end
flags, i = read_uint8(buff, i);
local clean_session = (flags & 2) ~= 0;
local will = (flags & 4) ~= 0;
local will_qos = (flags & 24) >> 3;
local will_retain = (flags & 32) ~= 0;
local has_password = (flags & 64) ~= 0;
local has_username = (flags & 128) ~= 0;
keepalive_max, i = read_uint16(buff, i);
client_id, i = read_utf8(buff, i);
if will then
will_topic, i = read_utf8(buff, i);
will_msg, i = read_utf8(buff, i);
end
if has_username then
username, i = read_utf8(buff, i);
end
if has_password then
password, i = read_utf8(buff, i);
end
return {
type = "connect",
clean_session = clean_session,
keepalive_max = keepalive_max,
client_id = client_id,
username = username,
password = password,
will = will and {
topic = will_topic,
msg = will_msg,
qos = will_qos,
retain = will_retain
} or nil,
};
end
local function read_pk_conack(buff)
local _, code = string.unpack("!1> I1 I1", buff);
local msg;
if code == 0 then
msg = "Success";
else
msg = ack_codes[code] or "Reserved code given";
end
return {
type = "ack",
code = code,
msg = msg,
};
end
local function read_pk_publish(buff, flags)
local retain = (flags & 1) ~= 0;
local qos = (flags >> 1) & 3;
local dup = (flags & 8) ~= 0;
local topic, msg_id, i = string.unpack("!1> s2 I2", buff);
local data = buff:sub(i);
return {
type = "publish",
retain = retain,
qos = qos,
dup = dup,
topic = topic,
id = msg_id,
data = data,
};
end
local function read_pk_puback(buff, flags)
return {
type = "puback",
id = string.unpack("!1> I2", buff),
};
end
local function read_pk_pubrec(buff)
return {
type = "pubrec",
id = string.unpack("!1> I2", buff),
};
end
local function read_pk_pubrel(buff)
return {
type = "pubrel",
id = string.unpack("!1> I2", buff),
};
end
local function read_pk_pubcomp(buff)
return {
type = "pubcomp",
id = string.unpack("!1> I2", buff),
};
end
local function read_pk_subscribe(buff)
local id, i = string.unpack("!1> I2", buff);
local topics = {};
while i < #buff do
local name, qos;
name, qos, i = string.unpack("!1> s2 I1", buff, i);
topics[#topics + 1] = { name = name, qos = qos };
end
return {
type = "subscribe",
id = id,
topics = topics,
};
end
local function read_pk_suback(buff)
local id, i = string.unpack("!1> I2", buff);
local granted_qos = {};
while i < #buff do
local qos;
qos, i = string.unpack("!1> I1", buff, i);
granted_qos[#granted_qos + 1] = qos;
end
return {
type = "suback",
id = id,
granted_qos = granted_qos,
};
end
local function read_pk_unsubscribe(buff)
local id, i = string.unpack("!1> I2", buff);
local topics = {};
while i < #buff do
local name, qos;
name, i = string.unpack("!1> s2", buff, i);
topics[#topics + 1] = name;
end
return {
type = "unsubscribe",
id = id,
topics = topics,
};
end
local function read_pk_unsuback(buff)
local id, i = string.unpack("!1> I2", buff);
return {
type = "unsuback",
id = id,
};
end
local function read_pk_pingreq()
return { type = "pingreq" };
end
local function read_pk_pingresp()
return { type = "pingresp" };
end
local function read_pk_disconnect()
return { type = "disconnect" };
end
local readers = {
read_pk_connect,
read_pk_conack,
read_pk_publish,
read_pk_puback,
read_pk_pubrec,
read_pk_pubrel,
read_pk_pubcomp,
read_pk_subscribe,
read_pk_suback,
read_pk_unsubscribe,
read_pk_unsuback,
read_pk_pingreq,
read_pk_pingresp,
read_pk_disconnect,
};
local function packet_reader(next_chunk)
local raw_packets = raw_packet_reader(next_chunk);
return function ()
if raw_packets == nil then return nil end
local type, flags, buff = raw_packets();
if type == nil then
raw_packets = nil;
return nil;
end
local reader = readers[type];
if reader == nil then
error(table.concat { "Unknown packet type '", type, "'" });
end
return reader(buff, flags);
end
end
local function pk_wrap(type, flags, parts)
local res = { string.pack("!1> I1", (type << 4) | (flags & 0xF)) };
local len = 0;
for i = 1, #parts do
len = len + #parts[i];
end
repeat
local byte = len & 0x7F;
len = len >> 7;
if len ~= 0 then
byte = byte | 0x80;
end
res[#res + 1] = string.pack("!1> I1", byte);
until len == 0;
for i = 1, #parts do
res[#res + 1] = parts[i];
end
return table.concat(res);
end
local writers = {};
function writers.connect(pk)
local flags = 0;
if pk.clean_session then flags = flags | 2 end
if pk.password then flags = flags | 64 end
if pk.username then flags = flags | 128 end
if pk.will then
flags = flags | 4;
if pk.will.retian then flags = flags | 32 end
flags = flags | (pk.will.qos & 3) << 3;
end
local parts = {
string.pack("!1> s2 I1 I1 I2 s2", "MQIsdp", 3, flags, pk.keepalive_max, pk.client_id),
};
if pk.will then
parts[#parts + 1] = string.pack("!1> s2 s2", pk.will.topic, pk.will.msg);
end
if pk.username then
parts[#parts + 1] = string.pack("!1> s2", pk.username);
end
if pk.password then
parts[#parts + 1] = string.pack("!1> s2", pk.password);
end
return pk_wrap(1, 0, parts);
end
function writers.ack(pk)
return pk_wrap(2, 0, { string.pack("!1> I1 I1", 0, pk.code or 0) });
end
function writers.publish(pk)
local flags = ((pk.qos or 0) & 3) << 1;
if pk.retain then flags = flags | 1 end
if pk.dup then flags = flags | 8 end
return pk_wrap(3, flags, { string.pack("!1> s2 I2", pk.topic, pk.id), pk.data });
end
function writers.puback(pk)
return pk_wrap(4, 2, { string.pack("!1> I2", pk.id) });
end
function writers.pubrec(pk)
return pk_wrap(5, 0, { string.pack("!1> I2", pk.id) });
end
function writers.pubrel(pk)
return pk_wrap(6, 0, { string.pack("!1> I2", pk.id) });
end
function writers.pubcomp(pk)
return pk_wrap(7, 0, { string.pack("!1> I2", pk.id) });
end
function writers.subscribe(pk)
local parts = { string.pack("!1> I2", pk.id) };
for i = 1, #pk.topics do
parts[i + 1] = string.pack("!1> s2 I1", pk.topics[i].name, pk.topics[i].qos);
end
return pk_wrap(8, 2, parts);
end
function writers.suback(pk)
local parts = { string.pack("!1> I2", pk.id) };
for i = 1, #pk.topics do
parts[i + 1] = string.pack("!1> I1", pk.granted_qos[i]);
end
return pk_wrap(9, 0, parts);
end
function writers.unsubscribe(pk)
local parts = { string.pack("!1> I2", pk.id) };
for i = 1, #pk.topics do
parts[i + 1] = string.pack("!1> s2", pk.topics[i]);
end
return pk_wrap(10, 2, parts);
end
function writers.unsuback(pk)
return pk_wrap(11, 0, { string.pack("!1> I2", pk.id) });
end
function writers.pingreq()
return pk_wrap(12, 0, {});
end
function writers.pingresp()
return pk_wrap(13, 0, {});
end
function writers.disconnect()
return pk_wrap(14, 0, {});
end
local function write_packet(pk)
local writer = writers[pk.type];
if writer == nil then
error(table.concat { "Unknown packet type '", pk.type or "(nil)", "'" });
end
return writer(pk);
end
return {
read = packet_reader,
write = write_packet,
};

160
src/utils/path.lua Normal file
View File

@@ -0,0 +1,160 @@
---@diagnostic disable: cast-local-type
local exports = {};
---@param ... string
---@return string[] parts
---@return integer? i
---@return boolean dir
function exports.split(...)
--- @type integer | nil
local i = 0;
--- @type string[]
local res = {};
local dir = false;
local _, start = (... or ""):find("^/+");
if start == nil then
start = 1;
else
i = nil;
dir = true;
end
for i = 1, select("#", ...) do
local p = select(i, ...);
if p ~= nil then
for part, slashes in p:gmatch("([^/]+)(/*)", start) do
dir = #slashes > 0;
if part == ".." then
if #res == 0 then
if i ~= nil then
i = i + 1;
end
else
res[#res] = nil;
end
elseif part ~= "." and part ~= "" then
res[#res + 1] = part;
end
end
end
end
return res, i, dir;
end
---@param parts string[]
---@param i integer?
---@param dir boolean | "all"
function exports.stringify(parts, i, dir)
local a = table.concat(parts, "/");
if #parts == 0 then
if i == nil then
return dir and "/" or "/.";
elseif i == 0 then
return dir and "./" or "";
else
local res = (".."):rep(i, "/");
return dir and res .. "/" or res;
end
end
local res = i and ("../"):rep(i) or "/";
if dir then
return res .. a .. "/";
else
return res .. a;
end
end
---@param ... string
---@return string path
---@return boolean dir
function exports.join(...)
local parts, i, dir = exports.split(...);
return exports.stringify(parts, i, dir), dir;
end
---@param ... string
---@return string
function exports.join_dir(...)
local p, i = exports.split(...);
return exports.stringify(p, i, true);
end
---@param ... string
---@return string
function exports.join_file(...)
local p, i = exports.split(...);
return exports.stringify(p, i, false);
end
---@param p string
---@return boolean
function exports.is_dir(p)
return p:match "/$" ~= nil;
end
---@param ... string
---@return string
---@return boolean dir
function exports.chroot(...)
local parts, i, dir = exports.split((...));
for i = 2, select("#", ...) do
local new_parts, _, new_dir = exports.split((select(i, ...)));
dir = new_dir;
for j = 1, #new_parts do
table.insert(parts, new_parts[j]);
end
end
return exports.stringify(parts, i, dir), dir;
end
---@param ... string
---@return string
function exports.cwd(...)
local parts, i, dir = exports.split((...));
for i = 2, select("#", ...) do
local new_parts, new_i, new_dir = exports.split((select(i, ...)));
dir = new_dir;
if new_i == nil then
parts = new_parts;
i = nil;
else
for _ = 1, new_i do
parts[#parts] = nil;
end
if new_parts ~= nil then
local offset = parts and #parts or 0;
for i = 1, #new_parts do
parts[i + offset] = new_parts[i];
end
end
end
end
return exports.stringify(parts, i, dir);
end
---@param ... string
---@return string
function exports.dirname(...)
local parts, i = exports.split(...);
parts[#parts] = nil;
return exports.stringify(parts, i, true);
end
---@param ... string
---@return string
function exports.filename(...)
return table.remove(exports.split(...)) or "";
end
return exports;

263
src/utils/printing.lua Normal file
View File

@@ -0,0 +1,263 @@
local default_colors = {
func_kw = "\x1B[34m",
func_name = "\x1B[93m",
str = "\x1B[32m",
num = "\x1B[33m",
bool = "\x1B[34m",
["nil"] = "\x1B[34m",
meta = "\x1B[90m",
ref = "\x1B[91m",
reset = "\x1B[0m",
thread = "\x1B[34m",
udata = "\x1B[34m",
};
local str_escape_codes = {
["\x00"] = "\\0",
["\x01"] = "\\x01",
["\x02"] = "\\x02",
["\x03"] = "\\x03",
["\x04"] = "\\x04",
["\x05"] = "\\x05",
["\x06"] = "\\x06",
["\x07"] = "\\x07",
["\x08"] = "\\x08",
["\x09"] = "\\t",
["\x0A"] = "\\n",
["\x0B"] = "\\x0B",
["\x0C"] = "\\x0C",
["\x0D"] = "\\r",
["\x0E"] = "\\x0E",
["\x0F"] = "\\x0F",
["\x10"] = "\\x10",
["\x11"] = "\\x11",
["\x12"] = "\\x12",
["\x13"] = "\\x13",
["\x14"] = "\\x14",
["\x15"] = "\\x15",
["\x16"] = "\\x16",
["\x17"] = "\\x17",
["\x18"] = "\\x18",
["\x19"] = "\\x19",
["\x1A"] = "\\x1A",
["\x1B"] = "\\x1B",
["\x1C"] = "\\x1C",
["\x1D"] = "\\x1D",
["\x1E"] = "\\x1E",
["\x1F"] = "\\x1F",
["\\"] = "\\\\",
["\""] = "\\\"",
["\x7F"] = "\\x7F",
["\xFF"] = "\\xFF",
};
--- @alias tal.printing.color fun(color: string): fun(str: string): string, integer
--- @param tab table<string, string> | false
--- @return tal.printing.color
local function mkcolors(tab)
local function noop(v)
return v, #v;
end
if not tab then
return function ()
return noop;
end
else
local reset = tab.reset;
return function (color)
if tab[color] then
local fmt = tab[color];
return function (text)
return fmt .. text .. reset, #text;
end
else
return noop;
end
end
end
end
--- @param color tal.printing.color
--- @return string
--- @return integer text_len
local function stringify_int(obj, n, color, passed, hit)
local kind = type(obj);
if kind == "table" then
if passed[obj] then
hit[obj] = true;
return color "ref" ("<circular " .. passed[obj] .. ">");
end
passed[obj] = passed.next;
passed.next = passed.next + 1;
local tablen = #obj;
local parts = {};
local res_len = 0;
for i = 1, tablen do
parts[i] = stringify_int(obj[i], n .. "\t", color, passed, hit) .. ",";
end
local keys = {};
for k in pairs(obj) do
if type(k) ~= "number" or k < 1 or k > tablen then
table.insert(keys, k);
end
end
table.sort(keys, function (a, b)
if type(a) ~= type(b) then
return type(a) < type(b);
else
local ok, res = pcall(function (a, b) return a < b end);
if ok then return res end
return tostring(a) < tostring(b);
end
end);
for i = 1, #keys do
local k = keys[i];
local v = obj[k];
local val, val_len = stringify_int(v, n .. "\t", color, passed, hit);
if val ~= nil then
if type(k) == "string" and k:find "^[a-zA-Z_][a-zA-Z0-9_]*$" then
res_len = res_len + #k + 3 + val_len + 1;
table.insert(parts, k .. " = " .. val .. ",");
else
local key, key_len = stringify_int(k, n .. "\t", color, passed, hit);
res_len = res_len + 1 + key_len + 4 + val_len + 1;
table.insert(parts, "[" .. key .. "] = " .. val .. ",");
end
end
end
local meta = getmetatable(obj);
if meta ~= nil then
local meta_str, meta_len = stringify_int(meta, n .. "\t", color, passed, hit);
res_len = res_len + 6 + 3 + meta_len + 1;
table.insert(parts, color "meta" ("<meta>") .. " = " .. meta_str .. ",");
end
if #parts == 0 then
if hit[obj] then
return color "ref" ("<ref " .. passed[obj] .. ">") .. " {}", #("<ref " .. passed[obj] .. "> {}");
else
return "{}", 2;
end
end
local contents;
if res_len > 160 then
local indent = "\n" .. n .. "\t";
contents = indent .. table.concat(parts, indent) .. "\n" .. n;
else
contents = " " .. table.concat(parts, " "):sub(1, -2) .. " ";
end
if hit[obj] then
return color "ref" ("<ref " .. passed[obj] .. ">") .. " {" .. contents .. "}", #("<ref " .. passed[obj] .. ">") + 3 + res_len;
else
return "{" .. contents .. "}", 2 + res_len;
end
elseif kind == "function" then
local data = debug.getinfo(obj, "Sn");
local res = color "func_kw" "function";
local res_len = 8;
if data.name then
res = res .. " " .. color "func_name" (data.name);
res_len = res_len + 1 + #data.name;
end
if data.source ~= "=?" and data.source ~= "=[C]" then
res = res .. " @ " .. data.short_src;
res_len = 3 + #data.short_src;
if data.linedefined then
res = res .. ":" .. data.linedefined;
res_len = 1 + #tostring(data.linedefined);
end
end
return res, res_len;
elseif kind == "string" then
local escaped, n = obj:gsub("[\x00-\x1F\"\\\x7F\xFF]", str_escape_codes);
if n > 4 and n > #obj / 80 then
local marker = obj:match "%](%=*)%]";
for curr in obj:gmatch "%](%=*)%]" do
if not marker or #marker < #curr then
marker = curr;
end
end
if not marker then
marker = "";
else
marker = marker .. "=";
end
return color "str" ("[" .. marker .. "[" .. obj .. "]" .. marker .. "]");
else
return color "str" ("\"" .. escaped .. "\"");
end
elseif kind == "nil" then
return color "nil" ("nil");
elseif kind == "boolean" then
return color "bool" (tostring(obj));
elseif kind == "number" then
return color "num" (tostring(obj));
elseif kind == "thread" then
return color "thread" (tostring(obj));
elseif kind == "userdata" then
return color "udata" (tostring(obj));
else
error "unknown type";
end
end
--- @param colors true | false | table
local function stringify(obj, colors)
if colors == true then colors = default_colors end
return stringify_int(obj, "", mkcolors(colors), { next = 0 }, {});
end
function pprint(...)
if select("#", ...) == 0 then return end
local function fix(...)
if select("#", ...) == 0 then
return;
else
return stringify((...), true), fix(select(2, ...));
end
end
print(fix(...));
end
function print(...)
if select("#", ...) == 0 then
return;
elseif select("#", ...) == 1 then
io.stderr:write(tostring(...), "\n");
else
io.stderr:write(tostring(...), "\t");
print(select(2, ...));
end
end
return {
print = print,
pprint = pprint,
stringify = stringify,
};

11
src/utils/tag.lua Normal file
View File

@@ -0,0 +1,11 @@
return function (name)
return setmetatable({}, {
__newindex = function ()
error "Tags are read-only";
end,
__metatable = false,
__tostring = function ()
return "@tag(" .. name .. ")";
end
})
end

122
src/utils/url.lua Normal file
View File

@@ -0,0 +1,122 @@
local url = {};
function url.encode(data)
data = tostring(data);
return data:gsub("[^a-zA-Z0-9%-_]", function (c)
return "%" .. ("%.2X"):format(string.byte(c));
end);
end
function url.encode_url(data)
data = tostring(data);
return data:gsub("[^%?/a-zA-Z0-9%-_]", function (c)
return "%" .. ("%.2X"):format(string.byte(c));
end);
end
function url.decode(data)
return data:gsub("%%(%d%d)", function (val)
return string.char(assert(tonumber(val, 16)));
end);
end
function url.encode_body(body)
local parts = {};
for i = 1, #body do
table.insert(parts, url.encode(body[i][1]) .. "=" .. url.encode(body[i][2]));
end
for k, v in pairs(body) do
if type(k) == "string" then
table.insert(parts, url.encode(k) .. "=" .. url.encode(v));
end
end
return table.concat(parts, "&");
end
--- @param raw string
--- @return { scheme: string?, host?: string, port?: integer, path: string, params: table<string, string | true>, username?: string, password?: string }?
--- @return string?
function url.parse(raw)
local scheme, username, password, host, port, path, params;
local l;
local i = 1;
params = {};
scheme, l = raw:match("^([%w%d_%-]+):()", i);
i = l or i;
if raw:match("^//", i) then
i = i + 2;
username, password, l = raw:match("^(.-):(.-)@()", i);
i = l or i;
if username then
username = url.decode(username);
password = url.decode(password);
end
host, i = raw:match("^([^:/]*)()", i);
port, l = raw:match("^:(%d+)()", i);
i = l or i;
if host then
host = url.decode(host);
end
if port then
port = tonumber(port);
end
if i > #raw then
return {
scheme = scheme,
username = username,
password = password,
host = host,
port = tonumber(port),
path = "/",
params = {}
};
end
i = raw:match("^()/", i);
end
if not i then return nil, "invalid URL syntax" end
path, i = raw:match("^([^?]*)()", i);
path = url.decode(path);
if i <= #raw then
i = raw:match("^%?()", i);
if not i then return nil, "invalid URL syntax" end
for part in raw:gmatch("[^&]*", i) do
local key, val = part:match "^(.-)=(.*)$";
if not key then
key = url.decode(part);
val = true;
else
key = url.decode(key);
val = url.decode(val);
end
params[key] = val;
end
end
return {
scheme = scheme,
username = username,
password = password,
host = host,
port = port,
path = path,
params = params,
};
end
return url;

569
src/utils/xml.lua Normal file
View File

@@ -0,0 +1,569 @@
--- @alias xml_node_raw { tag: string, attribs: { [string]: string }, [integer]: xml_element }
--- @alias xml_element string | xml_node
--- @class xml_node
--- @field tag string
--- @field attribs { [string]: string }
--- @field [integer] xml_element
local xml_node = {};
xml_node.__index = xml_node;
--- @param name string?
function xml_node:get_all(name)
--- @type xml_node[]
local res = {};
for _, el in ipairs(self) do
if type(el) ~= "string" and (not name or el.tag == name) then
res[#res + 1] = el;
end
end
return res;
end
--- @param name string
function xml_node:get(name)
local res = self:get_all(name);
if #res == 0 then
error("node '" .. name .. "' not found");
elseif #res > 1 then
error("multiple nodes '" .. name .. "' exist");
else
return res[1];
end
end
--- @param name string
--- @return xml_node[]
function xml_node:query_all(name)
local tag, id, classes, attribs;
local rem = name;
local function get_tag(rem)
local tag, res_rem = rem:match "^([^%s#%.%[%]]+)%s*(.*)$";
if tag then return res_rem, tag end
return rem;
end
local function get_class(rem)
local class, res_rem = rem:match "^%.([^%s#%.%[%]]+)%s*(.*)$";
if class then return res_rem, class end
return rem;
end
local function get_id(rem)
local id, res_rem = rem:match "^#([^%s#%.%[%]]+)%s*(.*)$";
if id then return res_rem, id end
return rem;
end
local function get_attrib(rem)
local inner, res_rem = rem:match "^%[%s*([^%]]+)%s*%]%s*(.*)$";
if not inner then return rem end
local prop, val = inner:match "^(.-)%s*=%s*(.-)$";
if prop then
return res_rem, prop, val;
else
return res_rem, inner;
end
end
rem, tag = get_tag(rem);
rem, id = get_id(rem);
while true do
local class, key, val;
rem, class = get_class(rem);
if class then
classes = classes or {};
classes[class] = 0;
else
rem, key, val = get_attrib(rem);
if key then
attribs = attribs or {};
attribs[key] = val or true;
else
break;
end
end
end
if #rem > 0 then error "invalid query" end
local res = {};
local function match(el)
if type(el) == "string" then return false end
if tag and el.tag ~= tag then return false end
if id and el.attribs.id ~= id then return false end
if classes then
if not el.attribs.class then return false end
for name in el.attribs.class:gmatch "[^%s]+" do
if classes[name] then
classes[name] = classes[name] + 1;
end
end
local ok = true;
for k, v in pairs(classes) do
if classes[k] == 0 then
ok = false;
end
classes[k] = 0;
end
if not ok then return false end
end
if attribs then
for k, v in pairs(attribs) do
if v == true then
if not el.attribs[k] then return false end
else
if el.attribs[k] ~= v then return false end
end
end
end
return true;
end
for i = 1, #self do
if match(self[i]) then
table.insert(res, self[i]);
end
end
return res;
end
--- @param name string
--- @return xml_node
function xml_node:query(name)
local res = self:query_all(name);
if #res == 1 then
return res[1];
else
error("no single element with the query found", 2);
end
end
--- @param name string
--- @return xml_node
function xml_node:query_first(name)
local res = self:query_all(name);
if #res >= 1 then
return res[1];
else
return nil;
end
end
--- @return string
function xml_node:text()
if #self == 0 then
return "";
elseif #self == 1 and type(self[1]) == "string" then
--- @diagnostic disable-next-line: return-type-mismatch
return self[1];
else
error "not a text-only node";
end
end
function xml_node:to_string(level)
level = level or 0;
local indent = ("\t"):rep(level);
local lines = { self.tag .. " {" };
for k, v in pairs(self.attribs) do
table.insert(lines, indent .. "\t" .. k .. " = " .. v);
end
for i = 1, #self do
local child = self[i];
if type(child) == "string" then
table.insert(lines, indent .. "\t" .. child);
else
table.insert(lines, indent .. "\t" .. child:to_string(level + 1));
end
end
if #lines == 1 then
return self.tag .. "{ }";
end
table.insert(lines, indent .. "}");
return table.concat(lines, "\n");
end
function xml_node:__tostring()
return self:to_string();
end
--- @param raw xml_node_raw
--- @return xml_node
function xml_node.new(raw)
local res = setmetatable({
tag = raw.tag,
attribs = raw.attribs or {},
}, xml_node);
table.move(raw, 1, #raw, 1, res);
return res;
end
local function skip_spaces(raw, i)
local next_i = raw:find("[^%s]", i);
if next_i then return next_i end
if raw:find("^%s", i) then
local match = raw:match("^%s*", i);
if match then return i + #match end
else
return i;
end
end
local function parse_tag(raw, i, state)
i = skip_spaces(raw, i);
local tag = raw:match("^[%w0-9%-_:]+", i);
if tag == nil then
if state.relaxed then
return nil, i;
else
error("expected tag name near '" .. raw:sub(i, i + 25) .. "'")
end
end
i = i + #tag;
local attribs = {};
while true do
i = skip_spaces(raw, i);
local all, key, _, val = raw:match("^(([%w0-9%-_:]-)%s*=%s*(['\"])(.-)%3%s*)", i);
if all then
attribs[key] = val;
i = i + #all;
elseif state.relaxed then
all, key, val = raw:match("^(([%w0-9%-_:]+)%s*=%s*([^/<>%s]+))", i);
if all then
attribs[key] = val;
i = i + #all;
else
all, key = raw:match("^(([%w0-9%-_:]+)%s*)", i);
if all then
attribs[key] = "";
i = i + #all;
else
break;
end
end
else
end
end
return { tag = tag, attribs = attribs }, i;
end
local function parse_part(raw, i, state)
i = skip_spaces(raw, i);
local j = i;
repeat
local comment_end;
if raw:sub(i, i + 3) == "<!--" then
i = i + 4;
comment_end = raw:find("-->", i);
if comment_end then
i = comment_end + 3;
else
i = #raw + 1;
end
i = skip_spaces(raw, i);
end
until not comment_end;
local fallback = false;
if i > #raw then
return { type = "eof" }, i;
elseif (state.start or state.relaxed) and raw:sub(i, i + 1) == "<?" then
i = i + 2;
local tag;
tag, i = parse_tag(raw, i, state);
if tag then
if raw:sub(i, i + 1) == "!>" then
i = i + 2;
return { type = "version", tag = tag.tag, attribs = tag.attribs }, i;
end
elseif not state.relaxed then
error("malformed XML near '" .. raw:sub(i, i + 25) .. "'");
end
fallback = true;
elseif (state.start or state.relaxed) and raw:sub(i, i + 1) == "<!" then
i = i + 2;
local tag;
tag, i = parse_tag(raw, i, state);
if tag then
if raw:sub(i, i) == ">" then
i = i + 1;
return { type = "version", tag = tag.tag, attribs = tag.attribs }, i;
elseif not state.relaxed then
error("malformed XML near '" .. raw:sub(i, i + 25) .. "'");
end
end
fallback = true;
elseif raw:sub(i, i + 1) == "</" then
i = i + 2;
i = skip_spaces(raw, i);
local tag = raw:match("[%w0-9%-_:]+", i);
if tag then
i = i + #tag;
if raw:sub(i, i) == ">" then
i = i + 1;
return { type = "end", tag = tag }, i;
elseif not state.relaxed then
error("malformed closing tag near '" .. raw:sub(i, i + 25) .. "'");
end
elseif not state.relaxed then
error("expected closing tag name near '" .. raw:sub(i, i + 25) .. "'")
end
fallback = true;
elseif raw:sub(i, i) == "<" then
i = i + 1;
local tag;
tag, i = parse_tag(raw, i, state);
if tag then
if raw:sub(i, i + 1) == "/>" then
i = i + 2;
return { type = "small", tag = tag.tag, attribs = tag.attribs }, i;
elseif raw:sub(i, i) == ">" then
i = i + 1;
return { type = "begin", tag = tag.tag, attribs = tag.attribs }, i;
elseif not state.relaxed then
error("malformed opening tag near '" .. raw:sub(i, i + 25) .. "'");
end
end
fallback = true;
end
i = j;
local text_parts = {};
if fallback then
table.insert(text_parts, raw:sub(i, i));
i = i + 1;
end
while i <= #raw do
local text_end = raw:find("<", i);
local text_part = raw:sub(i, text_end and text_end - 1 or #raw)
:match "^%s*(.-)%s*$"
:gsub("%s+", " ");
if text_part ~= "" then
text_parts[#text_parts + 1] = text_part;
end
if not text_end then i = #raw + 1 break end
i = text_end or #raw;
local comment_end;
if raw:sub(i, i + 3) == "<!--" then
i = i + 4;
comment_end = raw:find("-->", i);
if comment_end then
i = comment_end + 3;
else
i = #raw + 1;
end
i = skip_spaces(raw, i);
else
break
end
end
if #text_parts > 0 then
return { type = "text", text = table.concat(text_parts, " ") }, i;
elseif i > #raw then
return { type = "eof" }, i;
else
error("malformed XML near '" .. raw:sub(i, i + 25) .. "'");
end
end
local function is_in_list(tag, attribs, list)
if not list then return false end
if list[tag] then
return true;
end
for i = 1, #list do
if list[i](tag, attribs) then
return true;
end
end
return false;
end
local function fix_list(list)
local res = {};
if list then
for k, v in pairs(list) do
if type(k) == "number" then
if type(v) == "function" then
table.insert(res, v);
else
res[v] = true;
end
else
res[k] = true;
end
end
end
return res;
end
--- @param raw string
--- @param settings? { relaxed?: boolean, self_closing?: { [integer]: string | (fun(tag: string, attribs: table<string, string>): boolean), [string]: true } } | "html"
--- @return xml_node
local function parse(raw, settings)
if settings == "html" then
settings = {
relaxed = true,
self_closing = {
"area", "base", "br", "col", "embed", "hr", "img", "input", "meta", "param", "source", "track", "wbr",
function (tag, attribs) return tag == "script" and attribs.src end,
function (tag, attribs) return tag == "link" and attribs.href end,
-- function (tag, attribs) end,
},
no_children = { "script", "style" },
};
end
settings = settings or {};
local state = {
start = true,
relaxed = settings.relaxed or false,
self_closing = fix_list(settings.self_closing),
no_children = fix_list(settings.no_children),
}
--- @type xml_node
local document = xml_node.new { tag = "document", attribs = {} };
local curr_node = document;
local stack = {};
local i = 1;
while true do
local part;
part, i = parse_part(raw, i, state);
if part.type == "eof" then
break;
elseif part.type == "text" then
if not state.relaxed and #stack == 0 then
error("text may not appear outside a tag (near '" .. raw:sub(i, i + 25) .. "')");
else
if type(curr_node[#curr_node]) == "string" then
curr_node[#curr_node] = curr_node[#curr_node] .. part.text;
else
table.insert(curr_node, part.text);
end
end
elseif part.type == "version" then
curr_node.attribs.type = part.tag;
curr_node.attribs.version = part.attribs.version;
elseif part.type == "begin" then
local new_node = xml_node.new { tag = part.tag, attribs = part.attribs };
table.insert(curr_node, new_node);
if is_in_list(part.tag, part.attribs, state.no_children) then
local start_i = i;
local end_part;
while true do
local end_i = i - 1;
end_part, i = parse_part(raw, i, state);
if end_part.type == "end" and end_part.tag == part.tag or end_part.type == "eof" then
table.insert(new_node, raw:sub(start_i, end_i));
break;
end
end
elseif not is_in_list(part.tag, part.attribs, state.self_closing) then
curr_node = new_node;
table.insert(stack, new_node);
end
elseif part.type == "end" then
if not state.relaxed then
if part.tag ~= curr_node.tag then
error("closing tag '" .. part.tag .. "' doesn't match most recent opening tag '" .. curr_node.tag .. "'");
else
table.remove(stack);
curr_node = stack[#stack];
end
else
local found_i;
for i = #stack, 1, -1 do
if stack[i].tag == part.tag then
found_i = i;
break;
end
end
if found_i then
for _ = #stack, found_i, -1 do
table.remove(stack);
end
curr_node = stack[#stack] or document;
end
end
elseif part.type == "small" then
curr_node[#curr_node + 1] = xml_node.new { tag = part.tag, attribs = part.attribs };
else
error "wtf";
end
end
if not state.relaxed and #stack > 0 then
error("tag '" .. curr_node.tag .. "' was left open");
end
return document;
end
return {
parse = parse,
node = xml_node.new,
};

View File

@@ -1,49 +0,0 @@
import { invoke, resolveView } from "./resolution.ts";
import View, { popLocals, pushLocals, type Locals } from "./View.ts";
export default class JSView extends View {
public override onMetadata() {
const res = [];
for (const match of this.src.matchAll(/^(?:#!.+?\n)?\s*\/\*---\n(.+)\n---\*\/$/g)) {
res.push(match[1]);
}
return res.join("\n");
}
public override onRender(locals: Locals = {}, full = false): string {
locals = this.locals(locals);
let res: any, text;
try {
pushLocals(locals);
res = invoke(this, this.path);
}
finally {
popLocals();
}
if (typeof res === "object" && res != null && typeof res[Symbol.iterator] === "function") {
res = res[Symbol.iterator]();
const parts = [];
while (true) {
const el = res.next();
if (el.value != null) parts.push(el.value);
if (el.done) break;
}
text = parts.join("");
}
else {
text = String(res ?? "");
}
if (full && locals.page) {
const parent = resolveView(String(locals.page), false, true);
return parent?.render({ ...locals, text }, full) ?? text;
}
else return text;
}
}

View File

@@ -1,54 +0,0 @@
import frontMatter from "markdown-it-front-matter";
import markdown from "markdown-it";
import hljs from "highlight.js";
import View, { type Locals, type Metadata } from "./View.ts";
import { resolveView } from "./resolution.ts";
import { parse as parseYaml } from "@std/yaml";
export default class MarkdownView extends View {
public static metadata: Metadata;
public static readonly renderer = markdown({
html: true,
xhtmlOut: false,
breaks: false,
langPrefix: 'language-',
linkify: true,
typographer: false,
quotes: '“”‘’',
highlight: (str: string, lang: string) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
}
catch {}
}
return "";
}
}).use(frontMatter, (v: Metadata) => this.metadata = v);
public override onMetadata() {
MarkdownView.renderMarkdown(this.src, { metadata: true }, false);
return MarkdownView.metadata;
}
public override onRender(locals: Locals, full = false) {
locals = this.locals(locals);
const text = MarkdownView.renderMarkdown(this.src, locals, full);
if (full && locals.page) {
const parent = resolveView(String(locals.page), false, true);
return parent?.render({ ...locals, text }, full) ?? text;
}
else return text;
}
public static renderMarkdown(text: string, _locals: object, _full: boolean): string {
this.metadata = "{}" as any;
const res = this.renderer.render(text);
this.metadata = parseYaml(this.metadata as any) as Metadata;
return res;
// return (await MarkdownView.renderer.render(text, { metadata: true })).value;
}
}

View File

@@ -1,46 +0,0 @@
import View, { type Locals } from "./View.ts";
import config from "../config.ts";
import { compile } from "pug";
import MarkdownView from "./MarkdownView.ts";
import { invoke, renderView } from "./resolution.ts";
export default class PugView extends View {
public override onMetadata() {
const src = this.src;
const lines: string[] = [];
for (const match of src.matchAll(/\/\/---\n((?: .+?\n)*)\/\/---/g)) {
lines.push(...match[1].split("\n").map(v => v.slice(4)));
}
return lines.join("\n") ?? "";
}
public override onRender(locals: Locals, full: boolean): string {
locals = this.locals(locals);
const src = this.src;
const func = compile(src, {
basedir: config.views,
compileDebug: false,
debug: config.debug,
pretty: config.debug,
doctype: 'HTML',
filters: {
md: (src: string) => MarkdownView.renderMarkdown(src, {}, false),
},
globals: [],
});
const res = func({
...locals,
invoke: invoke.bind(View, this),
});
if (full && !src.match(/^extends\s/g) && !src.match(/^doctype\s/g)) {
if (locals.page != null) return renderView(String(locals.page), { ...locals, text: res }, false, true, true)?.text ?? res;
}
return res;
}
}

View File

@@ -1,118 +0,0 @@
import { parse as parseYaml } from "@std/yaml";
import config from "../config.ts";
export type Locals = Record<string, unknown>;
export type Metadata = Record<string, unknown>;
export type ViewConstr = new (mtime: number, path: string, uri: string, dirMtime: number | undefined, dirPath: string, dirUri: string, cached: boolean) => View;
const stack: Locals[] = [];
export function pushLocals(locals: Locals) {
stack.push(locals);
}
export function popLocals() {
stack.pop();
}
export function getLocals() {
const res = stack.at(-1);
if (res == null) throw new Error("getLocals() not called in a render context");
return res;
}
export default abstract class View {
public static cache = new Map<string, View>();
private _cache?: string;
private _metadata?: Metadata;
private _src?: string;
private _timer?: number;
public get key() {
return this.path;
}
public get isIndex() {
return this.dirMtime != null;
}
public get hasCache() {
return this._cache != null;
}
public get metadata() {
if (this._metadata != null) return this._metadata;
let res = this.onMetadata();
if (typeof res === "string") res = parseYaml(res) as Metadata ?? {};
if (typeof res !== "object") res = { data: res };
if (this.cached) this._metadata = res as Metadata;
return res;
}
public get src() {
return this._src ??= Deno.readTextFileSync(this.uri);
}
public get title() {
return String(this.metadata.title ?? this.path.split("/").at(-1));
}
public get date() {
const rawDate = this.metadata.date as any;
if (rawDate == null) return undefined;
const res = new Date(rawDate);
if (res == null) return undefined;
else return res.toISOString().split("T")[0];
}
public setTimeout() {
if (this._timer != null) clearTimeout(this._timer);
if (config.maxCache != null) this._timer = setTimeout(() => {
View.cache.delete(this.key);
if (config.debug) console.log("FREE " + this.path);
}, config.maxCache * 1000);
return this;
}
public clearTimeout() {
if (this._timer != null) clearTimeout(this._timer);
return this;
}
protected abstract onMetadata(): Metadata | string;
protected abstract onRender(locals: Locals, full: boolean): string;
protected locals(locals: Locals) {
return {
title: config.defaultTitle,
page: this.isIndex ? config.defaultIndex : config.defaultPage,
...locals,
...this.metadata
};
}
public render(locals: Locals, full: boolean) {
if (this._cache != null) return this._cache;
pushLocals(locals);
try {
const res = this.onRender(locals, full);
if (this.cached) this._cache = res;
return res;
}
finally {
popLocals();
}
}
public constructor(
public readonly mtime: number,
public readonly uri: string,
public readonly path: string,
public readonly dirMtime: number | undefined,
public readonly dirUri: string,
public readonly dirPath: string,
public readonly cached = true
) { }
}

View File

@@ -1,218 +0,0 @@
import config, { isIgnored } from "../config.ts";
import type { ViewConstr } from "./View.ts";
import { dirname, normalize, resolve } from "@std/path/posix";
import MarkdownView from "./MarkdownView.ts";
import PugView from "./PugView.ts";
import JSView from "./JSView.ts";
import { getPlugin } from "../plugins.ts";
import View from "./View.ts";
let getType = (ext: string) => {
const types = {
".md": MarkdownView,
".pug": PugView,
".js": JSView,
".ts": JSView,
};
getType = ext => types[ext as keyof typeof types];
return types[ext as keyof typeof types];
}
function tryStatFile(path: string) {
try {
return Deno.statSync(path);
}
catch {
return undefined;
}
}
function tryMtime(path: string | Deno.FileInfo | undefined) {
if (typeof path === "string") {
const stat = tryStatFile(path);
if (stat?.mtime != null) return Number(stat.mtime);
else return undefined;
}
else {
if (path?.mtime != null) return Number(path.mtime);
else return undefined;
}
}
export function of(
constr: ViewConstr,
uri: string, path: string, stat: Deno.FileInfo | undefined,
dirUri: string, dirPath: string, dirStat: Deno.FileInfo | undefined | boolean,
cached = true, force = false
) {
stat ??= tryStatFile(uri);
const mtime = tryMtime(stat);
if (stat?.isFile) {
if (!force && isIgnored(path)) return undefined;
if (dirStat === true) {
dirStat = tryStatFile(dirUri) ?? undefined;
if (dirStat == null) return undefined;
}
else if (dirStat === false) dirStat = undefined;
if (typeof dirStat === "boolean") throw "fuck typescript";
const index = dirStat != null;
if (cached && View.cache.has(path)) {
const res = View.cache.get(path)!;
if (res.path === path && res.isIndex === index && res.mtime >= mtime!) {
if (dirStat != null) {
if (res.dirMtime! >= Number(dirStat.mtime)) return res.setTimeout();
}
else return res.setTimeout();
}
}
const res = new constr(mtime!, uri, path, tryMtime(dirStat), normalize(dirUri + "/"), normalize(dirPath + "/"), cached);
if (cached) {
const old = View.cache.get(path);
if (old != null) old.clearTimeout();
View.cache.set(path, res);
return res.setTimeout();
}
return res;
}
}
export function invoke(view: View, name: string, ...args: unknown[]) {
if (config.libs == null) throw new Error("No 'lib' folder specified");
return getPlugin(name).apply(view, args);
}
export function resolveView(path: string, cached = true, force = false): View | undefined {
path = resolve("/" + path);
const uri = resolve(config.views + path);
let res;
const dirStat = tryStatFile(uri);
if (dirStat?.isDirectory) {
res = [
of(MarkdownView, uri + "/index.md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(PugView, uri + "/index.pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, uri + "/index.js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, uri + "/index.ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(MarkdownView, uri + ".md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(PugView, uri + ".pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, uri + ".js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, uri + ".ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
].find(v => v != null);
if (res != null) return res;
if (config.defaultIndex != null) {
const templUri = resolve(config.views!, config.defaultIndex);
return [
of(MarkdownView, templUri + ".md", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(PugView, templUri + ".pug", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, templUri + ".js", path, undefined, uri + "/", path + "/", dirStat, cached, force),
of(JSView, templUri + ".ts", path, undefined, uri + "/", path + "/", dirStat, cached, force),
].find(v => v != null);
}
}
else {
return [
of(MarkdownView, uri + ".md", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
of(PugView, uri + ".pug", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
of(JSView, uri + ".js", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
of(JSView, uri + ".ts", path, undefined, dirname(uri) + "/", dirname(path) + "/", undefined, cached, force),
].find(v => v != null);
}
return undefined;
}
export function resolveViewFromUri(uri: string, cached = true, index = true) {
uri = resolve(uri);
const views = resolve(config.views!);
if (!uri.startsWith(views + "/")) return undefined;
const getFile = (ext: string) => {
const type = getType(ext);
if (type == null) return undefined;
const path = uri.slice(views.length, -ext.length);
const dirPath = dirname(path);
const dirUri = resolve(views + "/" + dirPath);
const dirStat = tryStatFile(dirUri);
if (dirStat?.isDirectory) {
return of(type, uri, path, stat, resolve(views + "/" + path), path, dirStat, cached);
}
else {
return of(type, uri, path, stat, dirUri, dirPath, undefined, cached);
}
};
const getDir = (ext: string) => {
const type = getType(ext);
if (type == null) return undefined;
let newUri = uri + "/index" + ext;
let fileStat = tryStatFile(newUri);
if (fileStat == null) {
if (config.defaultIndex != null) {
newUri = resolve(config.views + "/" + config.defaultIndex + ext);
fileStat = tryStatFile(newUri);
}
if (fileStat == null) return undefined;
}
const path = uri.slice(views.length);
const dirUri = uri;
return of(type, newUri, path, fileStat, dirUri, path, stat ?? undefined, cached);
};
const getIndex = (ext: string) => {
if (!index) return undefined;
if (!uri.endsWith("/index" + ext)) return undefined;
const type = getType(ext);
if (type == null) return undefined;
const dirUri = dirname(uri);
const dirPath = dirUri.slice(views.length);
const dirStat = Deno.statSync(dirUri);
return of(type, uri, dirPath, stat, dirUri, dirPath, dirStat, cached);
};
const stat = tryStatFile(uri);
let match;
if (stat == null) return undefined;
else if (match = uri.match(/index(\.(?:md|pug|js))$/)) return getIndex(match![1]);
else if ((match = uri.match(/\.(?:md|pug|js)$/)) && stat.isFile) return getFile(match![0]);
else if (stat.isDirectory) return getDir(".md") ?? getDir(".pug") ?? getDir(".js") ?? getDir(".ts");
else return undefined;
}
export function renderView(view: string, locals = {}, full = false, force = false, passthroughLocals = false) {
const res = resolveView(decodeURIComponent(view), true, force);
if (res == null) return undefined;
const cached = res.hasCache;
return {
text: res?.render({
...locals,
...(passthroughLocals ? {} : {
dir: res.dirPath,
dirUri: res.dirUri,
path: res.path,
uri: res.uri,
isIndex: res.isIndex,
})
}, full),
cached,
};
}

80
src/website-main.lua Normal file
View File

@@ -0,0 +1,80 @@
require "utils.printing";
require "template";
local sync = require "sync";
local tcp = require "sync.tcp";
local http = require "http";
local asserted = require "utils.asserted";
local fs = require "sync.fs";
local p = require "utils.path";
local resolve = require "resolve";
local mime_types = require "mime-types";
local function try_static(conn, path, config)
local f_path = package.searchpath(path, config.static, "/");
if not f_path then return false end
if fs.stat(f_path).type ~= "file" then return false end
local f <close> = assert(fs.open(f_path));
assert(http.respond(
conn, 200,
http.headers.of { ["content-type"] = mime_types[path:match "%.(.-)$"] },
f:stream()
));
return true;
end
return require "sync.entry" (function (...)
-- Why a bad pipe manages to topple a whole process in unix is beyond me
local sigpipe = assert(uv.new_signal());
uv.signal_start(sigpipe, "sigpipe", function () end);
local conf_file = ...;
local config = assert(loadfile(conf_file))();
package.loaded.config = config;
if config.path and #config.path > 0 then
package.path = package.path .. ";" .. config.path;
end
local server = tcp();
assert(server:bind("0.0.0.0", config.port));
print("Listening on :" .. config.port);
for conn in asserted(server:listen()) do
sync.fork(function ()
--- @type string, string, http_headers
local method, path, headers = assert(http.read_req(conn));
if method == "EOF" then return end
if method ~= "GET" then
assert(http.respond(conn, 405, nil, "Bad method\n"));
return conn:close();
end
if path == "/.well-known/keepalive" then
assert(http.respond(conn, 200, nil, "OK"));
return conn:close();
end
local fixed_path = p.chroot("/", path):sub(2);
if not try_static(conn, fixed_path, config) then
local get_page = resolve(config, fixed_path);
if get_page then
local page = get_page();
local body_res = assert(http.write_res(conn, 200, http.headers.of { ["content-type"] = "text/html" }, true));
page:template(function (...) body_res:write(...) end);
body_res:close();
else
assert(http.respond(conn, 404, nil, "Not found\n"));
end
end
conn:close();
end);
end
end);