├── .busted ├── .editorconfig ├── config.ld ├── license ├── readme.md ├── skooma-dev-2.rockspec ├── skooma ├── dom.lua ├── env.lua ├── html.lua ├── init.lua ├── serialise.lua ├── slotty.lua └── xml.lua ├── spec ├── skooma_spec.moon └── slotty_spec.moon └── tasks.lua /.busted: -------------------------------------------------------------------------------- 1 | return { 2 | _all = { 3 | lpath = "?.lua" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | charset = utf-8 4 | trim-trailing_whitespace = true 5 | insert_final_newline = true 6 | indent_style = tab 7 | 8 | -------------------------------------------------------------------------------- /config.ld: -------------------------------------------------------------------------------- 1 | readme = "readme.md" 2 | format = "discount" 3 | file = { "skooma" } 4 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Skooma 2 | ================================================================================ 3 | 4 | A functional library for HTML and XML generation in Lua. 5 | 6 | Why? 7 | ---------------------------------------- 8 | 9 | Because HTML sucks and most existing templating systems end up being glorified 10 | string interpolation. This means you're still writing HTML at the end of the 11 | day. 12 | 13 | Additionally, HTML and XML are not trivial to parse, so transforming it is a 14 | lot easier with a simplified DOM-like tree structure than in text form. 15 | 16 | How? 17 | ---------------------------------------- 18 | 19 | Skooma is dead simple: every function returns a tree. No side effects. 20 | 21 | After you're done applying whatever transformations to the ast, another function 22 | serializes it into HTML, which you can then use however you want. 23 | 24 | When should I use this? 25 | ---------------------------------------- 26 | 27 | Whenever you: 28 | 29 | - Want to avoid writing actual HTML at all costs 30 | - Want to further transform your HTML structure before sending it to a user 31 | - Want to build small re-usable components close to where they're used 32 | 33 | When should I *not* use this? 34 | ---------------------------------------- 35 | 36 | If you want 37 | 38 | - Logic-less templating that you can trust your users with 39 | - A simple and close-to-html templating language that you can learn easily 40 | - Pain 41 | 42 | Then you'd probably be better off with a more traditional templating solution. 43 | A few recommendations in this case would be [Lustache][lustache], [Cosmo][cosmo] 44 | and [etLua][etlua] 45 | 46 | Examples 47 | ---------------------------------------- 48 | 49 | A simple example in [Yuescript][yuescript] 50 | 51 | skooma = require 'skooma' 52 | html = skooma.env 'html' 53 | 54 | link = => html.a @, href: "/#{@lower!}" 55 | list = => html.ul [html.li link item for item in *@] 56 | tree = html.html list 57 | * "Foo" 58 | * "Bar" 59 | * "Baz" 60 | 61 | print tree 62 | 63 | A similar snippet in Lua: 64 | 65 | local skooma = require 'skooma' 66 | local html = skooma.env 'html' 67 | 68 | local function map(fn, tab) 69 | local new = {} 70 | for i, value in ipairs(tab) do 71 | new[i]=fn(value) 72 | end 73 | return new 74 | end 75 | 76 | local tree do 77 | local function link(text) 78 | return html.a(text, {href="/"..string.lower(text)}) 79 | end 80 | 81 | local function list(items) 82 | return html.ul( 83 | map(html.li, map(link, items)) 84 | ) 85 | end 86 | 87 | tree = html.html(list{"Foo", "Bar", "Baz"}) 88 | end 89 | 90 | print(tree) 91 | 92 | The resulting HTML, formatted a bit, should look something like this: 93 | 94 | 95 | 106 | 107 | 108 | I want to output a different format 109 | ---------------------------------------- 110 | 111 | Instead of a named format like `"xml"` or `"html"` you can also pass in a custom 112 | serialise function during environment creation or while rendering an element. 113 | 114 | local alwaysfoo = skooma.env(function(root) return "foo" end) 115 | local root = alwaysfoo.h1(alwaysfoo.b("Bold title")) 116 | print(root) -- Just prints "foo" 117 | 118 | Custom serialisers should return a sequence of strings. This is to avoid 119 | needless concatenations when streaming content. Any return value that isn't 120 | a table will be wrapped in one after being `tostring`d. 121 | 122 | [cosmo]: https://github.com/LuaDist/cosmo 123 | [etlua]: https://github.com/leafo/etlua 124 | [yuescript]: http://yuescript.org 125 | [moonxml]: http://github.com/darkwiiplayer/moonxml 126 | [lustache]: https://github.com/Olivine-Labs/lustache 127 | -------------------------------------------------------------------------------- /skooma-dev-2.rockspec: -------------------------------------------------------------------------------- 1 | package = "skooma" 2 | version = "dev-2" 3 | source = { 4 | url = "git+http://git@github.com/darkwiiplayer/skooma" 5 | } 6 | description = { 7 | summary = "A library for generating HTML functionally", 8 | detailed = [[ 9 | Allows HTML and XML structures to be built from pure functions that return nodes which can easily be modified before serialising. 10 | ]], 11 | homepage = "https://github.com/darkwiiplayer/skooma", 12 | license = "Public Domain" 13 | } 14 | dependencies = { 15 | } 16 | build = { 17 | type = "builtin", 18 | modules = { 19 | ["skooma"] = "skooma/init.lua", 20 | ["skooma.dom"] = "skooma/dom.lua", 21 | ["skooma.env"] = "skooma/env.lua", 22 | ["skooma.html"] = "skooma/html.lua", 23 | ["skooma.serialise"] = "skooma/serialise.lua", 24 | ["skooma.slotty"] = "skooma/slotty.lua", 25 | ["skooma.xml"] = "skooma/xml.lua", 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /skooma/dom.lua: -------------------------------------------------------------------------------- 1 | --- Simple DOM generation library 2 | -- @module skooma.dom 3 | 4 | local dom = {} 5 | 6 | dom.name = {token="Unique token to store a tag name"} 7 | local NAME = dom.name 8 | 9 | --- Creates a new raw string object. 10 | -- Raw strings will not be escaped by the serialiser and instead inserted as is. 11 | function dom.raw(...) 12 | return { [NAME] = dom.raw, ... } 13 | end 14 | 15 | dom.format = {token="Unique token to stora a tags format"} 16 | local FORMAT = dom.format 17 | 18 | --- Table representing a DOM node. 19 | -- Integer keys are child elements. 20 | -- String keys are properties. 21 | -- The special tokens `dom.name` and `dom.format` serve as keys 22 | -- for the nodes name and its format (html, xml, etc.) respectively. 23 | -- @table node 24 | 25 | --- Renders a dom node 26 | -- @tparam node node 27 | -- @param node Format name or custom serialiser function 28 | function dom.render(node, format) 29 | format = format or node[FORMAT] or 'xml' 30 | 31 | if type(format) == "function" then 32 | local result = format(node) 33 | 34 | if type(result) == "table" then 35 | return result 36 | else 37 | return {tostring(result)} 38 | end 39 | else 40 | local serialise = require 'skooma.serialise' 41 | return serialise[format](node) 42 | end 43 | end 44 | 45 | dom.meta = { 46 | __index = dom, 47 | __call=dom.render, 48 | __tostring=function(self) 49 | return table.concat(dom.render(self)) 50 | end 51 | } 52 | 53 | --- Like `next`, but for `node` attributes (string keys). 54 | function dom.next_attribute(node, previous) 55 | local key, value = next(node, previous) 56 | if key then 57 | if type(key)=="string" then 58 | return key, value 59 | else 60 | return dom.next_attribute(node, key) 61 | end 62 | end 63 | end 64 | 65 | --- like `pairs` but for `node` attributes (string keys). 66 | function dom.attributes(node) 67 | return dom.next_attribute, node, nil 68 | end 69 | 70 | --- Inserts a subtree into a node. 71 | -- Non-table values and `node`s are inserted as-is. 72 | -- Tables are iterated and their integer keys are inserted into the 73 | -- given node in order while string keys are set as attributes. 74 | function dom.insert(node, subtree) 75 | if type(subtree)~="table" or subtree[NAME] or subtree[NAME]==false then 76 | table.insert(node, subtree) 77 | else 78 | for _, element in ipairs(subtree) do 79 | dom.insert(node, element) 80 | end 81 | for key, value in dom.attributes(subtree) do 82 | node[key]=value 83 | end 84 | end 85 | end 86 | 87 | --- Creates a new DOM node. 88 | -- @tparam string name Tag name of the new node 89 | -- @tparam string format Serialisation format of the new node (html, xml, etc.). 90 | -- Defaults to whatever is set in the environment, or 'xml' if nothing is set. 91 | function dom.node(name, format) 92 | if type(name) ~= "string" then 93 | error("Argument 1: expected string, got "..type(name)) 94 | end 95 | return function(...) 96 | local node = setmetatable({[NAME]=name, [FORMAT]=format}, dom.meta) 97 | for i=1,select('#', ...) do 98 | dom.insert(node, select(i, ...)) 99 | end 100 | return node 101 | end 102 | end 103 | 104 | return dom 105 | -------------------------------------------------------------------------------- /skooma/env.lua: -------------------------------------------------------------------------------- 1 | --- Mini-DSL for DOM generation. 2 | -- @module skooma.env 3 | -- @usage 4 | -- local env = require 'skooma.env' 5 | -- local html = env 'html' 6 | -- local node = html.h1 { 7 | -- "Example Title"; 8 | -- html.a { 9 | -- href = "https://example.org/", 10 | -- "Example Link" 11 | -- } 12 | -- } 13 | -- print(node) 14 | 15 | local dom = require 'skooma.dom' 16 | 17 | --- Creates a new environment of the given format 18 | local function env(format) 19 | local meta = {} 20 | function meta:__index(key) 21 | self[key] = assert(dom.node(key, format)) 22 | return rawget(self, key) 23 | end 24 | return setmetatable({ 25 | raw = dom.raw 26 | }, meta) 27 | end 28 | 29 | return env 30 | -------------------------------------------------------------------------------- /skooma/html.lua: -------------------------------------------------------------------------------- 1 | return require("skooma.env")("html") 2 | -------------------------------------------------------------------------------- /skooma/init.lua: -------------------------------------------------------------------------------- 1 | --- Helper module that loads skooma's submodules 2 | -- @module skooma 3 | -- @usage 4 | -- local skooma = require 'skooma' 5 | -- assert(skooma.env == require 'skooma.env') 6 | 7 | return setmetatable({}, {__index = function(_, key) 8 | return require("skooma."..tostring(key)) 9 | end}) 10 | -------------------------------------------------------------------------------- /skooma/serialise.lua: -------------------------------------------------------------------------------- 1 | --- Module for DOM serialisation. 2 | -- This module should rarely be used directly, as DOM nodes know how to serialise themselves. 3 | -- @module skooma.serialise 4 | 5 | local dom = require 'skooma.dom' 6 | local NAME = dom.name 7 | local RAW = dom.raw 8 | 9 | local serialise = {} 10 | 11 | local html_void = { 12 | area = true, base = true, br = true, col = true, 13 | command = true, embed = true, hr = true, img = true, 14 | input = true, keygen = true, link = true, meta = true, 15 | param = true, source = true, track = true, wbr = true 16 | } 17 | 18 | --- Turns a Lua object into something usable as an attribute. 19 | -- Tables get concatenated as sequences, functions get called and their result fed back into `toattribute` and 20 | -- everything else is fed through `tostring`. 21 | local function toattribute(element) 22 | if type(element) == "table" then 23 | return table.concat(element, " ") 24 | elseif type(element) == "function" then 25 | return toattribute(element()) 26 | else 27 | return tostring(element) 28 | end 29 | end 30 | 31 | --- Returns all attributes of a DOM node as a string. 32 | -- @todo Benchmark table.insert 33 | local function attribute_list(dom_node) 34 | local buffer = {} 35 | for attribute, value in dom.attributes(dom_node) do 36 | table.insert(buffer, ' '..attribute..'="'..toattribute(value)..'"') 37 | end 38 | return table.concat(buffer) 39 | end 40 | 41 | --- Recursively serialises a DOM tree. 42 | -- @todo Benchmark table.insert 43 | local function serialise_tree(serialise_tag, escape, dom_node, buffer, ...) 44 | local t = type(dom_node) 45 | if t=="table" and dom_node[NAME] then 46 | if dom_node[NAME] == RAW then 47 | for _, str in ipairs(dom_node) do 48 | table.insert(buffer, tostring(str)) 49 | end 50 | else 51 | serialise_tag(dom_node, buffer, ...) 52 | end 53 | elseif t=="table" then 54 | for _, child in ipairs(dom_node) do 55 | serialise_tree(serialise_tag, escape, child, buffer, ...) 56 | end 57 | elseif t=="function" then 58 | return serialise_tree(serialise_tag, escape, dom_node(), buffer, ...) 59 | elseif t=="nil" then 60 | return buffer 61 | else 62 | table.insert(buffer, escape(tostring(dom_node))) 63 | end 64 | return buffer 65 | end 66 | 67 | --- Excape a string for XML output 68 | local function xml_escape(input) 69 | return (tostring(input):gsub("[<>]", { 70 | ["<"] = "<"; 71 | [">"] = ">"; 72 | })) 73 | end 74 | local html_escape = xml_escape 75 | 76 | --- Serialises an HTML tag 77 | local function html_tag(dom_node, buffer, ...) 78 | local name = dom_node[NAME]:gsub("%u", "-%1", 2):lower() 79 | if html_void[name] then 80 | table.insert(buffer, "<"..tostring(name)..attribute_list(dom_node)..">") 81 | -- TODO: Maybe error or warn when node not empty? 🤔 82 | else 83 | table.insert(buffer, "<"..tostring(name)..attribute_list(dom_node)..">") 84 | for _, child in ipairs(dom_node) do 85 | serialise_tree(html_tag, html_escape, child, buffer, ...) 86 | end 87 | table.insert(buffer, "") end 88 | end 89 | 90 | --- Serialises an XML tag 91 | local function xml_tag(dom_node, buffer, ...) 92 | local name = dom_node[NAME] 93 | if 0 == #dom_node then 94 | table.insert(buffer, "<"..tostring(name)..attribute_list(dom_node).."/>") 95 | else 96 | table.insert(buffer, "<"..tostring(name)..attribute_list(dom_node)..">") 97 | for _, child in ipairs(dom_node) do 98 | serialise_tree(xml_tag, xml_escape, child, buffer, ...) 99 | end 100 | table.insert(buffer, "") 101 | end 102 | end 103 | 104 | local meta = { __index = { concat = table.concat; } } 105 | 106 | --- Serialises an HTML btree 107 | function serialise.html(dom_node, ...) 108 | return serialise_tree(html_tag, html_escape, dom_node, setmetatable({}, meta), ...) 109 | end 110 | 111 | --- Serialises an XML tree 112 | function serialise.xml(dom_node, ...) 113 | return serialise_tree(xml_tag, xml_escape, dom_node, setmetatable({}, meta), ...) 114 | end 115 | 116 | return serialise 117 | -------------------------------------------------------------------------------- /skooma/slotty.lua: -------------------------------------------------------------------------------- 1 | --- Slot helper for skooma templates. 2 | -- Skooma templates are intended to be free of invisible side effects. 3 | -- This helper library offers a way to pass content around different templates. 4 | -- @classmod skooma.slotty 5 | -- @usage 6 | -- local html = require 'skooma.env' 'html' 7 | -- local slots = require 'skooma.slotty' () 8 | -- -- Use slots 9 | -- local document = html.html(html.ul{ 10 | -- html.li("Home"); 11 | -- html.li("About"); 12 | -- slots.nav(); -- Returns an empty table for now 13 | -- }) 14 | -- -- Fill slots 15 | -- slots.navbar(html.li("Contact")) 16 | -- -- Renders the whole thing 17 | -- print(tostring(documnet)) 18 | 19 | local slot = {} 20 | local NAME = require('skooma').dom.name 21 | 22 | local __slot = {__index=slot} 23 | 24 | function __slot:__call(item, ...) 25 | if item ~= nil then 26 | table.insert(self, item) 27 | end 28 | if select("#", ...) > 0 then 29 | return self(...) 30 | end 31 | end 32 | 33 | local __slotty = {} 34 | 35 | function __slotty:__index(key) 36 | self[key] = setmetatable({[NAME]=false}, __slot) 37 | return self[key] 38 | end 39 | 40 | local function slotty() 41 | return setmetatable({}, __slotty) 42 | end 43 | 44 | return slotty 45 | -------------------------------------------------------------------------------- /skooma/xml.lua: -------------------------------------------------------------------------------- 1 | return require("skooma.env")("xml") 2 | -------------------------------------------------------------------------------- /spec/skooma_spec.moon: -------------------------------------------------------------------------------- 1 | skooma = require 'skooma' 2 | xml = skooma.env! 3 | html = skooma.env 'html' 4 | NAME = skooma.dom.name 5 | 6 | describe 'skooma', -> 7 | describe 'environment', -> 8 | it 'returns DOM nodes', -> 9 | assert.is.equal "h1", xml.h1![NAME] 10 | it 'has a raw function', -> 11 | assert.is.function xml.raw 12 | assert.same { [NAME]: skooma.dom.raw, 'foo' }, xml.raw 'foo' 13 | pending 'handles nested table arguments', -> 14 | 15 | describe 'serialiser', -> 16 | it 'treats empty XML tags correctly', -> 17 | assert.is.equal '
', 18 | tostring xml.div xml.span! 19 | it 'treats empty HTML tags correctly', -> 20 | assert.is.equal '
', 21 | tostring html.div! 22 | assert.is.equal '
', 23 | tostring html.br! 24 | it 'correctly transforms custom property names for convenience', -> 25 | assert.is.equal '', 26 | tostring html.customElement! 27 | it 'concatenates attribute sequences', -> 28 | assert.is.equal '', 29 | tostring xml.span class: {"foo", "bar", "baz"} 30 | describe 'method', -> 31 | it 'renders the content', -> 32 | assert.is.equal '', 33 | xml.span(class: {"foo", "bar", "baz"})\render('xml')\concat! 34 | assert.is.equal '', 35 | xml.span(class: {"foo", "bar", "baz"})\render('html')\concat! 36 | 37 | it 'escapes strings by default', -> 38 | for lang in *{'xml', 'html'} 39 | assert.is.equal '<div>', 40 | xml.span('
')\render(lang)\concat! 41 | 42 | it 'does not escape raw strings', -> 43 | assert.is.equal '
', 44 | xml.span(xml.raw("
"))\render('html')\concat! 45 | 46 | it 'defaults to xml', -> -- TODO: Find a nice way of switching defaults 47 | assert.is.equal '', 48 | xml.span(class: {"foo", "bar", "baz"})\render!\concat! 49 | 50 | it 'is available as a __call metamethod', -> 51 | assert.is.equal '', 52 | xml.span(class: {"foo", "bar", "baz"})!\concat! 53 | 54 | it 'accepts custom serialiser functions', -> 55 | custom = skooma.env (node) -> node[NAME] 56 | doc = custom.node_name! 57 | assert.equal "node_name", tostring(doc) 58 | -------------------------------------------------------------------------------- /spec/slotty_spec.moon: -------------------------------------------------------------------------------- 1 | import slotty, env from require 'skooma' 2 | 3 | html = env 'html' 4 | 5 | describe "slotty", -> 6 | before_each -> 7 | export slots = slotty! 8 | it "returns slots consistently", -> 9 | assert.equal slots.foo, slots.foo 10 | assert.not.equal slots.foo, slots.bar 11 | it "skips flattening until serialisation", -> 12 | div = html.div slots.foo 13 | assert.equal "
", tostring(div) 14 | slots.foo "foo" 15 | assert.equal "
foo
", tostring(div) 16 | -------------------------------------------------------------------------------- /tasks.lua: -------------------------------------------------------------------------------- 1 | local task = require 'spooder' .task 2 | 3 | task.install { 4 | description = "Installs the rock"; 5 | depends = "test"; 6 | 'luarocks make'; 7 | } 8 | 9 | task.clean { 10 | description = "Deletes buildt leftovers"; 11 | 'rm -rf luacov-html'; 12 | 'rm -f luacov.report.out'; 13 | 'rm -f luacov.stats.out'; 14 | } 15 | 16 | task.test { 17 | description = "Runs tests"; 18 | 'rm luacov.stats.out || true'; 19 | 'luacheck .'; 20 | 'busted --coverage --lpath "?/init.lua;?.lua"'; 21 | 'luacov -r html skooma.lua'; 22 | } 23 | 24 | task.documentation { 25 | description = "Builds and pushes the documentation"; 26 | depends = { "test"}; 27 | [[ 28 | hash=$(git log -1 --format=%h) 29 | mkdir -p doc/coverage 30 | cp -r luacov-html/* doc/coverage 31 | rm -r doc/* 32 | ldoc . 33 | cd doc 34 | find . | treh -c 35 | git add --all 36 | if git log -1 --format=%s | grep "$hash$" 37 | then git commit --amend --no-edit 38 | else git commit -m "Update documentation to $hash" 39 | fi 40 | git push --force origin doc 41 | cd ../ 42 | git stash pop || true 43 | ]] 44 | } 45 | --------------------------------------------------------------------------------