diff options
| author | venomade <venomade@venomade.com> | 2026-05-21 16:57:22 +0100 |
|---|---|---|
| committer | venomade <venomade@venomade.com> | 2026-05-21 16:57:22 +0100 |
| commit | 671e18febd45bba01d3d29db7a544d25d1b36a3b (patch) | |
| tree | 61bb6654df52df2f2c3d77c9169ea4e0b84177f3 /documentation/docs-build.lua | |
Initial Commit main
Diffstat (limited to 'documentation/docs-build.lua')
| -rw-r--r-- | documentation/docs-build.lua | 860 |
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("&", "&") + :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] = '<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") |
