search
LoginSignup
0

posted at

updated at

A Static Website Generator in 94 lines of Lua

Intro

Over the years I've maintained a few different static websites. In the past I've run into the same problems with almost every static website generators:

  • ๐Ÿ’ข Installing the library on a new system is a pain.
  • ๐Ÿ’ข Updating the library breaks everything.
  • ๐Ÿ’ข I forgot how the generator library works.
  • ๐Ÿ’ข I haven't worked in the language the generator uses in a long time and getting up to speed is annoying.
  • ๐Ÿ’ข The generator can't handle partial updates.

I've settled on using Lua+Make and it's refreshing how much easier it is to maintain.

The key component has been my 94 line html.lua library. It isn't perfect but it has everything I need:

  • ๐Ÿ™ No templating language to remember
  • ๐Ÿ™ It looks/acts like the DOM
  • ๐Ÿ™ can be understood at a glance
  • ๐Ÿ™ composing DOM nodes and writing helpers is easy

Example Usage

Here's what it looks like in practice:

Lua 5.4.3 Copyright (C) 1994-2021 Lua.org, PUC-Rio
> H = require "html"

-- a tag
> H.div("ใ“ใ‚“ใฐใ‚“ใฏ")
<div>ใ“ใ‚“ใฐใ‚“ใฏ</div>

-- tags can take tags or strings
> H.p(H.div("Hello"), "world", "!")
<p><div>Hello</div>world!</p>

-- tags combine with strings
> H.div("Hello") .. " world!"
<div>Hello</div> world!

-- tags can have attributes
> H.div(attr{class="error"}, "Error!")
<div class="error">Error!</div>

-- attributes can be given in any order and combine
H.div("Error: ", attr{class="error"}, "exception!", attr{aria="sr-only"})
<div aria="sr-only" class="error">Error: exception!</div>

Dom

Tags also work as DOM-like nodes and are composable

-- tags are DOM-like
local ul = H.ul()
for i=1,3 do
   ul:append(H.li(i))
end
print(ul)
-- "<ul><li>1</li><li>2</li><li>3</li></ul>

View Helpers

Creating helper functions is easy

helper.lua
local function strip(s)
    return s:gsub("^%s+", ""):gsub("%s+$", "")
end

function code_example(code)
   return H.code(H.pre(strip(code)))
end

print(code_example[[
function square(n)
    return n * n
end
]])
--[[<code><pre>function square(n)
return n * n
end</pre></code>
]]--

html.lua

Here's the complete implementation for html.lua:

html.lua
local Attr = {}
local AttrMeta = {
    __index = Attr,
    __tostring = function(attr)
        local str = ""
        for k,v in pairs(attr.fields) do
            str = string.format("%s %s=%q", str, k, v)
        end
        return str
    end
}

function Attr.merge(self, attr)
    for k,v in pairs(attr.fields) do self.fields[k] = v end
end

function is_attr(t)
    return getmetatable(t) == AttrMeta
end

function attr(fields)
    return setmetatable({fields=fields}, AttrMeta)
end

local Tag = {}
local TagMeta = {
    __index = Tag,
    __concat = function(tag, x)
        return tostring(tag) .. tostring(x)
    end,
    __tostring = function(tag)
        if tag.options.is_singular then
            local closing_slash = " /"
            if tag.options.no_closing_slash then
                closing_slash = ""
            end
            return string.format("<%s%s%s>%s", tag.name, tag.attributes, closing_slash, tag:content())
        else
            return string.format("<%s%s>%s</%s>", tag.name, tag.attributes, tag:content(), tag.name)
        end
    end
}

function Tag.content(tag)
    local str = ""
    for i,v in ipairs(tag.contents) do
        str = str .. tag.contents[i]
    end
    return str
end

function Tag.append(self, ...) 
    local args = {...}
    for i,v in ipairs(args) do
        if is_attr(v) then self:attr(v) else table.insert(self.contents, v) end
    end
    return self
end

function Tag.attr(self, attr)
    self.attributes:merge(attr)
    return self
end

function tag(name, opt, ...)
    return setmetatable({
        name=name,
        options=opt,
        contents={},
        attributes=attr{}
    }, TagMeta):append(...)
