From c8a7a33717d71219617632a811e80dca13da978f Mon Sep 17 00:00:00 2001 From: robin Date: Sat, 8 Aug 2026 16:47:11 +0200 Subject: [PATCH] refactor: render fragments with zig --- src/fragment.zig | 220 ++++++++++++++++++++++++++++++++++++++ src/gen.zig | 23 ++-- src/lua/lua_display.zig | 4 +- src/lua/lua_str.zig | 46 +++++++- src/lua/runtime/_meta.lua | 8 +- src/lua/runtime/h.lua | 27 ++--- src/lua/runtime/maivi.lua | 128 +++++----------------- src/router.zig | 15 ++- 8 files changed, 322 insertions(+), 149 deletions(-) create mode 100644 src/fragment.zig diff --git a/src/fragment.zig b/src/fragment.zig new file mode 100644 index 0000000..c8a9330 --- /dev/null +++ b/src/fragment.zig @@ -0,0 +1,220 @@ +const std = @import("std"); + +const zlua = @import("zlua"); +const Lua = zlua.Lua; +const fern = @import("fern"); + +const display = @import("lua/lua_display.zig"); +const str = @import("lua/lua_str.zig"); +const maivi = @import("root.zig"); + +// void elements +// https://developer.mozilla.org/en-US/docs/Glossary/Void_element +const VOID_ELEMENTS = [_][]const u8{ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "wbr", +}; + +const ElementConfig = struct { + close: bool = true, + empty: bool = false, + + pub fn default(ntype: ?[]const u8) @This() { + const name = ntype orelse return .{}; + + for (VOID_ELEMENTS) |n| if (std.mem.eql(u8, name, n)) return .{ + .close = false, + }; + + return .{}; + } +}; + +pub const Error = error{ + ExpectedTable, + ExpectedString, +} || error{ + LuaTableMissingValue, + LuaValueNotATable, + LuaRuntime, + LuaMsgHandler, + UnexpectedStackSize, +} || std.Io.Writer.Error || std.mem.Allocator.Error; +fn elem(l: *Lua, w: *std.Io.Writer) Error!void { + const stack_size_on_entry = l.getTop(); + + if (!l.isTable(-1)) return error.ExpectedTable; + + const name: ?[]u8 = blk: { + const ltype_type = l.getField(-1, "type"); + defer l.pop(1); + if (ltype_type == .nil) break :blk null; + if (ltype_type != .string) return error.ExpectedString; + + l.pushValue(-1); + defer l.pop(1); + const namez = try l.toString(-1); + break :blk try maivi.allocator.dupe(u8, namez); + }; + defer if (name) |m| maivi.allocator.free(m); + + const config: ElementConfig = blk: { + l.getMetatable(-1) catch break :blk null; + defer l.pop(1); + + const ltype_config = l.getField(-1, "config"); + defer l.pop(1); + if (ltype_config == .nil) break :blk null; + if (ltype_config != .table) return error.ExpectedTable; + break :blk try l.toStruct(ElementConfig, null, false, -1); + } orelse .default(name); + + if (name) |n| { + try w.writeByte('<'); + try w.writeAll(n); + } + + // props + props: { + const ltype_props = l.getField(-1, "props"); + defer l.pop(1); + if (ltype_props == .nil) break :props; + if (ltype_props != .table) return error.ExpectedTable; + + try writeProps(l, w); + } + + const nchildren: usize = nchildren: { + const ltype_children = l.getField(-1, "children"); + if (ltype_children == .nil) break :nchildren 0; + if (ltype_children != .table) return error.ExpectedTable; + break :nchildren l.lenRaw(-1); + }; + // -1 => children; -2 => elem + + // empty + if (config.empty and nchildren == 0) { + if (name != null) try w.writeAll(" />"); + l.pop(1); // pops "children" + + try w.flush(); + return; + } + if (name != null) try w.writeByte('>'); + + // children + try writeChildren(l, w); + l.pop(1); + + // close + if (config.close) if (name) |n| { + try w.writeAll("'); + }; + + if (l.getTop() != stack_size_on_entry) return error.UnexpectedStackSize; + + try w.flush(); +} + +fn writeProps(l: *Lua, w: *std.Io.Writer) !void { + var arena: std.heap.ArenaAllocator = .init(maivi.allocator); + defer arena.deinit(); + var allocator = arena.allocator(); + + l.pushValue(-1); + defer l.pop(1); + + l.pushNil(); + // stack now contains: -1 => nil; -2 => table + + while (l.next(-2)) { + // stack now contains: -1 => value; -2 => key; -3 => table + + try w.writeByte(' '); + + const k = k: { + l.pushValue(-2); + defer l.pop(1); + + const kz = try l.toString(-1); + break :k try allocator.dupe(u8, kz); + }; + + // uses -1 => value + _ = try display.attr(l); + // stack now contains: -1 => value (parsed); -2 => value; -3 => key; -4 => table + + // uses -1 => value (parsed) + _ = try str.escape(l); + // stack now contains: -1 => value (escaped); -2 => value (parsed); -3 => value; -4 => key; -5 => table + + const vz = try l.toString(-1); + const v = try allocator.dupe(u8, vz); + + try w.writeAll(k); + try w.writeByte('='); + try w.writeByte('"'); + try w.writeAll(v); + try w.writeByte('"'); + + // pop value + parsed + escaped, leaving original key + l.pop(3); + // stack now contains: -1 => key; -2 => table + } +} + +fn writeChildren(l: *Lua, w: *std.Io.Writer) !void { + var arena: std.heap.ArenaAllocator = .init(maivi.allocator); + defer arena.deinit(); + var allocator = arena.allocator(); + + l.pushValue(-1); + defer l.pop(1); + + l.pushNil(); + // stack now contains: -1 => nil; -2 => table + + while (l.next(-2)) { + // stack now contains: -1 => value; -2 => key; -3 => table + + if (!l.isTable(-1)) { + // uses -1 => value + _ = try display.elem(l); + // stack now contains: -1 => value (parsed); -2 => value; -3 => key; -4 => table + + // uses -1 => value (parsed) + _ = try str.escape(l); + // stack now contains: -1 => value (escaped); -2 => value (parsed); -3 => value; -4 => key; -5 => table + + const vz = try l.toString(-1); + const v = try allocator.dupe(u8, vz); + + try w.writeAll(v); + + l.pop(2); // pop parsed + escaped + } else { + // uses -1 => value + try elem(l, w); + } + + // pop value, leaving original key + l.pop(1); + // stack now contains: -1 => key; -2 => table + } +} + +pub fn render(l: *Lua, w: *std.Io.Writer) !void { + try elem(l, w); +} diff --git a/src/gen.zig b/src/gen.zig index 7e7787f..7d8d8e5 100644 --- a/src/gen.zig +++ b/src/gen.zig @@ -54,22 +54,25 @@ pub fn generate() !void { } fn genpath(io: Io, dir: Io.Dir, path: []const u8) !?void { - const str = try maivi.router.get(maivi.lua.lua, maivi.allocator, path) orelse return null; - defer maivi.allocator.free(str); - - const outpath_base = if (path.len == 1) "." else path[1..]; - var it = std.mem.splitBackwardsScalar(u8, path, '/'); - const outpath = if (!std.mem.containsAtLeast(u8, it.first(), 1, ".")) - try std.mem.join(allocator, "/", &.{ outpath_base, "index.html" }) - else - outpath_base; + const outpath = blk: { + const outpath_base = if (path.len == 1) "." else path[1..]; + var it = std.mem.splitBackwardsScalar(u8, path, '/'); + break :blk if (!std.mem.containsAtLeast(u8, it.first(), 1, ".")) + try std.mem.join(allocator, "/", &.{ outpath_base, "index.html" }) + else + outpath_base; + }; fern.info().ctx("gen.writepath") .str("path", path) .str("outpath", outpath).log(); if (std.mem.cutScalarLast(u8, outpath, '/')) |outpath_parts| try mkdir(dir, outpath_parts[0]); const file = try dir.createFile(io, outpath, .{ .lock = .exclusive }); - try file.writeStreamingAll(io, str); + + var buf: [0x1000]u8 = undefined; + var writer = file.writer(io, &buf); + + try maivi.router.get(maivi.lua.lua, &writer.interface, path) orelse return null; } // helpers ==================================================================== diff --git a/src/lua/lua_display.zig b/src/lua/lua_display.zig index 8eee8ff..38396b3 100644 --- a/src/lua/lua_display.zig +++ b/src/lua/lua_display.zig @@ -18,7 +18,7 @@ pub fn lua_opendisplay(l: *Lua) !i32 { return 1; } -fn elem(l: *Lua) !i32 { +pub fn elem(l: *Lua) !i32 { if (l.isFunction(-1)) { try maivi.lua.pcall(l, .{ .results = 1 }); return try elem(l); @@ -39,7 +39,7 @@ fn elem(l: *Lua) !i32 { return 1; } -fn attr(l: *Lua) !i32 { +pub fn attr(l: *Lua) !i32 { if (l.isTable(-1)) { return try recursive_concat(l, " "); } diff --git a/src/lua/lua_str.zig b/src/lua/lua_str.zig index 8e46c94..31ace6f 100644 --- a/src/lua/lua_str.zig +++ b/src/lua/lua_str.zig @@ -15,10 +15,13 @@ pub fn lua_openstr(l: *Lua) !i32 { l.pushFunction(zlua.wrap(split)); l.setField(-2, "split"); + l.pushFunction(zlua.wrap(escape)); + l.setField(-2, "escape"); + return 1; } -fn len(l: *Lua) !i32 { +pub fn len(l: *Lua) !i32 { if (!l.isString(-1)) return error.ExpectedString; l.pushValue(-1); @@ -33,7 +36,7 @@ fn len(l: *Lua) !i32 { return 1; } -fn split(l: *Lua) !i32 { +pub fn split(l: *Lua) !i32 { if (!l.isString(-1)) return error.ExpectedString; if (!l.isString(-2)) return error.ExpectedString; @@ -65,3 +68,42 @@ fn split(l: *Lua) !i32 { return 1; } + +pub fn escape(l: *Lua) !i32 { + const stack_size_on_entry = l.getTop(); + + const strz = str: { + l.pushValue(-1); + defer l.pop(1); + if (!l.isString(-1)) return error.ExpectedString; + + break :str try l.toString(-1); + }; + + var result: std.ArrayList(u8) = try .initCapacity(maivi.allocator, 0x100); + defer result.deinit(maivi.allocator); + + for (strz) |char| { + const escaped: ?[]const u8 = switch (char) { + '<' => "<", + '>' => ">", + '&' => "&", + '"' => """, + else => null, + }; + + if (escaped) |e| + try result.appendSlice(maivi.allocator, e) + else + try result.append(maivi.allocator, char); + } + + const escaped = try result.toOwnedSlice(maivi.allocator); + defer maivi.allocator.free(escaped); + + if (l.getTop() != stack_size_on_entry) return error.UnexpectedStackSize; + + _ = l.pushString(escaped); + + return 1; +} diff --git a/src/lua/runtime/_meta.lua b/src/lua/runtime/_meta.lua index 4feaaac..d9c4660 100644 --- a/src/lua/runtime/_meta.lua +++ b/src/lua/runtime/_meta.lua @@ -23,10 +23,6 @@ _G.maivi.router = ... _G.maivi.display = ... ----@param str string ----@return string -function _G.maivi.escape(str) end - ---@param str string ---@return integer function _G.maivi.str.len(str) end @@ -36,6 +32,10 @@ function _G.maivi.str.len(str) end ---@return string[] function _G.maivi.str.split(str, sep) end +---@param str string +---@return string +function _G.maivi.str.escape(str) end + ---@param v any ---@return string function _G.maivi.display.elem(v) end diff --git a/src/lua/runtime/h.lua b/src/lua/runtime/h.lua index 7e25f3a..d83169a 100644 --- a/src/lua/runtime/h.lua +++ b/src/lua/runtime/h.lua @@ -7,21 +7,6 @@ h.comment = function(str) return string.format("", str) end --- void elements --- https://developer.mozilla.org/en-US/docs/Glossary/Void_element -h.area = maivi.elem("area", { close = false }) -h.base = maivi.elem("base", { close = false }) -h.br = maivi.elem("br", { close = false }) -h.col = maivi.elem("col", { close = false }) -h.embed = maivi.elem("embed", { close = false }) -h.hr = maivi.elem("hr", { close = false }) -h.img = maivi.elem("img", { close = false }) -h.input = maivi.elem("input", { close = false }) -h.link = maivi.elem("link", { close = false }) -h.meta = maivi.elem("meta", { close = false }) -h.source = maivi.elem("source", { close = false }) -h.wbr = maivi.elem("wbr", { close = false }) - -- svg h.path = maivi.elem("path", { empty = true }) @@ -32,18 +17,18 @@ h.stylesheet = function(args) end return maivi.defaulttable(h, maivi.elem, { - __call = function(_, tagorelem, elem) + __call = function(_, tagorchildren, children) local tag - if not elem then - elem = tagorelem + if not children then + children = tagorchildren else - tag = tagorelem + tag = tagorchildren end if tag then - return maivi.elem(tag)(elem) + return maivi.elem(tag)(children) else - return maivi.display.elem(elem) + return maivi.elem(tag)(children) end end, }) diff --git a/src/lua/runtime/maivi.lua b/src/lua/runtime/maivi.lua index 3e8fe94..68cc14e 100644 --- a/src/lua/runtime/maivi.lua +++ b/src/lua/runtime/maivi.lua @@ -25,39 +25,6 @@ function maivi.defaulttable(t, f, mt) return setmetatable(t, mt_) end ----@param str string ----@return string -function maivi.escape(str) - local s = Iter.str(str) - :map(function(_, char) - if char == "<" then - return "<" - elseif char == ">" then - return ">" - elseif char == "&" then - return "&" - elseif char == '"' then - return """ - end - return char - end) - :to() - return table.concat(s) -end - --- maivi.str = maivi.str or {} --- --- function maivi.str.split(str, sep) --- if sep == nil then --- sep = "%s" --- end --- local t = {} --- for s in string.gmatch(str, "(.-)(" .. sep .. ")") do --- table.insert(t, s) --- end --- return t --- end - -- elem ======================================================================= ---@alias Displayable string | number | boolean @@ -66,9 +33,11 @@ end ---@alias PropsOrChildren { [string]: Displayable, [integer]: Displayable } ---| Displayable ----@alias VNode Displayable | Displayable[] ----@alias Element fun(args: PropsOrChildren?): Displayable ----@alias Component fun(props: Props, children: Children): VNode +---@alias VNode { type: ElementType, props: PropsOrChildren } +---@alias ElementType string | Component +---@alias Element VNode | Component +---@alias Component fun(props: Props, children: ComponentChild[]): Element +---@alias ComponentChild VNode | Displayable ---@class ElementConfig ---@field close? boolean @@ -79,6 +48,27 @@ end ---@field children Children ---@field boolean_props string[] +---@param etype ElementType? +---@param props Props +---@param children ComponentChild[] +function maivi.h(etype, props, children) + if type(etype) == "function" then + return etype(props, children) + end + return { type = etype, props = props, children = children } +end + +---@param etype ElementType +---@param config? ElementConfig +function maivi.elem(etype, config) + return function(args) + local parsed = maivi.parse(args) + return setmetatable(maivi.h(etype, parsed.props, parsed.children), { + config = config, + }) + end +end + ---@param args PropsOrChildren ---@return ParsedArgs function maivi.parse(args) @@ -116,76 +106,12 @@ function maivi.parse(args) } end ----@param config? ElementConfig ----@return ElementConfig -local function assign_config(config) - local default = { - close = true, - empty = false, - } - - if config then - for k, v in pairs(config) do - default[k] = v - end - end - - return default -end - ----@param name string ----@param config? ElementConfig ----@return Element -function maivi.elem(name, config) - local cfg = assign_config(config) - return function(args) - local str = "<" .. name - local parsed = maivi.parse(args) - - for k, v in pairs(parsed.props) do - local s = maivi.display.attr(v) - if s then - s = maivi.escape(s) - local entry = k .. '="' .. s .. '"' - str = str .. " " .. entry - end - end - - for _, prop in ipairs(parsed.boolean_props) do - str = str .. " " .. prop - end - - if cfg.empty and #parsed.children == 0 then - str = str .. " />" - return str - end - - str = str .. ">" - - for _, child in ipairs(parsed.children) do - local s = maivi.display.elem(child) - if s then - str = str .. s - end - end - - if cfg.close then - str = str .. "" - end - - return str - end -end - -- components ================================================================= ---@param f Component ---@return Element function maivi.component(f) - return function(args) - local parsed = maivi.parse(args) - return maivi.display.elem(f(parsed.props, parsed.children)) - end + return maivi.elem(f, {}) end ---@param f fun(slots: table): Component diff --git a/src/router.zig b/src/router.zig index a552e65..f9ff265 100644 --- a/src/router.zig +++ b/src/router.zig @@ -8,6 +8,8 @@ const Lua = zlua.Lua; const httpz = @import("httpz"); +const fragment = @import("fragment.zig"); + const Self = @This(); const Path = struct { @@ -111,7 +113,7 @@ pub fn handle(h: *maivi.Handler, req: *maivi.helpers.Request, res: *maivi.helper try lhandle(maivi.lua.lua, req, res) orelse return h.notFound(req.request, res.response); } -pub fn get(self: *Self, l: *Lua, allocator: std.mem.Allocator, path: []const u8) !?[]const u8 { +pub fn get(self: *Self, l: *Lua, writer: *std.Io.Writer, path: []const u8) !?void { const data = self.paths.get(path) orelse return null; // call handler function @@ -154,16 +156,11 @@ pub fn get(self: *Self, l: *Lua, allocator: std.mem.Allocator, path: []const u8) try maivi.lua.pcall(l, .{ .args = 1, .results = 1 }); - // return html string - const str = try l.toString(-1); - return try allocator.dupe(u8, str); + try fragment.render(l, writer); } fn lhandle(l: *Lua, req: *maivi.helpers.Request, res: *maivi.helpers.Response) !?void { - const buf = try maivi.router.get(l, res.response.arena, req.path) orelse return null; - errdefer res.response.arena.free(buf); + const writer = res.response.writer(); + try maivi.router.get(l, writer, req.path) orelse return null; res.response.content_type = maivi.helpers.content_type_for_file(req.path) orelse .HTML; - var writer = res.response.writer(); - _ = try writer.write(buf); - try writer.flush(); } -- 2.51.2