Get in touch,

Maarten Poirot5 min read

SilverBullet

For my server I wanted a knowledge base that would not break, and that I am the only one who has to maintain. I tried Obsidian, HedgeDoc and SeaFile Wiki before settling on SilverBullet, a markdown wiki you script with actual Lua. This post covers how I run it in Docker, and four Space Lua snippets that fill gaps the docs don’t cover well yet.

Key takeaways

  • SilverBullet is a stack of plain markdown files with a browser front-end on top, nothing proprietary, nothing that can corrupt beyond a text file.
  • Space Lua is a real dialect of Lua, the same language behind Neovim and Redis, not a toy scripting layer bolted on for show.
  • Below are four ready-to-paste snippets: breadcrumbs, a frontmatter table, a table of contents, and two small utility functions.

I needed very few features, but primarily I needed something that could not break, is light-weight, and easy to use. Each of the alternatives does more than that, at the cost of either a sync service, a collaboration backend, or a heavier server footprint than a single markdown folder deserves.

In its essence, SilverBullet is a stack of markdown files, served through the browser. The browser interface already provides nice features, such as shortcuts so you can write without leaving your keyboard, but also customization options.

Where SilverBullet really starts to shine is in its use of Space Lua, SilverBullet’s own dialect of Lua. It’s the same language that powers Neovim and Redis, and syntactically close enough to real Lua that most general Lua snippets carry over with little adjustment.

Space Lua can be used to add functionality to your SilverBullet space. In this blog post, I wanted to share a few snippets that I’ve written, because, as much as I love using SilverBullet, the documentation can sometimes be confusing. Especially since SilverBullet has been under active development, going from v1 to v2, many solutions presented online for v1 cannot be used for v2 (the version I am using) anymore.

To enable file versioning you could set SilverBullet up with git. However, I had already set up daily snapshots of the location so haven’t done so yet.

Setting up SilverBullet

HTTP, basic auth
reads/writes markdown
DNS lookups
Browser
silverbullet:2.3.0 container
/opt/silverbullet volume
1.1.1.1 / 8.8.8.8

For complete transparency of the version I am using, I am using version 2.3.0. I pin the exact version rather than tracking the :latest tag, general good practice for anything running unattended on a server, not a fix for a specific incident.

docker-compose.yml
Copy
version: '3' services: silverbullet: # image: ghcr.io/silverbulletmd/silverbullet:v2 image: ghcr.io/silverbulletmd/silverbullet:2.3.0 dns: - 1.1.1.1 - 8.8.8.8 environment: - SB_USER=admin:silver_bullet_password ports: - "3000:3000" volumes: - /opt/silverbullet:/space restart: always

Useful components

Generates breadcrumbs for page navigation. For example:
${breadcrumbs()} >>> 🏠 / silverbullet / components

breadcrumbs
Copy
breadcrumbs=breadcrumbs or {} local function breadcrumb(path) local mypath = path or editor.getCurrentPage() local parts = string.split(mypath,"/") local breadcrumbs = {} for i,part in ipairs(parts) do local current = "" for j=1,i do if current ~= "" then current=current.."/" end current = current..parts[j] end table.insert(breadcrumbs, {name = current}) end return breadcrumbs end breadcrust = template.new([==[/[[${name}]]]==]) function breadcrumbs(path) return "[[index|🏠]]"..(template.each(breadcrumb(path),breadcrust)) end

frontmatterTable

Generates a table from frontmatter content. For example:

frontmatterTable
Copy
function frontmatterTable(key, columns) -- Build markdown table header local head = "|" local separator = "|" for _, col in ipairs(columns) do head = head .. " " .. col .. " |" separator = separator .. "--------|" end head = head .. "\n" .. separator .. "\n" -- Build the row template local row_cells = {} for i, column in ipairs(columns) do table.insert(row_cells, "${" .. column .. "}") end local function row_template(data) local row_cells = {} for i, column in ipairs(columns) do local value = data[column] or "" local ref = nil if i == 1 then ref = data.ref elseif string.sub(value, -4) == ".com" then ref = "https://" .. value end if ref ~= nil then value = "[" .. value .. "](" .. "/" .. ref .. ")" end table.insert(row_cells, value) end return "| " .. table.concat(row_cells, " | ") .. " |\n" end -- Run query and assemble body local body = template.each(query[[ from index.tag "page" where table.includes(tags, key) ]], row_template) return head .. body end

toc

Generates a hierarchical table of contents of pages below the current page. For example:
${toc(2)}

toc
Copy
toc = toc or {} function getDepth(pageName) return #string.split(pageName, "/") end function row(depth) local depth = depth or 1 local mypath = editor.getCurrentPage() local minDepth = getDepth(mypath) local tocIgnore = {} if mypath == "index" then mypath = "" minDepth = 0 tocIgnore = {"CONFIG", "index", "Library/Std"} else mypath = mypath .. "/" end local pages = query[[ from index.tag "page" ]] or {} local rows = {} for page in pages do local pageName = page.name if string.startsWith(pageName, mypath) and not has(tocIgnore, pageName) then local pageDepth = getDepth(pageName) local relDepth = pageDepth - minDepth if relDepth <= depth then local indent = "" for i = 1, relDepth - 1 do indent = indent .. " " end table.insert(rows, { name = pageName, indent = indent}) end end end return rows end rowTemplate = template.new([==[ ${indent}../[[${name}]] ]==]) function toc(depth) return template.each(row(depth), rowTemplate) end

Useful methods

has

Checks if an array contains a value. For example:
${has({1, 2, 3, 4, 5}, 4)} >>> True

has
Copy
function has (tab, val) for index, value in ipairs(tab) do if value == val then return true end end return false end

humanTimeAgo

Converts ISO time to human time ago. For example:
${humanTimeAgo("2026-2-1T14:30:15Z")} >>> 10 days ago.

humanTimeAgo
Copy
function parseIsoTime(iso) local year, month, day, hour, min, sec = iso:match("(%d+)%-(%d+)%-(%d+)T(%d+):(%d+):(%d+)") return os.time({ year = tonumber(year), month = tonumber(month), day = tonumber(day), hour = tonumber(hour), min = tonumber(min), sec = tonumber(sec) }) end -- Convert ISO timestamp to "x ago" function humanTimeAgo(iso) local now = os.time() local past = parseIsoTime(iso) local delta = now - past local minutes = delta / 60 local hours = minutes / 60 local days = hours / 24 local weeks = days / 7 local months = days / 30 local years = days / 365 if minutes < 1 then return 'Now' elseif minutes < 60 then return string.format("%d minutes ago", math.floor(minutes + 0.5)) elseif hours < 48 then return string.format("%d hours ago", math.floor(hours + 0.5)) elseif days < 15 then return string.format("%d days ago", math.floor(days + 0.5)) elseif weeks < 9 then return string.format("%d weeks ago", math.floor(weeks + 0.5)) elseif months < 49 then return string.format("%d months ago", math.floor(months + 0.5)) else return string.format("%d years ago", math.floor(years + 0.5)) end end

That’s the full set for now. If you hit the same v1-vs-v2 documentation gaps I did, the SilverBullet GitHub repo and community forum are the places to check before assuming a snippet is broken.