about summary refs log tree commit diff
path: root/documentation/docs-build.lua
diff options
context:
space:
mode:
authorvenomade <venomade@venomade.com>2026-05-21 16:57:22 +0100
committervenomade <venomade@venomade.com>2026-05-21 16:57:22 +0100
commit671e18febd45bba01d3d29db7a544d25d1b36a3b (patch)
tree61bb6654df52df2f2c3d77c9169ea4e0b84177f3 /documentation/docs-build.lua
Initial Commit main
Diffstat (limited to 'documentation/docs-build.lua')
-rw-r--r--documentation/docs-build.lua860
1 files changed, 860 insertions, 0 deletions
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("&", "&amp;")
+            :gsub("<", "&lt;")
+            :gsub(">", "&gt;")
+            :gsub('"', "&quot;"))
+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] = '<code class="inline-code">' .. c .. "</code>"
+    return "\x00CODE" .. #code_stash .. "\x00"
+  end)
+
+  -- bold + italic  ***text***
+  s = s:gsub("%*%*%*(.-)%*%*%*", "<strong><em>%1</em></strong>")
+  -- bold  **text**
+  s = s:gsub("%*%*(.-)%*%*", "<strong>%1</strong>")
+  -- italic  *text*
+  s = s:gsub("%*(.-)%*", "<em>%1</em>")
+
+  -- links  [text](url)
+  s = s:gsub("%[(.-)%]%((.-)%)", function(txt, url)
+    -- internal anchor links start with #
+    if url:sub(1,1) == "#" then
+      return '<a href="' .. url .. '">' .. txt .. "</a>"
+    end
+    return '<a href="' .. url .. '" target="_blank" rel="noopener">' .. txt .. "</a>"
+  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] = '<span class="hl-comment">' .. rest .. "</span>"
+      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] = '<span class="hl-string">'..escape_html(code:sub(i,j-1)).."</span>"
+      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]='<span class="hl-number">'..escape_html(code:sub(i,j-1)).."</span>"
+      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]='<span class="hl-keyword">'..word.."</span>"
+      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]='<span class="hl-comment">'..escape_html(code:sub(i)).."</span>"; 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]='<span class="hl-string">'..escape_html(code:sub(i,j-1)).."</span>"; 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]='<span class="hl-number">'..escape_html(code:sub(i,j-1)).."</span>"; 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]='<span class="hl-keyword">'..word.."</span>"
+      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]='<span class="hl-comment">'..escape_html(code:sub(i)).."</span>"; 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]='<span class="hl-string">'..escape_html(code:sub(i,j-1)).."</span>"; i=j
+    elseif ch=="$" then
+      out[#out+1]='<span class="hl-operator">$</span>'; 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]='<span class="hl-keyword">'..word.."</span>"
+      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] = "</p>"; in_para = false end
+  end
+  local function close_list()
+    if in_list then out[#out+1] = "</ul>"; 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 ('<span class="code-lang">' .. escape_html(lang) .. "</span>") or ""
+      out[#out+1] = '<div class="code-block">'
+                 .. lang_label
+                 .. '<button class="copy-btn" onclick="copyCode(this)" title="Copy">⎘</button>'
+                 .. '<pre><code>'
+                 .. body
+                 .. "</code></pre></div>"
+      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(
+        '<h%d id="%s" class="doc-heading" data-level="%d">'
+        .. '<a class="heading-anchor" href="#%s">¶</a>%s</h%d>',
+        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] = "<hr>"
+      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] = "<blockquote><p>" .. inline_md(table.concat(bq_lines, " ")) .. "</p></blockquote>"
+      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] = '<div class="table-wrap"><table>'
+      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] = "<thead><tr>"
+            for _, cell in ipairs(cells) do
+              out[#out+1] = "<th>" .. inline_md(cell:match("^%s*(.-)%s*$")) .. "</th>"
+            end
+            out[#out+1] = "</tr></thead><tbody>"
+          else
+            out[#out+1] = "<tr>"
+            for _, cell in ipairs(cells) do
+              out[#out+1] = "<td>" .. inline_md(cell:match("^%s*(.-)%s*$")) .. "</td>"
+            end
+            out[#out+1] = "</tr>"
+          end
+        end
+      end
+      out[#out+1] = "</tbody></table></div>"
+      goto continue
+    end
+
+    -- unordered list  - item or * item
+    if line:match("^%s*[%-%*]%s+") then
+      close_para()
+      if not in_list then out[#out+1] = '<ul class="doc-list">'; in_list = true end
+      local item = line:match("^%s*[%-%*]%s+(.*)")
+      out[#out+1] = "<li>" .. inline_md(item) .. "</li>"
+      i = i + 1
+      goto continue
+    end
+
+    -- ordered list  1. item
+    if line:match("^%s*%d+%.%s+") then
+      close_para()
+      if not in_list then out[#out+1] = '<ol class="doc-list">'; in_list = true end
+      local item = line:match("^%s*%d+%.%s+(.*)")
+      out[#out+1] = "<li>" .. inline_md(item) .. "</li>"
+      i = i + 1
+      goto continue
+    end
+
+    -- blank line
+    if line:match("^%s*$") then
+      close_all()
+      i = i + 1
+      goto continue
+    end
+
+    -- paragraph / continuation
+    close_list()
+    if not in_para then
+      out[#out+1] = "<p>"
+      in_para = true
+    else
+      out[#out+1] = " "
+    end
+    out[#out+1] = inline_md(line)
+    i = i + 1
+
+    ::continue::
+  end
+
+  close_all()
+  return table.concat(out, "\n"), toc
+end
+
+-- ── TOC sidebar HTML ──────────────────────────────────────────────────────────
+
+local function build_sidebar(toc)
+  local out = {}
+  out[#out+1] = '<nav id="sidebar" aria-label="Table of contents">'
+  out[#out+1] = '<div id="sidebar-header">'
+  out[#out+1] = '  <span class="sidebar-title">Lume</span>'
+  out[#out+1] = '  <span class="sidebar-version">0.1 Alpha</span>'
+  out[#out+1] = '</div>'
+  out[#out+1] = '<div id="sidebar-search-wrap">'
+  out[#out+1] = '  <input id="sidebar-search" type="text" placeholder="Filter…" aria-label="Filter sections">'
+  out[#out+1] = '</div>'
+  out[#out+1] = '<ul id="toc-list">'
+  for _, entry in ipairs(toc) do
+    -- skip h1 from TOC (document title)
+    if entry.level >= 2 then
+      local indent = (entry.level - 2) * 14
+      local clean  = entry.text
+                       :gsub("`([^`]+)`", "%1")
+                       :gsub("%[(.-)%]%(.-%)","  %1")
+      out[#out+1] = string.format(
+        '<li class="toc-item toc-level-%d" style="padding-left:%dpx">'
+        .. '<a href="#%s" class="toc-link">%s</a></li>',
+        entry.level, indent, entry.slug, escape_html(clean))
+    end
+  end
+  out[#out+1] = '</ul></nav>'
+  return table.concat(out, "\n")
+end
+
+-- ── Full HTML template ────────────────────────────────────────────────────────
+
+local function build_html(title, sidebar_html, body_html)
+  return string.format([[<!DOCTYPE html>
+<html lang="en" data-theme="dark">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>%s</title>
+<style>
+/* ── Reset ─────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+/* ── Tokens ─────────────────────────────────────────── */
+:root {
+  --font-body:   "Inter", "Segoe UI", system-ui, sans-serif;
+  --font-mono:   "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
+  --font-size:   15px;
+  --line-height: 1.7;
+  --sidebar-w:   260px;
+  --radius:      6px;
+  --transition:  0.2s ease;
+}
+[data-theme="dark"] {
+  --bg:          #0f1117;
+  --bg-alt:      #181b24;
+  --bg-code:     #12141c;
+  --bg-sidebar:  #13161e;
+  --border:      #2a2d3a;
+  --text:        #c9d1e0;
+  --text-muted:  #6b7280;
+  --text-head:   #e8ecf4;
+  --accent:      #7c91f5;
+  --accent-dim:  #3d4a8a;
+  --link:        #7c91f5;
+  --link-hover:  #a5b4fc;
+  --hl-keyword:  #7c91f5;
+  --hl-string:   #86efac;
+  --hl-comment:  #4b5563;
+  --hl-number:   #fca5a5;
+  --hl-operator: #fcd34d;
+  --tag-bg:      #1e2130;
+  --tag-text:    #94a3b8;
+  --shadow:      0 4px 24px rgba(0,0,0,0.5);
+}
+[data-theme="light"] {
+  --bg:          #f8f9fc;
+  --bg-alt:      #ffffff;
+  --bg-code:     #eef0f6;
+  --bg-sidebar:  #f0f2f8;
+  --border:      #d1d5e0;
+  --text:        #1e2030;
+  --text-muted:  #6b7280;
+  --text-head:   #111827;
+  --accent:      #4f5fe8;
+  --accent-dim:  #c7ccf7;
+  --link:        #4255d4;
+  --link-hover:  #2d3fa8;
+  --hl-keyword:  #4255d4;
+  --hl-string:   #166534;
+  --hl-comment:  #9ca3af;
+  --hl-number:   #b91c1c;
+  --hl-operator: #92400e;
+  --tag-bg:      #e5e7f0;
+  --tag-text:    #374151;
+  --shadow:      0 4px 24px rgba(0,0,0,0.08);
+}
+
+/* ── Base ────────────────────────────────────────────── */
+html { scroll-behavior: smooth; }
+body {
+  font-family: var(--font-body);
+  font-size:   var(--font-size);
+  line-height: var(--line-height);
+  color:       var(--text);
+  background:  var(--bg);
+  display:     flex;
+  min-height:  100vh;
+}
+
+/* ── Sidebar ─────────────────────────────────────────── */
+#sidebar {
+  width:      var(--sidebar-w);
+  min-width:  var(--sidebar-w);
+  background: var(--bg-sidebar);
+  border-right: 1px solid var(--border);
+  height:     100vh;
+  position:   sticky;
+  top:        0;
+  display:    flex;
+  flex-direction: column;
+  overflow:   hidden;
+  flex-shrink: 0;
+  transition: width var(--transition);
+}
+#sidebar-header {
+  display:     flex;
+  align-items: baseline;
+  gap:         8px;
+  padding:     18px 16px 10px;
+  border-bottom: 1px solid var(--border);
+}
+.sidebar-title   { font-weight: 700; font-size: 17px; color: var(--text-head); letter-spacing: 0.03em; }
+.sidebar-version {
+  font-size:  11px;
+  background: var(--tag-bg);
+  color:      var(--tag-text);
+  border:     1px solid var(--border);
+  padding:    1px 6px;
+  border-radius: 99px;
+  font-family: var(--font-mono);
+}
+#sidebar-search-wrap { padding: 8px 12px; }
+#sidebar-search {
+  width:       100%%;
+  background:  var(--bg);
+  border:      1px solid var(--border);
+  border-radius: var(--radius);
+  padding:     6px 10px;
+  color:       var(--text);
+  font-size:   13px;
+  outline:     none;
+  transition:  border-color var(--transition);
+}
+#sidebar-search:focus { border-color: var(--accent); }
+#toc-list {
+  list-style:  none;
+  overflow-y:  auto;
+  flex:        1;
+  padding:     6px 0 24px;
+}
+.toc-item { display: block; }
+.toc-link {
+  display:     block;
+  padding:     3px 16px;
+  color:       var(--text-muted);
+  text-decoration: none;
+  font-size:   13px;
+  white-space: nowrap;
+  overflow:    hidden;
+  text-overflow: ellipsis;
+  border-left: 2px solid transparent;
+  transition:  color var(--transition), border-color var(--transition), background var(--transition);
+}
+.toc-link:hover          { color: var(--text); background: var(--bg-alt); }
+.toc-link.active         { color: var(--accent); border-left-color: var(--accent); background: var(--bg-alt); }
+.toc-level-2 .toc-link  { font-weight: 600; color: var(--text); }
+.toc-level-3 .toc-link  { font-size: 12.5px; }
+.toc-level-4 .toc-link  { font-size: 12px; }
+
+/* ── Main content ────────────────────────────────────── */
+#content {
+  flex:        1;
+  min-width:   0;
+  padding:     0 0 80px;
+}
+#topbar {
+  position:   sticky;
+  top:        0;
+  z-index:    100;
+  display:    flex;
+  justify-content: flex-end;
+  align-items: center;
+  padding:    8px 32px;
+  background: var(--bg);
+  border-bottom: 1px solid var(--border);
+  gap:        12px;
+}
+#theme-toggle {
+  background: var(--bg-alt);
+  border:     1px solid var(--border);
+  border-radius: var(--radius);
+  color:      var(--text);
+  cursor:     pointer;
+  padding:    5px 12px;
+  font-size:  13px;
+  transition: background var(--transition), color var(--transition);
+}
+#theme-toggle:hover { background: var(--border); }
+#doc-body { max-width: 820px; margin: 0 auto; padding: 40px 40px 0; }
+
+/* ── Typography ──────────────────────────────────────── */
+.doc-heading {
+  color:       var(--text-head);
+  margin-top:  2.2em;
+  margin-bottom: 0.5em;
+  position:    relative;
+  scroll-margin-top: 56px;
+}
+h1.doc-heading { font-size: 2em;   border-bottom: 1px solid var(--border); padding-bottom: 0.3em; margin-top: 0.2em; }
+h2.doc-heading { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.25em; }
+h3.doc-heading { font-size: 1.2em; }
+h4.doc-heading { font-size: 1em;   color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.82em; margin-top: 2.5em; }
+.heading-anchor {
+  opacity:     0;
+  margin-left: 8px;
+  font-size:   0.75em;
+  color:       var(--text-muted);
+  text-decoration: none;
+  transition:  opacity var(--transition);
+  user-select: none;
+}
+.doc-heading:hover .heading-anchor { opacity: 1; }
+p  { margin-bottom: 1em; }
+hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
+blockquote {
+  border-left: 3px solid var(--accent-dim);
+  margin:      1em 0;
+  padding:     0.6em 1em;
+  background:  var(--bg-alt);
+  border-radius: 0 var(--radius) var(--radius) 0;
+  color:       var(--text-muted);
+  font-size:   0.95em;
+}
+blockquote strong { color: var(--text); }
+blockquote p { margin: 0; }
+a { color: var(--link); text-decoration: none; }
+a:hover { color: var(--link-hover); text-decoration: underline; }
+
+/* ── Lists ────────────────────────────────────────────── */
+.doc-list { margin: 0.5em 0 1em 1.4em; }
+.doc-list li { margin-bottom: 0.25em; }
+
+/* ── Inline code ──────────────────────────────────────── */
+.inline-code {
+  font-family: var(--font-mono);
+  font-size:   0.875em;
+  background:  var(--bg-code);
+  border:      1px solid var(--border);
+  padding:     1px 5px;
+  border-radius: 4px;
+  color:       var(--text-head);
+}
+
+/* ── Code blocks ──────────────────────────────────────── */
+.code-block {
+  position:    relative;
+  margin:      1.2em 0;
+  border-radius: var(--radius);
+  border:      1px solid var(--border);
+  background:  var(--bg-code);
+  overflow:    hidden;
+}
+.code-block pre {
+  overflow-x:  auto;
+  padding:     18px 20px;
+  font-family: var(--font-mono);
+  font-size:   0.875em;
+  line-height: 1.6;
+}
+.code-block code { font-family: inherit; color: var(--text); }
+.code-lang {
+  position:   absolute;
+  top:        0; right: 44px;
+  font-family: var(--font-mono);
+  font-size:  11px;
+  color:      var(--text-muted);
+  padding:    3px 8px;
+  background: var(--border);
+  border-radius: 0 0 4px 4px;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+.copy-btn {
+  position:   absolute;
+  top:        6px; right: 8px;
+  background: var(--bg-alt);
+  border:     1px solid var(--border);
+  border-radius: 4px;
+  color:      var(--text-muted);
+  cursor:     pointer;
+  font-size:  14px;
+  padding:    2px 7px;
+  transition: color var(--transition), background var(--transition);
+}
+.copy-btn:hover { color: var(--text); background: var(--border); }
+.copy-btn.copied { color: #86efac; }
+
+/* ── Syntax colours ────────────────────────────────────── */
+.hl-keyword  { color: var(--hl-keyword);  font-weight: 600; }
+.hl-string   { color: var(--hl-string); }
+.hl-comment  { color: var(--hl-comment);  font-style: italic; }
+.hl-number   { color: var(--hl-number); }
+.hl-operator { color: var(--hl-operator); }
+
+/* ── Tables ────────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; margin: 1.2em 0; border-radius: var(--radius); border: 1px solid var(--border); }
+table { width: 100%%; border-collapse: collapse; font-size: 0.92em; }
+thead { background: var(--bg-alt); }
+th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid var(--border); }
+th { font-weight: 600; color: var(--text-head); white-space: nowrap; }
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: var(--bg-alt); }
+
+/* ── Scrollbar styling (webkit) ───────────────────────── */
+#toc-list::-webkit-scrollbar, pre::-webkit-scrollbar, .table-wrap::-webkit-scrollbar {
+  width: 5px; height: 5px;
+}
+#toc-list::-webkit-scrollbar-track, pre::-webkit-scrollbar-track, .table-wrap::-webkit-scrollbar-track {
+  background: transparent;
+}
+#toc-list::-webkit-scrollbar-thumb, pre::-webkit-scrollbar-thumb, .table-wrap::-webkit-scrollbar-thumb {
+  background: var(--border);
+  border-radius: 3px;
+}
+
+/* ── Mobile ────────────────────────────────────────────── */
+@media (max-width: 768px) {
+  #sidebar { display: none; }
+  #doc-body { padding: 24px 20px 0; }
+  #topbar { padding: 8px 16px; }
+}
+</style>
+</head>
+<body>
+
+%s
+
+<div id="content">
+  <div id="topbar">
+    <button id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle colour scheme">☀ Light</button>
+  </div>
+  <main id="doc-body">
+    %s
+  </main>
+</div>
+
+<script>
+// ── Theme ──────────────────────────────────────────────────
+const btn = document.getElementById("theme-toggle");
+function applyTheme(t) {
+  document.documentElement.setAttribute("data-theme", t);
+  btn.textContent = t === "dark" ? "☀ Light" : "☾ Dark";
+  localStorage.setItem("lume-theme", t);
+}
+function toggleTheme() {
+  applyTheme(document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark");
+}
+(function() {
+  const saved = localStorage.getItem("lume-theme") || "dark";
+  applyTheme(saved);
+})();
+
+// ── Sidebar search filter ──────────────────────────────────
+const searchEl = document.getElementById("sidebar-search");
+if (searchEl) {
+  searchEl.addEventListener("input", function() {
+    const q = this.value.trim().toLowerCase();
+    document.querySelectorAll("#toc-list .toc-item").forEach(function(li) {
+      const txt = li.textContent.toLowerCase();
+      li.style.display = (!q || txt.includes(q)) ? "" : "none";
+    });
+  });
+}
+
+// ── Active TOC highlight on scroll ────────────────────────
+const headings = Array.from(document.querySelectorAll(".doc-heading[id]"));
+const tocLinks  = document.querySelectorAll(".toc-link");
+
+function onScroll() {
+  const scrollY = window.scrollY + 80;
+  let active = headings[0];
+  for (const h of headings) {
+    if (h.offsetTop <= scrollY) active = h;
+  }
+  tocLinks.forEach(function(a) {
+    const isActive = active && a.getAttribute("href") === "#" + active.id;
+    a.classList.toggle("active", isActive);
+    if (isActive) a.scrollIntoView({ block: "nearest", behavior: "smooth" });
+  });
+}
+window.addEventListener("scroll", onScroll, { passive: true });
+onScroll();
+
+// ── Copy button ────────────────────────────────────────────
+function copyCode(btn) {
+  const code = btn.closest(".code-block").querySelector("code");
+  navigator.clipboard.writeText(code.innerText).then(function() {
+    btn.textContent = "✓";
+    btn.classList.add("copied");
+    setTimeout(function() { btn.textContent = "⎘"; btn.classList.remove("copied"); }, 1800);
+  });
+}
+</script>
+</body>
+</html>
+]], escape_html(title), sidebar_html, body_html)
+end
+
+-- ── Entry point ───────────────────────────────────────────────────────────────
+
+local src          = read_file(infile)
+local body_html, toc = parse_md(src)
+local sidebar_html = build_sidebar(toc)
+
+-- extract document title from first h1
+local title = src:match("^#%s+(.-)%s*\n") or "Lume Documentation"
+
+local html = build_html(title, sidebar_html, body_html)
+write_file(outfile, html)
+io.write("lume-docs-build: wrote " .. outfile .. "\n")