From 671e18febd45bba01d3d29db7a544d25d1b36a3b Mon Sep 17 00:00:00 2001 From: venomade Date: Thu, 21 May 2026 16:57:22 +0100 Subject: Initial Commit --- documentation/docs-build.lua | 860 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 documentation/docs-build.lua (limited to 'documentation/docs-build.lua') diff --git a/documentation/docs-build.lua b/documentation/docs-build.lua new file mode 100644 index 0000000..acb034e --- /dev/null +++ b/documentation/docs-build.lua @@ -0,0 +1,860 @@ +#!/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] = '