--- author: malys description: Automatically formats Markdown table cells based on hashtag column tags. tags: userscript name: "Library/Malys/MdTableRender" share.uri: "https://github.com/malys/silverbullet-libraries/blob/main/src/MdTableRender.md" share.hash: da8e2c70 share.mode: pull --- # Md table column rendering This script enhances Markdown tables inside SilverBullet by applying dynamic formatting rules to columns marked with hashtag-style format tags (e.g. `#euro`, `#percent`, `#stars`). It observes table changes in real time and transforms raw text values into styled, formatted elements โ€” such as currency, percentages, booleans, dates, badges, emojis, trends, and star ratings โ€” without altering the original Markdown source. It is designed to be non-intrusive, editable-friendly, and resilient thanks to mutation observers, debouncing, and a polling fallback. ## Disclaimer & Contributions This code is provided **as-is**, **without any kind of support or warranty**. I do **not** provide user support, bug-fixing on demand, or feature development. If you detect a bug, please **actively participate in debugging it** (analysis, proposed fix, or pull request) **before reporting it**. Bug reports without investigation may be ignored. ๐Ÿšซ **No new features will be added.** โœ… **All types of contributions are welcome**, including bug fixes, refactoring, documentation improvements, and optimizations. By using or contributing to this project, you acknowledge and accept these conditions. ## Supported renderers (via `#tag` in header) | Tag | Effect | | --------------- | --------------------------------------------------------- | | **#euro** | Formats number as โ€œ12 345 โ‚ฌโ€ | | **#usd** | Formats number as โ€œ$12,345โ€ | | **#percent** | Converts decimal to percentage (0.15 โ†’ โ€œ15 %โ€) | | **#gauge** | Graphical percentage representation โ–ˆโ–ˆโ–ˆโ–‘ | | **#posneg** | Colored gauge -2 ๐ŸŸฅ๐ŸŸฅ,0 โฌœ, +1 ๐ŸŸฉ | | **#km** | Formats number as โ€œ12 345 kmโ€ | | **#kg** | Formats number as โ€œ12 345 kgโ€ | | **#watt** | Formats number as โ€œ12 345 Wโ€ | | **#int** | Parses and formats whole numbers with locale separators | | **#float** | Forces 2 decimal places (e.g. โ€œ3.14โ€) | | **#upper** | Forces uppercase | | **#lower** | Forces lowercase | | **#bold** | Wraps value in `` | | **#italic** | Wraps value in `` | | **#link** | Turns URL into clickable link | | **#date** | Formats dates (YYYY-MM-DD or ISO) | | **#datetime** | Formats full timestamp | | **#logical** | Converts truthy โ†’ `โœ…` / falsy โ†’ `โŒ` | | **#stars** | Converts number to up to 10 โญ stars | | **#evaluation** | Converts 0โ€“5 into โ˜…/โ˜† rating | | **#badge** | Renders value as a blue pill badge | | **#emoji** | Converts words like โ€œhappyโ€, โ€œcoolโ€, โ€œneutralโ€ โ†’ ๐Ÿ˜ƒ ๐Ÿ˜Ž ๐Ÿ˜ | | **#mood** | Converts evaluation of mood to emoj 1:bad 5: very well | | **#trend** | Converts + / - / = into ๐Ÿ”ผ ๐Ÿ”ฝ โžก๏ธ | | **#histo** | Converts number to โ–ˆ | Just add the renderer as a hashtag tag in your table header: ```md | Product #wine | Euro #euro | Percent #percent | Logical #logical | Stars #stars | Evaluation #evaluation | Updated | Mood #emoji | Trend #trend | | ------------- | ---------- | ---------------- | ---------------- | ------------ | ---------------------- | -------------------- | ----------- | ------------ | | Widget | 12.99 | 0.15 | 0 | 3 | 4 | 2025-11-06T14:30:00Z | happy | + | | Gadget | 8.50 | 0.23 | false | 5 | 2 | 2024-12-25T10:00:00Z | neutral | - | | Thingamajig | 5.75 | 0.05 | true | 4 | 5 | 2023-05-10T08:15:00Z | cool | = | ``` ![](https://community.silverbullet.md/uploads/default/original/2X/e/e2598b9faf8fb223eb5b68b9d03b0729384c5351.png) ![](https://community.silverbullet.md/uploads/default/original/2X/e/ec9b8a44f48b1854b94544da609e24fb1c9bf888.gif) ## How to ### Add new renderer ```lua mls = mls or {} mls.table = mls.table or {} mls.table.renderer = mls.table.renderer or {} mls.table.renderer["euro"] = { completion = { { name = "one", value = "1" }, { name = "two", value = "2" }, }, visual = [[isNaN(v) ? v : `${parseFloat(v).toLocaleString()} TEST`]], validation = function(v){ --... return true } } ``` ## Code ```space-lua -- Table Renderer (Formatter) mls = mls or {} mls.table = mls.table or {} mls.table.renderer = mls.table.renderer or {} local cfg = config.get("tableRenderer") or {} local enabled = cfg.enabled ~= false -------------------------------------------------- -- FUNCTION -------------------------------------------------- function mls.table.cleanupRenderer() local scriptId = "sb-table-renderer-runtime" local existing = js.window.document.getElementById(scriptId) if existing then local ev = js.window.document.createEvent("Event") ev.initEvent("sb-table-renderer-unload", true, true) js.window.dispatchEvent(ev) existing.remove() print("Table Renderer: Disabled") else print("Table Renderer: Already inactive") end end ------------------------------------------------ -- Generic validators ------------------------------------------------ local function isNumber(v) return tonumber(v) ~= nil end local function isBetween(v, min, max) v = tonumber(v) return v ~= nil and v >= min and v <= max end local function isInt(v) v = tonumber(v) return v ~= nil and math.floor(v) == v end ------------------------------------------------ -- Renderers (JS visual + Lua validation) ------------------------------------------------ mls.table.renderer = { euro = { visual = [[isNaN(v) ? v : `${parseFloat(v).toLocaleString()} โ‚ฌ`]], validation = isNumber }, usd = { visual = [[isNaN(v) ? v : `$${parseFloat(v).toLocaleString()}`]], validation = isNumber }, kg = { visual = [[isNaN(v) ? v : `${parseFloat(v).toLocaleString()} kg`]], validation = isNumber }, km = { visual = [[isNaN(v) ? v : `${parseFloat(v).toLocaleString()} km`]], validation = isNumber }, watt = { visual = [[isNaN(v) ? v : `${parseFloat(v).toLocaleString()} W`]], validation = isNumber }, percent = { visual = [[isNaN(v) ? v : `${(parseFloat(v) * 100).toFixed(0)} %`]], validation = isNumber }, int = { visual = [[isNaN(v) ? v : parseInt(v, 10).toLocaleString()]], validation = isInt }, float = { visual = [[isNaN(v) ? v : parseFloat(v).toFixed(2)]], validation = isNumber }, upper = { visual = [[v.toString().toUpperCase()]], validation = function(v) return v ~= nil end }, lower = { visual = [[v.toString().toLowerCase()]], validation = function(v) return v ~= nil end }, bold = { visual = [[`${v}`]], validation = function(v) return v ~= nil end }, italic = { visual = [[`${v}`]], validation = function(v) return v ~= nil end }, link = { visual = [[`${v.replace(/^https?:\/\//, '')}`]], validation = function(v) return type(v) == "string" and v:match("^https?://") end }, logical = { completion = { { name = "โŒ", value = "false" }, { name = "โœ…", value = "true" }, }, visual = [[ if (v !== 'โœ…' && v !== 'โŒ') { const val = v.toString().toLowerCase().trim(); return (val === '1' || val === 'true' || val === 'yes' || val === 'ok') ? 'โœ…' : 'โŒ'; } return v; ]], validation = function(v) return v ~= nil end }, evaluation = { completion = { { name = "๐Ÿค๐Ÿค๐Ÿค๐Ÿค๐Ÿค", value = "0" }, { name = "โค๏ธ๐Ÿค๐Ÿค๐Ÿค๐Ÿค", value = "1" }, { name = "โค๏ธโค๏ธ๐Ÿค๐Ÿค๐Ÿค", value = "2" }, { name = "โค๏ธโค๏ธโค๏ธ๐Ÿค๐Ÿค", value = "3" }, { name = "โค๏ธโค๏ธโค๏ธโค๏ธ๐Ÿค", value = "4" }, { name = "โค๏ธโค๏ธโค๏ธโค๏ธโค๏ธ", value = "5" }, }, visual = [[ const n = parseInt(v, 10); if (isNaN(n)) return v; return 'โค๏ธ'.repeat(Math.max(0, Math.min(n, 5))) + '๐Ÿค'.repeat(5 - Math.max(0, Math.min(n, 5))); ]], validation = function(v) return isBetween(v, 1, 5) end }, histo = { visual = [[ const n = parseInt(v, 10); return isNaN(n) ? v : 'โ–ˆ'.repeat(n); ]], validation = isInt }, gauge = { visual = [[ const a = 0; const b = 100; const val = parseInt(v, 10); if (isNaN(val)) return v; const clampedValue = Math.max(a, Math.min(val, b)); const percentage = (clampedValue - a) / (b - a); const filled = Math.floor(percentage * 10); const empty = 10 - filled; return `[${'โ–ˆ'.repeat(filled)}${'โ–‘'.repeat(empty)}]`; ]], validation = function(v) return isBetween(v, 0, 100) end }, trend = { completion = { { name = "๐Ÿ”ผ", value = "+" }, { name = "๐Ÿ”ฝ", value = "-" }, { name = "โžก๏ธ", value = "=" } }, visual = [[ const val = v.trim(); if (val === '+') return '๐Ÿ”ผ'; if (val === '-') return '๐Ÿ”ฝ'; if (val === '=') return 'โžก๏ธ'; return val; ]], validation = function(v) return v == "+" or v == "-" or v == "=" end }, emoji = { completion = { -- basic emotions { name = "happy ๐Ÿ˜ƒ", value = "happy" }, { name = "sad ๐Ÿ˜ข", value = "sad" }, { name = "angry ๐Ÿ˜ ", value = "angry" }, { name = "love โค๏ธ", value = "love" }, { name = "neutral ๐Ÿ˜", value = "neutral" }, { name = "cool ๐Ÿ˜Ž", value = "cool" }, -- positive / joyful { name = "smile ๐Ÿ˜Š", value = "smile" }, { name = "grin ๐Ÿ˜", value = "grin" }, { name = "laugh ๐Ÿ˜‚", value = "laugh" }, { name = "excited ๐Ÿคฉ", value = "excited" }, { name = "proud ๐Ÿ˜Œ", value = "proud" }, { name = "relieved ๐Ÿ˜ฎโ€๐Ÿ’จ", value = "relieved" }, { name = "thankful ๐Ÿ™", value = "thankful" }, { name = "party ๐Ÿฅณ", value = "party" }, { name = "confident ๐Ÿ˜", value = "confident" }, -- negative / difficult { name = "cry ๐Ÿ˜ญ", value = "cry" }, { name = "disappointed ๐Ÿ˜ž", value = "disappointed" }, { name = "worried ๐Ÿ˜Ÿ", value = "worried" }, { name = "anxious ๐Ÿ˜ฐ", value = "anxious" }, { name = "scared ๐Ÿ˜ฑ", value = "scared" }, { name = "tired ๐Ÿ˜ด", value = "tired" }, { name = "sick ๐Ÿค’", value = "sick" }, { name = "bored ๐Ÿ˜’", value = "bored" }, { name = "frustrated ๐Ÿ˜ค", value = "frustrated" }, { name = "confused ๐Ÿ˜•", value = "confused" }, -- reactions { name = "surprised ๐Ÿ˜ฎ", value = "surprised" }, { name = "shocked ๐Ÿ˜ฒ", value = "shocked" }, { name = "thinking ๐Ÿค”", value = "thinking" }, { name = "facepalm ๐Ÿคฆ", value = "facepalm" }, { name = "shrug ๐Ÿคท", value = "shrug" }, { name = "eyeRoll ๐Ÿ™„", value = "eyeroll" }, -- social / playful { name = "wink ๐Ÿ˜‰", value = "wink" }, { name = "kiss ๐Ÿ˜˜", value = "kiss" }, { name = "hug ๐Ÿค—", value = "hug" }, { name = "teasing ๐Ÿ˜œ", value = "teasing" }, { name = "silly ๐Ÿคช", value = "silly" }, -- approval / disapproval { name = "ok ๐Ÿ‘Œ", value = "ok" }, { name = "thumbsUp ๐Ÿ‘", value = "thumbsup" }, { name = "thumbsDown ๐Ÿ‘Ž", value = "thumbsdown" }, { name = "clap ๐Ÿ‘", value = "clap" }, { name = "respect ๐Ÿซก", value = "respect" }, -- status / misc { name = "fire ๐Ÿ”ฅ", value = "fire" }, { name = "star โญ", value = "star" }, { name = "check โœ…", value = "check" }, { name = "cross โŒ", value = "cross" }, { name = "warning โš ๏ธ", value = "warning" }, }, visual = [[ const map = { // basic emotions happy: '๐Ÿ˜ƒ', sad: '๐Ÿ˜ข', angry: '๐Ÿ˜ ', love: 'โค๏ธ', neutral: '๐Ÿ˜', cool: '๐Ÿ˜Ž', // positive / joyful smile: '๐Ÿ˜Š', grin: '๐Ÿ˜', laugh: '๐Ÿ˜‚', excited: '๐Ÿคฉ', proud: '๐Ÿ˜Œ', relieved: '๐Ÿ˜ฎโ€๐Ÿ’จ', thankful: '๐Ÿ™', party: '๐Ÿฅณ', confident: '๐Ÿ˜', // negative / difficult cry: '๐Ÿ˜ญ', disappointed: '๐Ÿ˜ž', worried: '๐Ÿ˜Ÿ', anxious: '๐Ÿ˜ฐ', scared: '๐Ÿ˜ฑ', tired: '๐Ÿ˜ด', sick: '๐Ÿค’', bored: '๐Ÿ˜’', frustrated: '๐Ÿ˜ค', confused: '๐Ÿ˜•', // reactions surprised: '๐Ÿ˜ฎ', shocked: '๐Ÿ˜ฒ', thinking: '๐Ÿค”', facepalm: '๐Ÿคฆ', shrug: '๐Ÿคท', eyeRoll: '๐Ÿ™„', // social / playful wink: '๐Ÿ˜‰', kiss: '๐Ÿ˜˜', hug: '๐Ÿค—', teasing: '๐Ÿ˜œ', silly: '๐Ÿคช', // approval / disapproval ok: '๐Ÿ‘Œ', thumbsUp: '๐Ÿ‘', thumbsDown: '๐Ÿ‘Ž', clap: '๐Ÿ‘', respect: '๐Ÿซก', // status / misc fire: '๐Ÿ”ฅ', star: 'โญ', check: 'โœ…', cross: 'โŒ', warning: 'โš ๏ธ', }; const key = v.toString().toLowerCase(); return map[key] || v; ]], validation = function(v) return type(v) == "string" end }, posneg = { completion = { { name = "-2 ๐ŸŸฅ๐ŸŸฅ", value = "-2" }, { name = "-1 ๐ŸŸฅ", value = "-1" }, { name = "0 โฌœ", value = "0" }, { name = "1 ๐ŸŸฉ", value = "1" }, { name = "2 ๐ŸŸฉ๐ŸŸฉ", value = "2" }, }, visual = [[ if (isNaN(v)) return v; const val = parseInt(v, 10); if (val < 0) return "๐ŸŸฅ".repeat(Math.abs(val)); if (val > 0) return "๐ŸŸฉ".repeat(Math.abs(val)); return "โฌœ"; ]], validation = function(v) return isBetween(v, -10, 10) end }, speed = { visual = [[v + " km/h"]], validation = function(v) return isBetween(v, 0, 300) end }, mood = { completion = { { name = "๐Ÿ˜ž", value = "1" }, { name = "๐Ÿ™", value = "2" }, { name = "๐Ÿ˜", value = "3" }, { name = "๐Ÿ™‚", value = "4" }, { name = "๐Ÿ˜„", value = "5" }, }, visual = [[ const n = parseInt(v, 10); const moodScaleSoft = ['๐Ÿ˜”', '๐Ÿ™', '๐Ÿ˜', '๐Ÿ™‚', '๐Ÿ˜„']; return moodScaleSoft[(n - 1) % 5]; ]], validation = function(v) return isBetween(v, 1, 5) end }, stars = { completion = { { name = "1 โญ", value = "1" }, { name = "2 โญโญ", value = "2" }, { name = "3 โญโญโญ", value = "3" }, { name = "4 โญโญโญโญ", value = "4" }, { name = "5 โญโญโญโญโญ", value = "5" }, { name = "6 โญโญโญโญโญโญ", value = "6" }, { name = "7 โญโญโญโญโญโญโญ", value = "7" }, { name = "8 โญโญโญโญโญโญโญโญ", value = "8" }, { name = "9 โญโญโญโญโญโญโญโญโญ", value = "9" }, { name = "10 โญโญโญโญโญโญโญโญโญโญ", value = "10" }, }, visual = [[ const n = parseInt(v, 10); return isNaN(n) ? v : 'โญ'.repeat(Math.max(0, Math.min(n, 10))); ]], validation = function(v) return isBetween(v, 1, 10) end }, badge = { completion = {}, visual = [[ return `${v}`; ]], validation = function(v) return v ~= nil end }, } ------------------------------------------------ -- Dispatcher ------------------------------------------------ mls.table.render = function(label, rendererN) local rendererName = rendererN if (type(rendererN) == "table") then for _, tag in ipairs(rendererN) do if mls.table.renderer[tag] then rendererName = tag end end end if label and rendererName and mls.table.renderer[rendererName] then local renderer = mls.table.renderer[rendererName] local input if renderer.completion and # renderer.completion > 0 then input = editor.filterBox(label, renderer.completion) else input = editor.prompt(label) end local value = input if input and input.value then value = input.value end if renderer.validation(value) then return value else editor.flashNotification("Input not valid: " .. tostring(input), "error") end else editor.flashNotification("Missing renderer: " .. tostring(rendererName), "error") end end ------------------------------------------------ -- JS generator ------------------------------------------------ local function exportJSFormatters(renderers) local lines = {} table.insert(lines, "const formatters = {") for name, def in pairs(renderers) do if def.visual then local js = def.visual -- single line or block if string.match(js, "\n") then table.insert(lines, " " .. name .. ": v => {") table.insert(lines, js) table.insert(lines, " },") else table.insert(lines, " " .. name .. ": v => " .. js .. ",") end end end table.insert(lines, "};") return table.concat(lines, "\n") end -------------------------------------------------- -- ENABLE -------------------------------------------------- function mls.table.enableTableRenderer() local scriptId = "sb-table-renderer-runtime" if js.window.document.getElementById(scriptId) then print("Table Renderer: Already active") return end local scriptEl = js.window.document.createElement("script") scriptEl.id = scriptId scriptEl.innerHTML = [[ (function () { 'use strict'; const DEBUG = false; const log = (...a) => DEBUG && console.log('[sb-table-renderer]', ...a); /* ---------------- FORMATTERS ---------------- */ ]] .. exportJSFormatters(mls.table.renderer) .. [[ /* ---------------- CORE ---------------- */ function extractFormats(table) { const formats = []; const header = table.querySelector('thead tr') || table.querySelector('tr'); if (!header) return formats; [...header.cells].forEach((cell, idx) => { formats[idx] = null; const tags = cell.querySelectorAll('a.hashtag,[data-tag-name]'); for (const tag of tags) { const name = tag.dataset?.tagName || tag.textContent?.replace('#', ''); if (formatters[name]) { formats[idx] = name; tag.style.display = 'none'; break; } } }); return formats; } function processTable(table) { if (table.dataset.sbFormatted) return; const formats = extractFormats(table); const rows = table.tBodies[0]?.rows || []; [...rows].forEach(row => { [...row.cells].forEach((cell, idx) => { const fmt = formats[idx]; if (!fmt) return; const raw = cell.textContent.trim(); const out = formatters[fmt](raw); if (out !== raw) { cell.textContent = out; cell.dataset.sbformatted = fmt; } }); }); table.dataset.sbFormatted = 'true'; } function scan() { document .querySelectorAll('#sb-editor table') .forEach(processTable); } /* ---------------- OBSERVER ---------------- */ const observer = new MutationObserver(scan); observer.observe(document.body, { childList: true, subtree: true }); scan(); /* ---------------- CLEANUP ---------------- */ window.addEventListener('sb-table-renderer-unload', function cln() { observer.disconnect(); document .querySelectorAll('[data-sbformatted]') .forEach(c => { c.removeAttribute('data-sbformatted'); }); document .querySelectorAll('table[data-sb-formatted]') .forEach(t => delete t.dataset.sbFormatted); window.removeEventListener('sb-table-renderer-unload', cln); }); })() ]] js.window.document.body.appendChild(scriptEl) end -------------------------------------------------- -- COMMANDS -------------------------------------------------- command.define{ name = "Table: Enable Renderer", run = function() mls.table.enableTableRenderer() end } command.define{ name = "Table: Disable Renderer", run = function() mls.table.cleanupRenderer() end } -------------------------------------------------- -- AUTOSTART -------------------------------------------------- if enabled then mls.table.enableTableRenderer() else mls.table.cleanupRenderer() end ``` ## Changelog - 2026-02-01 - feat: define renderers in lua - feat: add validation mechanism - feat: add completion mechanism - 2026-01-24: - feat: convert to space-lua - feat: add renderers (mood, emoj) - 2026-01-02 feat: add kg, km, watt, histo ## Community [Silverbullet forum](https://community.silverbullet.md/t/md-table-renderers/3545/15)