Last active
December 13, 2024 22:31
-
-
Save nicebyte/86bd4e9f731e5536e1f239bd53d59837 to your computer and use it in GitHub Desktop.
lua site generator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Usage: | |
-- lua generator.lua --src <path-to-source-folder> | |
-- --dst <path-to-destination-folder> | |
-- --assets <paths-to-asset-subfolders> | |
-- --content <paths-to-content-subfolders> | |
-- | |
-- The source folder must contain all templates within a subfolder called | |
-- "templates". | |
-- The destination folder must exist prior to invokation of the script. | |
-- "--assets" specifies names of subfolders in the source folder that will get | |
-- copied to the destination. | |
-- "--content" specifies names of subfolders in the source folder that contain | |
-- content files from which HTML will be generated using templates. Note that | |
-- "." is a valid content subfolder, allowing to place output files in the root. | |
-- Content files are lua files comprising a single Lua table in the following | |
-- format: | |
-- { | |
-- ["title"] = "<content title>", | |
-- ["pubdate"] = {["month"]=<publication day month 1-12>, ["day"]=<publication day of the month>, ["year"]=<publication year>}, | |
-- ["template"] = "<name of the template to use from the templates subfolder>, | |
-- ["slug"] = "<name of the output file that will be used verbatim. if not specified, it will be generated>" | |
-- ["content"] = [[ | |
-- <raw html goes here> | |
-- ]] | |
-- } | |
-- Templates are lua scripts that specify how the content is presented. Example | |
-- template: | |
-- t:render("header.html") -- templates can include other templates | |
-- t:sink([[ | |
-- <h1>%s</h1> | |
-- <p><small>published on %s</small></p> | |
-- %s | |
-- <hr style="border: 1px dotted #353535; border-bottom: none;"/> | |
-- <p><i>Like this post? <a href="https://bsky.app/profile/nicebyte.bsky.social">Follow</a> me on bluesky for more!</i></p> | |
-- ]], t.data.title, os.date("%b %d %Y", t.data.timestamp), t.data.content) -- t:sink writes a formatted string to output file | |
-- t:render("footer.html") | |
-- In the context of a template, `t.data` is the table read from the content file. | |
-- This script requires Lua FileSystem. If you're not using a custom Lua build | |
-- with Lua FileSystem hacked in, uncomment the following line: | |
-- local lfs = require("lfs") | |
function bg_fatal(msg) | |
print("FATAL ERROR: " .. msg) | |
os.exit(1) | |
end | |
function bg_parse_cmdline(args) | |
local src_key <const> = "src" | |
local dst_key <const> = "dst" | |
local content_key <const> = "content" | |
local asset_key <const> = "assets" | |
local result = { | |
["content_dirs"] = {}, | |
["asset_dirs"] = {} | |
} | |
local append_list = nil | |
for arg_idx=0,#args do | |
local arg = args[arg_idx] | |
if arg:sub(1,2) == "--" then | |
append_list = nil | |
local arg_key = arg:sub(3) | |
if arg_key == src_key or arg_key == dst_key then | |
if arg_idx == #args then bg_fatal("missing argument value") end | |
local arg_value = args[arg_idx + 1] | |
result[arg_key == src_key and "src" or "dst"] = arg_value | |
elseif arg_key == content_key then | |
append_list = result.content_dirs | |
elseif arg_key == asset_key then | |
append_list = result.asset_dirs | |
end | |
elseif append_list then | |
append_list[#append_list+1] = arg | |
end | |
end | |
if not result.src or not result.dst then | |
bg_fatal("source or destination folder not specified") | |
end | |
return result | |
end | |
function bg_isdir(path) | |
mode, _ = lfs.attributes(path, "mode") | |
return mode == "directory" | |
end | |
function bg_pathsep() | |
return "/" | |
end | |
string.find_last_sub = function(str, pattern, init) | |
local s, e = nil, nil | |
local found_last = false | |
while not found_last do | |
local sn, en = string.find(str, pattern, init, true) | |
found_last = not sn | |
if sn then | |
s, e = sn, en | |
init = s + 1 | |
end | |
end | |
return s,e | |
end | |
function bg_makepath(...) | |
local components = {...} | |
local lastc = components[#components] | |
local trailingsep = lastc:find_last_sub(bg_pathsep()) | |
components[#components] = lastc:sub(1, trailingsep and trailingsep-1 or nil) | |
return table.concat(components, bg_pathsep()) | |
end | |
function bg_validate_settings(settings) | |
if not bg_isdir(settings.src) then | |
bg_fatal(settings.src.." is not a folder") | |
end | |
if not bg_isdir(settings.dst) then | |
bg_fatal(settings.dst.." is not a folder") | |
end | |
print("source folder path: "..settings.src) | |
print("destination folder path: "..settings.dst) | |
return settings | |
end | |
function bg_iterate_files(path, func) | |
if not bg_isdir(path) then | |
bg_fatal("directory "..path.." not found") | |
end | |
local dir_it, dir = lfs.dir(path) | |
local it = function() return dir_it(dir) end | |
for basename in it do | |
local file_path = bg_makepath(path, basename) | |
if lfs.attributes(file_path, "mode") == "file" then | |
func(basename, file_path) | |
end | |
end | |
end | |
function bg_readfile(path) | |
local f = io.open(path, "r") | |
if not f then bg_fatal("failed to open "..template_path) end | |
local content = f:read("*all") | |
f:close() | |
return content | |
end | |
function bg_load_code(prefix, source_file) | |
return load((function() | |
local load_stage = 0 | |
return function() | |
load_stage = load_stage + 1 | |
return ({ | |
[1] = function() return prefix end, | |
[2] = function() return bg_readfile(source_file) end, | |
[3] = function() return nil end | |
})[load_stage]() | |
end | |
end)()) | |
end | |
function bg_load_templates(source) | |
result = {} | |
bg_iterate_files( | |
bg_makepath(source, "templates"), | |
function(template_basename, template_path) | |
local t, err = bg_load_code("local t=({...})[1]", template_path) | |
if not t then bg_fatal("failed to load template \""..template_path.."\", error"..err) end | |
result[template_basename] = t | |
end) | |
return result | |
end | |
function bg_load_content(srcdir, sources) | |
result = {} | |
for _,source in pairs(sources) do | |
bg_iterate_files( | |
bg_makepath(srcdir, source), | |
function (content_basename, content_path) | |
local chunk, err = bg_load_code("return ", content_path) | |
if not chunk then bg_fatal("failed to load \""..content_path.."\", error: "..err) end | |
local data = chunk() | |
if not data then bg_fatal(content_path.." did not return any data") end | |
if not data.title then bg_fatal(content_path.." is missing title") end | |
if not data.content then bg_fatal(content_path.." is missing content") end | |
if not data.pubdate then bg_fatal(content_path.." is missing publication date") end | |
if not data.template then bg_fatal(content_path.." is missing a template") end | |
if not data.pubdate.month or | |
not data.pubdate.day or | |
not data.pubdate.year then | |
bg_fatal(content_path.." bad publication date") | |
end | |
data.timestamp = os.time(data.pubdate) | |
if not data.slug then | |
local last_dot_pos = content_basename:find_last_sub(".") | |
data.slug = | |
((last_dot_pos and last_dot_pos > 1) | |
and string.sub(content_basename, 1, last_dot_pos - 1) | |
or content_basename)..".html" | |
end | |
data.rel_out_path = source | |
if not data.tags then data.tags = {} end | |
result[#result+1] = data | |
end) | |
end | |
table.sort(result, function(a,b) return a.timestamp > b.timestamp end) | |
return result | |
end | |
function bg_copydir(source, dest) | |
local dir_it, dir = lfs.dir(source) | |
local it = function() return dir_it(dir) end | |
for item in it do | |
if not (item == "." or item == "..") then | |
local item_src_path = bg_makepath(source, item) | |
local item_dst_path = bg_makepath(dest, item) | |
local mode = lfs.attributes(item_src_path, "mode") | |
if mode == "directory" then | |
lfs.mkdir(item_dst_path) | |
bg_copydir(item_src_path, item_dst_path) | |
elseif mode == "file" then | |
local inf = io.open(item_src_path, "rb") | |
local outf = io.open(item_dst_path, "wb") | |
local data = inf:read("*all") | |
outf:write(data) | |
inf:close() | |
outf:close() | |
else | |
bg_fatal("unknown type "..item_src_path) | |
end | |
end | |
end | |
end | |
print("gpfault.net generator") | |
if #arg > 0 then | |
local settings = bg_parse_cmdline(arg) | |
bg_validate_settings(settings) | |
local templates = bg_load_templates(settings.src) | |
local template_api = { | |
["templates"] = templates, | |
["sink"] = function(tapi, str, ...) | |
tapi.out_file:write(str:format(...)) | |
end, | |
["render"] = function(tapi, name) | |
tapi.templates[name](tapi) | |
end | |
} | |
local pages = bg_load_content(settings.src, settings.content_dirs) | |
template_api.pages = pages | |
for _, page in pairs(pages) do | |
local template = templates[page.template] | |
if not template then | |
bg_fatal(page.title.." uses missing template "..page.template) | |
end | |
local output_dir_path = bg_makepath(settings.dst, page.rel_out_path) | |
if lfs.attributes(output_dir_path, "mode") ~= "directory" then | |
if not lfs.mkdir(output_dir_path) then | |
bg_fatal("failed to create directory "..output_dir_path) | |
end | |
end | |
local output_file_basename = page.slug | |
local output_file_path = bg_makepath(output_dir_path, output_file_basename) | |
template_api.out_file = io.open(output_file_path, "w") | |
if not template_api.out_file then | |
bg_fatal("failed to open output file "..output_file_path) | |
end | |
template_api.data = page | |
template(template_api) | |
template_api.out_file:close() | |
end | |
for _, asset_dir in pairs(settings.asset_dirs) do | |
local dst_path = bg_makepath(settings.dst, "assets") | |
lfs.mkdir(dst_path) | |
bg_copydir(bg_makepath(settings.src, asset_dir), dst_path) | |
end | |
else | |
print([[ | |
usage: lua.exe generator.lua --source-dir <path-to-source-folder> | |
--destination-dir <path-to-destination-folder>]]) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment