#!/usr/bin/env lua -- -- lume-docs-build.lua -- Converts Lume's Markdown documentation into a standalone HTML site. -- Usage: lua lume-docs-build.lua [input.md] [output.html] -- Defaults: DOCS.md -> docs/index.html -- local infile = arg and arg[1] or "DOCS.md" local outfile = arg and arg[2] or "index.html" -- ── Utilities ──────────────────────────────────────────────────────────────── local function read_file(path) local f, err = io.open(path, "r") if not f then io.stderr:write("lume-docs-build: cannot open '" .. path .. "': " .. tostring(err) .. "\n") os.exit(1) end local content = f:read("*a") f:close() return content end local function write_file(path, content) -- create parent directory if it doesn't exist (best-effort) local dir = path:match("^(.*)/[^/]+$") if dir and dir ~= "" then os.execute("mkdir -p " .. dir) end local f, err = io.open(path, "w") if not f then io.stderr:write("lume-docs-build: cannot write '" .. path .. "': " .. tostring(err) .. "\n") os.exit(1) end f:write(content) f:close() end local function escape_html(s) return (s:gsub("&", "&") :gsub("<", "<") :gsub(">", ">") :gsub('"', """)) end -- ── Slug generation (for heading anchors) ──────────────────────────────────── local function slugify(text) -- strip markdown inline code and links from heading text text = text:gsub("`[^`]+`", function(m) return m:sub(2,-2) end) text = text:gsub("%[(.-)%]%(.-%)"," %1") text = text:lower() text = text:gsub("[^%a%d%s%-]", "") text = text:gsub("%s+", "-") text = text:gsub("^%-+", ""):gsub("%-+$", "") return text end -- ── Inline Markdown → HTML ──────────────────────────────────────────────────── -- Handles: **bold**, *italic*, `code`, [text](url), and bare anchors local function inline_md(s) -- escape HTML first so we don't double-escape our own tags s = escape_html(s) -- code spans (backtick) — protect from further processing local code_stash = {} s = s:gsub("`([^`]+)`", function(c) code_stash[#code_stash+1] = '' .. c .. "" return "\x00CODE" .. #code_stash .. "\x00" end) -- bold + italic ***text*** s = s:gsub("%*%*%*(.-)%*%*%*", "%1") -- bold **text** s = s:gsub("%*%*(.-)%*%*", "%1") -- italic *text* s = s:gsub("%*(.-)%*", "%1") -- links [text](url) s = s:gsub("%[(.-)%]%((.-)%)", function(txt, url) -- internal anchor links start with # if url:sub(1,1) == "#" then return '' .. txt .. "" end return '' .. txt .. "" end) -- restore code spans s = s:gsub("\x00CODE(%d+)\x00", function(n) return code_stash[tonumber(n)] end) return s end -- ── Syntax highlighting for code blocks ────────────────────────────────────── -- Simple but good-enough token colouring for lua, c, sh, and text. local SH_KEYWORDS = { ["if"]=1,["then"]=1,["else"]=1,["elif"]=1,["fi"]=1, ["for"]=1,["while"]=1,["do"]=1,["done"]=1,["case"]=1, ["esac"]=1,["in"]=1,["function"]=1,["return"]=1, ["local"]=1,["export"]=1,["echo"]=1,["exit"]=1, } local LUA_KEYWORDS = { ["and"]=1,["break"]=1,["do"]=1,["else"]=1,["elseif"]=1, ["end"]=1,["false"]=1,["for"]=1,["function"]=1,["goto"]=1, ["if"]=1,["in"]=1,["local"]=1,["nil"]=1,["not"]=1, ["or"]=1,["repeat"]=1,["return"]=1,["then"]=1,["true"]=1, ["until"]=1,["while"]=1, } local C_KEYWORDS = { ["auto"]=1,["break"]=1,["case"]=1,["char"]=1,["const"]=1, ["continue"]=1,["default"]=1,["do"]=1,["double"]=1,["else"]=1, ["enum"]=1,["extern"]=1,["float"]=1,["for"]=1,["goto"]=1, ["if"]=1,["inline"]=1,["int"]=1,["long"]=1,["register"]=1, ["return"]=1,["short"]=1,["signed"]=1,["sizeof"]=1,["static"]=1, ["struct"]=1,["switch"]=1,["typedef"]=1,["union"]=1,["unsigned"]=1, ["void"]=1,["volatile"]=1,["while"]=1,["NULL"]=1,["true"]=1,["false"]=1, } local function hl_lua(code) local out = {} local i, n = 1, #code while i <= n do local ch = code:sub(i,i) -- single-line comment if code:sub(i,i+1) == "--" then local rest = escape_html(code:sub(i)) out[#out+1] = '' .. rest .. "" break -- long string / comment stubs not handled — treated as unknown elseif ch == '"' or ch == "'" then local q, j = ch, i+1 while j <= n do local c = code:sub(j,j) if c == "\\" then j=j+2 elseif c==q then j=j+1; break else j=j+1 end end out[#out+1] = ''..escape_html(code:sub(i,j-1)).."" i=j elseif ch:match("%d") then local j=i while j<=n and code:sub(j,j):match("[%d%.xXa-fA-F]") do j=j+1 end out[#out+1]=''..escape_html(code:sub(i,j-1)).."" i=j elseif ch:match("[%a_]") then local j=i while j<=n and code:sub(j,j):match("[%w_]") do j=j+1 end local word=code:sub(i,j-1) if LUA_KEYWORDS[word] then out[#out+1]=''..word.."" else out[#out+1]=escape_html(word) end i=j else out[#out+1]=escape_html(ch); i=i+1 end end return table.concat(out) end local function hl_c(code) local out = {} local i, n = 1, #code while i <= n do local ch = code:sub(i,i) if code:sub(i,i+1) == "//" then out[#out+1]=''..escape_html(code:sub(i))..""; break elseif ch == '"' or ch == "'" then local q,j=ch,i+1 while j<=n do local c=code:sub(j,j) if c=="\\" then j=j+2 elseif c==q then j=j+1;break else j=j+1 end end out[#out+1]=''..escape_html(code:sub(i,j-1))..""; i=j elseif ch:match("%d") then local j=i while j<=n and code:sub(j,j):match("[%d%.xXuUlL]") do j=j+1 end out[#out+1]=''..escape_html(code:sub(i,j-1))..""; i=j elseif ch:match("[%a_]") then local j=i while j<=n and code:sub(j,j):match("[%w_]") do j=j+1 end local word=code:sub(i,j-1) if C_KEYWORDS[word] then out[#out+1]=''..word.."" else out[#out+1]=escape_html(word) end i=j else out[#out+1]=escape_html(ch); i=i+1 end end return table.concat(out) end local function hl_sh(code) local out = {} local i,n=1,#code while i<=n do local ch=code:sub(i,i) if ch=="#" then out[#out+1]=''..escape_html(code:sub(i))..""; break elseif ch=='"' or ch=="'" then local q,j=ch,i+1 while j<=n do local c=code:sub(j,j) if ch=='"' and c=="\\" then j=j+2 elseif c==q then j=j+1;break else j=j+1 end end out[#out+1]=''..escape_html(code:sub(i,j-1))..""; i=j elseif ch=="$" then out[#out+1]='$'; i=i+1 elseif ch:match("[%a_]") then local j=i while j<=n and code:sub(j,j):match("[%w_]") do j=j+1 end local word=code:sub(i,j-1) if SH_KEYWORDS[word] then out[#out+1]=''..word.."" else out[#out+1]=escape_html(word) end i=j else out[#out+1]=escape_html(ch); i=i+1 end end return table.concat(out) end local highlighters = { lua=hl_lua, c=hl_c, sh=hl_sh, bash=hl_sh, zsh=hl_sh } local function highlight_code(lang, code) local hl = lang and highlighters[lang:lower()] if hl then -- highlight line by line local lines = {} for line in (code .. "\n"):gmatch("([^\n]*)\n") do lines[#lines+1] = hl(line) end return table.concat(lines, "\n") end return escape_html(code) end -- ── Block-level Markdown parser ─────────────────────────────────────────────── local function parse_md(src) local lines = {} for ln in (src .. "\n"):gmatch("([^\n]*)\n") do lines[#lines+1] = ln end local out = {} local toc = {} -- {level, text, slug} local i = 1 local in_list = false local in_para = false local function close_para() if in_para then out[#out+1] = "

