├── .gitignore ├── .stylua.toml ├── LICENSE ├── README.md ├── redbean.meta.lua ├── run.sh └── src ├── .init.lua ├── .lua └── htmlua.lua └── pages └── index.html.lua /.gitignore: -------------------------------------------------------------------------------- 1 | *.com 2 | -------------------------------------------------------------------------------- /.stylua.toml: -------------------------------------------------------------------------------- 1 | indent_type = "Spaces" 2 | indent_width = 2 3 | column_width = 80 4 | call_parentheses = "Input" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NotNite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htmlua 2 | 3 | htmlua converts this: 4 | 5 | ```lua 6 | document { 7 | html { 8 | head { 9 | meta { charset = "utf-8" }, 10 | title "Hello, world!" 11 | }, 12 | 13 | body { 14 | h1 "Hello, world!", 15 | p "This is a paragraph." 16 | } 17 | } 18 | } 19 | ``` 20 | 21 | into this: 22 | 23 | ```html 24 | 25 | 26 | 27 | 28 | Hello, world! 29 | 30 | 31 |

Hello, world!

32 |

This is a paragraph.

33 | 34 | 35 | ``` 36 | 37 | ...all dynamically at request time. It is effectively serverside rendering where your output file is being rendered from Lua functions. It is written in 100% vanilla Lua and can be integrated into any Lua environment. It supports a component-like system for reusing blocks of HTML. It runs on six operating systems with a single file thanks to the [redbean](https://redbean.dev/) web server. 38 | 39 | It is suggested to read `src/pages/index.html.lua`, as it is heavily commented. 40 | 41 | ## Why? 42 | 43 | I recently read [Ben Visness](https://bvisness.me/luax/)'s article about their custom Lua dialect for JSX-like elements. I thought I could build a system just as flexible to use in vanilla Lua. 44 | 45 | ## Installation 46 | 47 | - Copy the contents of this repository into a new folder. 48 | - Download `redbean.com` from the redbean website. 49 | - Execute `./run.sh` to pack the web server and run it. 50 | 51 | ## How it works 52 | 53 | This abuses a few tricks in the Lua language spec: 54 | 55 | - Functions that take a single argument can be called without parenthesis (`ident("a")` = `ident "a"`) 56 | - Key value pairs and array-like entries into tables can be mixed (`{ a = "b", "c" }`) 57 | 58 | The keywords are actually functions in the global scope that create the HTML element and return a string. Props are identified by key/value pairs and children are identified as entries in the table. Valueluess props (such as `autoplay` on the `video` element) can be specified with `_ = { "autoplay" }`. 59 | 60 | ## Using htmlua 61 | 62 | ### Creating your first page 63 | 64 | Create `pages/index.html.lua`: 65 | 66 | ```lua 67 | return function() 68 | return document { 69 | html { 70 | head { 71 | meta { charset = "utf-8" }, 72 | title "Hello, world!" 73 | }, 74 | 75 | body { 76 | h1 "Hello, world!", 77 | p "This is a paragraph." 78 | } 79 | } 80 | } 81 | end 82 | ``` 83 | 84 | ### Adding more HTML elements 85 | 86 | Put a line like this in your `.init.lua`: 87 | 88 | ```lua 89 | h2 = htmlua.elem("h2") 90 | ``` 91 | 92 | You can provide a second argument for options for this element: 93 | 94 | - `close: boolean = true` - whether to close the element with `` 95 | - `empty: boolean = false` - whether to close the element wiith `/>` if there are no children 96 | 97 | ### Creating a reusable component 98 | 99 | ```lua 100 | local htmlua = require("htmlua") 101 | 102 | local MyComponent = htmlua.component(function(props, children) 103 | return { 104 | h1("Hello, " .. props.name .. "!"), 105 | p "This is a reusable component." 106 | } 107 | end) 108 | 109 | return function() 110 | return document { 111 | html { 112 | head { 113 | meta { charset = "utf-8" } 114 | }, 115 | 116 | body { 117 | MyComponent { name = "world" }, 118 | hr {}, 119 | MyComponent { name = "Lua" } 120 | } 121 | } 122 | } 123 | end 124 | ``` 125 | 126 | ### Routing 127 | 128 | Paths get tried in this order: 129 | 130 | - `pages/:path/index.lua` 131 | - `pages/:path/index.html.lua` 132 | - `pages/:path.html.lua` 133 | - `pages/:path.lua` 134 | 135 | ## Testimonials 136 | 137 | - "this is based i like this" - 138 | - "Stop programming" - 139 | - ":(" - 140 | - "omg Roblox" - 141 | -------------------------------------------------------------------------------- /redbean.meta.lua: -------------------------------------------------------------------------------- 1 | ---@meta 2 | 3 | ---@param data string 4 | function Write(data) end 5 | 6 | ---@param host string? 7 | ---@param path string? 8 | function Route(host, path) end 9 | 10 | ---@return string 11 | function GetPath() end 12 | 13 | ---@param path string 14 | ---@return string 15 | function EscapePath(path) end 16 | 17 | ---@param path string 18 | ---@return string? 19 | function LoadAsset(path) end 20 | 21 | ---@param name string 22 | ---@param value string 23 | function SetHeader(name, value) end 24 | 25 | ---@return string 26 | function GetMethod() end 27 | 28 | ---@param code number 29 | ---@param reason string? 30 | function ServeError(code, reason) end 31 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cp ./redbean.com ./redbean.run.com 3 | 4 | cd src 5 | zip -r ../redbean.run.com . 6 | cd .. 7 | 8 | ./redbean.run.com 9 | -------------------------------------------------------------------------------- /src/.init.lua: -------------------------------------------------------------------------------- 1 | local htmlua = require("htmlua") 2 | 3 | ---@diagnostic disable: lowercase-global 4 | -- These can optionally be passed settings for their behavior - see the README. 5 | document = htmlua.elem("!DOCTYPE html", { close = false }) 6 | html = htmlua.elem("html") 7 | head = htmlua.elem("head") 8 | meta = htmlua.elem("meta", { empty = true }) 9 | title = htmlua.elem("title") 10 | body = htmlua.elem("body") 11 | h1 = htmlua.elem("h1") 12 | h2 = htmlua.elem("h2") 13 | p = htmlua.elem("p") 14 | span = htmlua.elem("span") 15 | style = htmlua.elem("style") 16 | input = htmlua.elem("input", { empty = true }) 17 | br = htmlua.elem("br", { empty = true }) 18 | 19 | ---@type table 20 | local load_cache = {} 21 | 22 | ---@param path string 23 | local function populate_cache(path) 24 | if type(load_cache[path]) ~= "nil" then 25 | return 26 | end 27 | 28 | local paths = { 29 | path .. "/index", 30 | path .. "/index.html", 31 | path .. ".html", 32 | path, 33 | } 34 | 35 | for _, try_path in ipairs(paths) do 36 | local asset = LoadAsset("pages" .. try_path .. ".lua") 37 | if asset then 38 | local load_attempt = load(asset) 39 | if load_attempt then 40 | load_cache[path] = load_attempt() 41 | return 42 | end 43 | end 44 | end 45 | 46 | load_cache[path] = false 47 | end 48 | 49 | local function do_htmlua() 50 | local path = EscapePath(GetPath()) 51 | -- Remove trailing slash 52 | path = string.gsub(path, "/$", "") 53 | 54 | -- Reject access to /pages, as we don't want redbean to execute it 55 | if string.match(path, "^/pages") then 56 | ServeError(404) 57 | return 58 | end 59 | 60 | populate_cache(path) 61 | local handler = load_cache[path] 62 | 63 | if handler then 64 | local result = handler() 65 | 66 | SetHeader("Content-Type", "text/html; charset=utf-8") 67 | Write(result) 68 | else 69 | Route() 70 | end 71 | end 72 | 73 | function OnHttpRequest() 74 | if GetMethod() == "GET" then 75 | do_htmlua() 76 | else 77 | Route() 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/.lua/htmlua.lua: -------------------------------------------------------------------------------- 1 | local htmlua = {} 2 | 3 | ---@alias Displayable string | number | boolean 4 | ---@alias DefaultProps table 5 | ---@alias DefaultChildren Displayable[] 6 | ---@alias PropsOrChildren `P` | `C` | Displayable 7 | 8 | -- LuaLS generics seem kinda broken right now lol 9 | ---@alias Element fun(args: PropsOrChildren<`P`, `C`>?): Displayable 10 | ---@alias Component fun(props: `P`, children: `C`): Displayable | Displayable[] 11 | 12 | ---@class ElementConfig 13 | ---@field close boolean? 14 | ---@field empty boolean? 15 | 16 | ---@generic P: {} 17 | ---@generic C: {} 18 | ---@param args PropsOrChildren<`P`, `C`>? 19 | ---@return { props: `P`, children: `C`, boolean_props: string[] } 20 | function htmlua.parse(args) 21 | local props = {} 22 | local children = {} 23 | local boolean_props = {} 24 | 25 | if type(args) == "table" then 26 | for k, v in pairs(args) do 27 | if type(k) == "number" then 28 | table.insert(children, v) 29 | elseif k == "_" then 30 | for _, prop in ipairs(v) do 31 | table.insert(boolean_props, prop) 32 | end 33 | else 34 | props[k] = tostring(v) 35 | end 36 | end 37 | elseif args then 38 | table.insert(children, args) 39 | end 40 | 41 | return { 42 | props = props, 43 | children = children, 44 | boolean_props = boolean_props, 45 | } 46 | end 47 | 48 | ---@param str string 49 | ---@return string 50 | function htmlua.escape(str) 51 | local ret = str:gsub("&", "&") 52 | ret = ret:gsub("<", "<") 53 | ret = ret:gsub(">", ">") 54 | ret = ret:gsub('"', """) 55 | ret = ret:gsub("'", "'") 56 | return ret 57 | end 58 | 59 | ---@param config ElementConfig? 60 | ---@return ElementConfig 61 | local function assign_config(config) 62 | local default = { 63 | close = true, 64 | empty = false, 65 | } 66 | 67 | if config then 68 | for k, v in pairs(config) do 69 | default[k] = v 70 | end 71 | end 72 | 73 | return default 74 | end 75 | 76 | ---@generic P: { _props: string[]? } 77 | ---@generic C: {} 78 | ---@param name string 79 | ---@param config ElementConfig? 80 | ---@return Element<`P`, `C`> 81 | function htmlua.elem(name, config) 82 | local cfg = assign_config(config) 83 | ---@param args PropsOrChildren<`P`, `C`>? 84 | return function(args) 85 | local str = "<" .. name 86 | local parsed = htmlua.parse(args) 87 | 88 | for k, v in pairs(parsed.props) do 89 | local display = htmlua.display(v) 90 | if display then 91 | display = htmlua.escape(display) 92 | local entry = k .. '="' .. display .. '"' 93 | str = str .. " " .. entry 94 | end 95 | end 96 | 97 | for _, prop in ipairs(parsed.boolean_props) do 98 | str = str .. " " .. prop 99 | end 100 | 101 | if cfg.empty and #parsed.children == 0 then 102 | str = str .. " />" 103 | return str 104 | end 105 | 106 | str = str .. ">" 107 | 108 | for _, child in ipairs(parsed.children) do 109 | local display = htmlua.display(child) 110 | if display then 111 | str = str .. display 112 | end 113 | end 114 | 115 | if cfg.close then 116 | str = str .. "" 117 | end 118 | 119 | return str 120 | end 121 | end 122 | 123 | ---@param tbl (Displayable | Displayable[])[] 124 | ---@return string 125 | local function recursive_concat(tbl) 126 | local str = "" 127 | 128 | for _, v in ipairs(tbl) do 129 | str = str .. htmlua.display(v) 130 | end 131 | 132 | return str 133 | end 134 | 135 | ---@return string? 136 | function htmlua.display(value) 137 | if type(value) == "table" then 138 | return recursive_concat(value) 139 | elseif 140 | type(value) == "string" 141 | or type(value) == "number" 142 | or type(value) == "boolean" 143 | then 144 | return tostring(value) 145 | end 146 | end 147 | 148 | ---@generic P 149 | ---@generic C 150 | ---@param func Component<`P`, `C`> 151 | ---@return Element<`P`, `C`> 152 | function htmlua.component(func) 153 | ---@param args PropsOrChildren<`P`, `C`> 154 | return function(args) 155 | local parsed = htmlua.parse(args) 156 | return htmlua.display(func(parsed.props, parsed.children)) 157 | end 158 | end 159 | 160 | return htmlua 161 | -------------------------------------------------------------------------------- /src/pages/index.html.lua: -------------------------------------------------------------------------------- 1 | local htmlua = require("htmlua") 2 | 3 | -- Components can be defined with htmlua.component. They return a wrapper 4 | -- function that parses the arguments into separate props and children. 5 | -- It is suggested to store reused components in src/.lua/components, which can 6 | -- be loaded with require("components."). 7 | local HelloWorld = htmlua.component(function(props, children) 8 | return { 9 | -- "p" is a global in the Lua state (defined in .init.lua), created with 10 | -- htmlua.elem. These are actually functions that take a table as arguments 11 | -- and return a string. To add missing elements, declare them in .init.lua. 12 | h2("Hello, " .. props.name .. "!"), 13 | 14 | -- Components can either be called with a string, number, boolean, or table. 15 | p { 16 | "Your lucky number is ", 17 | -- Numbers and booleans are converted to strings. 18 | props.lucky_number, 19 | ".", 20 | -- Nil values are ignored. 21 | nil, 22 | }, 23 | 24 | -- Simply pass the children into the table to use them. Tables in tables 25 | -- will be flattened by htmlua for you. 26 | children, 27 | } 28 | end) 29 | 30 | -- Components can return strings, numbers, booleans, or tables. 31 | local UnixTimestamp = htmlua.component(function(props, children) 32 | return os.time() 33 | end) 34 | 35 | local App = htmlua.component(function(props, children) 36 | local time = os.date("%Y-%m-%d %H:%M:%S") 37 | return { 38 | h1 { 39 | -- Pass props into the table as key-value pairs. 40 | class = "header", 41 | -- Props are mixed with children. 42 | "Hello, htmlua!", 43 | }, 44 | 45 | -- p(value) and p { value } are equivalent. 46 | p { "The current time is " .. time }, 47 | 48 | -- Reuse components by calling them like elements. 49 | HelloWorld { 50 | name = "world", 51 | lucky_number = 7, 52 | span "This is a child element!", 53 | }, 54 | HelloWorld { name = "Lua", lucky_number = 42 }, 55 | 56 | input { 57 | type = "checkbox", 58 | -- Boolean props can be passed as a table of strings with the "_" key. 59 | _ = { "checked" }, 60 | 61 | "Check me!", 62 | }, 63 | 64 | -- Elements with no data must either be invoked with an empty table... 65 | br {}, 66 | -- or no arguments at all. 67 | UnixTimestamp(), 68 | 69 | -- Props are escaped automatically. 70 | p { title = 'This is a title "with quotes".', "Hover over me!" }, 71 | } 72 | end) 73 | 74 | -- htmlua files return functions that return strings. These are called every request. 75 | return function() 76 | return document { 77 | html { 78 | head { 79 | meta { charset = "utf-8" }, 80 | title "Hello, htmlua!", 81 | style [[ 82 | .header { 83 | color: blue; 84 | } 85 | ]], 86 | }, 87 | 88 | body { 89 | App {}, 90 | }, 91 | }, 92 | } 93 | end 94 | --------------------------------------------------------------------------------