end

local is_singular = {
    img   = true,
    input = true,
    link  = true,
    meta  = true
}

return setmetatable({
    tag=tag,   -- make your own tags
    attr=attr, -- specify attributes
    doctype=function(...)
        -- example custom tag
        return tag("!DOCTYPE html", {is_singular=true, no_closing_slash=true}, ...)
    end
}, {
    -- dynamically generate tags
    __index=function(t, name)
        t[name] = function(...) return tag(name, {is_singular=is_singular[name]}, ...) end
        return t[name]
    end
})

Setup

To start a new static website I usually make these files:

www/
  Makefile
  fix_paths.lua
  src/index.lua
  lib/html.lua

Makefile

The Makefile pretty simple. It will look for any .lua file in src/ and create a corresponding .html file in rel/.

Makefile
CP= cp -r
RM= rm -rf
MKDIR= mkdir
LUA= lua -l fix_paths
REL=rel/
SRC=src/
HTML= $(patsubst $(SRC)%.lua, $(REL)%.html, $(wildcard $(SRC)*.lua))

all: $(REL) $(HTML)

$(REL)%.html: $(SRC)%.lua
	$(LUA) $< > $@

$(REL):
	$(MKDIR) $(REL)

clean:
	$(RM) rel/

.PHONY: all clean

For static files I'll usually add a rule to copy files to rel/ based on how I want to organize things. For example if I just put all my js/css files under assets/:

Makefile
ASSETS= $(REL)assets

all: $(REL) $(HTML) $(ASSETS)

$(ASSETS):
	$(CP) assets $(ASSETS)

fix_paths.lua

The fix_paths.lua file modifies the lua path to load modules under lib/ where html.lua is located. This is a common pattern. You could also add your path to your luarocks directory as well.

fix_paths.lua
-- fix lua paths for src dir
package.path = 'lib/?.lua;src/?.lua;' .. package.path
package.cpath = 'src/?.so;src/?.so;' .. package.cpath

index.lua

And here's an example page:

src/index.lua
local H = require "html"

local function layout(title, ...)
    local head = H.head(
        H.title(title),
        H.meta(attr{name="viewport", content="width=device-width, initial-scale=1.0"}),
        H.meta(attr{charset="UTF-8"})
    )
    local body = H.body(...)
    body:append(H.div(attr{id="copyright"}, "Content &copy;2022"))
    return H.doctype(H.html(head, body))
end

local function strip(s)
    return s:gsub("^%s+", ""):gsub("%s+$", "")
end

function code_example(content)
    return H.pre(H.code(strip(content)))
end

function homepage()
    return layout(
        "Hello World!",
        H.div("Hello World!"),
        code_example[[
function foo()
    return "OK!"
end
        ]]
    )
end
-- make will put the output from this script into `rel/index.html`
print(homepage())

This code could be cleaned up quite a bit but it's just an example. I usually put my helpers in a module like lib/view.lua then import them in my pages:

lib/view.lua
local function TODO(...) return H.div(attr{class="todo"}, ...) end
return {TODO=TODO}
src/index.lua
local V = require "view"

function homepage()
   return H.div(TODO("write this html"))
end

So my common elements are all in one place but that's entirely optional. I find that how to organize your code is pretty trivial.

Usage

To generate html just run make:

$ make
lua -l fix_paths src/index.lua > rel/index.html

$ cat rel/index.html
<!DOCTYPE html><html><head><title>Hello World!</title><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta charset="UTF-8" /></head><body><div>Hello World!</div><pre><code>function foo()
    return "OK!"
end1</code></pre><div id="copyright">Content &copy;2022</div></body></html>

Deploying

I use AWS for my static sites so deploying to Route 53 is usually just a matter of syncing to my S3 bucket.

I usually add a make deploy rule for this:

Makefile
AWS= aws

.PHONY: deploy
deploy:
        $(AWS) sync rel/ s3:bucket-name --dryrun --delete

Which has --dry-run by default. I then type the command without it if I'm happy with the output:

$ aws sync rel/ s3:bucket-name --delete

Example

An example of a site using this generator

mixtapes.png

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0