"; in_para = false end end local function close_list() if in_list then out[#out+1] = ""; in_list = false end end local function close_all() close_list(); close_para() end -- track duplicate slugs local slug_count = {} local function unique_slug(text) local base = slugify(text) slug_count[base] = (slug_count[base] or 0) + 1 if slug_count[base] == 1 then return base end return base .. "-" .. slug_count[base] end while i <= #lines do local line = lines[i] -- fenced code block ```lang local fence_lang = line:match("^```(%w*)") if fence_lang ~= nil then close_all() local lang = fence_lang ~= "" and fence_lang or nil local code_lines = {} i = i + 1 while i <= #lines and not lines[i]:match("^```%s*$") do code_lines[#code_lines+1] = lines[i] i = i + 1 end local raw = table.concat(code_lines, "\n") local body = highlight_code(lang, raw) local lang_label = lang and ('' .. escape_html(lang) .. "") or "" out[#out+1] = '
' .. lang_label .. '' .. '
'
                 .. body
                 .. "
" i = i + 1 goto continue end -- heading # ## ### #### local hlevel, htext = line:match("^(#+)%s+(.*)") if hlevel then close_all() local lvl = math.min(#hlevel, 4) local slug = unique_slug(htext) toc[#toc+1] = {level=lvl, text=htext, slug=slug} -- add data-level for sidebar indentation out[#out+1] = string.format( '' .. '%s', lvl, slug, lvl, slug, inline_md(htext), lvl) i = i + 1 goto continue end -- horizontal rule --- if line:match("^%-%-%-+%s*$") or line:match("^%*%*%*+%s*$") then close_all() out[#out+1] = "
" i = i + 1 goto continue end -- blockquote > text if line:match("^>") then close_all() local bq_lines = {} while i <= #lines and lines[i]:match("^>") do bq_lines[#bq_lines+1] = lines[i]:match("^>%s?(.*)") i = i + 1 end out[#out+1] = "

" .. inline_md(table.concat(bq_lines, " ")) .. "

" goto continue end -- table | col | col | if line:match("^|") then close_all() local tbl_lines = {} while i <= #lines and lines[i]:match("^|") do tbl_lines[#tbl_lines+1] = lines[i] i = i + 1 end out[#out+1] = '
' local header_done = false for _, tl in ipairs(tbl_lines) do -- separator row |---|---| if tl:match("^|[-| :]+|%s*$") then header_done = true else local cells = {} for cell in (tl .. "|"):gmatch("|([^|]*)|?") do -- skip trailing empty captures cells[#cells+1] = cell end -- remove last empty cell from the trailing | pattern if cells[#cells] and cells[#cells]:match("^%s*$") then table.remove(cells) end if not header_done then out[#out+1] = "" for _, cell in ipairs(cells) do out[#out+1] = "" end out[#out+1] = "" else out[#out+1] = "" for _, cell in ipairs(cells) do out[#out+1] = "" end out[#out+1] = "" end end end out[#out+1] = "
" .. inline_md(cell:match("^%s*(.-)%s*$")) .. "
" .. inline_md(cell:match("^%s*(.-)%s*$")) .. "
" goto continue end -- unordered list - item or * item if line:match("^%s*[%-%*]%s+") then close_para() if not in_list then out[#out+1] = '