LoginSignup
1
0

More than 1 year has passed since last update.

A Static Website Generator in 94 lines of Lua

Last updated at Posted at 2022-04-08

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
        if self.fields[k] ~= nil then self.fields[k] = self.fields[k] .. " " .. v else self.fields[k] = v end
    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

1
0
0

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
  3. You can use dark theme
What you can do with signing up
1
0