Implements general purpose widgets: buttons, sub-page lists, and docked views (table of contents, linked mentions and linked tasks).
std.toc, command Navigate: Table of Contents): the current page's headers as a tree, live as you type. Opens in the right sidebar. A page with fewer than minHeaders headers has no outline worth showing, and the view renders nothing at all in a page dock there.std.linkedMentions, command Navigate: Linked Mentions): every other page linking to this one, with a snippet of context. Docks at the bottom of the page, open until you close it.std.linkedTasks, command Navigate: Linked Tasks): incomplete tasks on other pages that link to this one. Docks at the top of the page, open until you close it.None of the three has an enabled config key any more. Each remembers its own dock and open/closed state: close it with its ×, bring it back with its command, move it with its dock menu, and that choice is what applies from then on. The one knob left is how short an outline is too short to be worth showing:
-- Only show a table of contents on pages with >= 5 headers
config.set("std.widgets.toc.minHeaders", 5)
To set where a view docks space-wide — and whether it starts open, folded, or what size it uses — use view.defaults (view.docks and navigator.docks still work as dock-only fallbacks).
-- priority: 10
--- Renders a button that runs `callback` when clicked; `text` is shown literally.
--- @param text string the button label
--- @param callback function run on click
--- @param attrs? table extra attributes for the button element
--- @return table an inline widget
--- @see API/widgets
function widgets.button(text, callback, attrs)
-- Labels are plain text: as markdown, ">" or "# 1" would render as blocks
local buttonEl = {
onclick = callback,
__rawText = text
}
-- attrs can be used for additional customization
if attrs then
for k, v in pairs(attrs) do
buttonEl[k] = v
end
end
return widget.html(dom.button(buttonEl))
end
--- Renders a button that runs a command; with one argument it is both label and command name.
--- @param text string the button label (or the command name)
--- @param commandName? string the command to run
--- @param args? table arguments passed to the command
--- @return table an inline widget
--- @see API/widgets
function widgets.commandButton(text, commandName, args)
if not commandName then
-- When only passed one argument, then let's assume it's a command name
commandName = text
end
return widget.html(dom.button {
onclick = function()
editor.invokeCommand(commandName, args)
end,
__rawText = text
})
end
--- Lists the pages below a page.
--- @param pageName? string default the current page
--- @return table a markdown widget
--- @see API/widgets
function widgets.subPages(pageName)
pageName = pageName or editor.getCurrentPage()
return widget.markdown(table.concat(query[[
from p = index.subPages(pageName)
select templates.pageItem(p)
]]))
end
-- priority: 10
widgets = widgets or {}
config.defineCategory {
name = "Widgets",
description = "Enable and configure built-in widgets (table of contents, linked mentions, etc.)",
priority = 45,
}
-- The Table of Contents view has no `enabled` key -- it remembers its own dock
-- and open state -- but it does have a floor: a page with fewer headers than
-- this has no outline worth showing, so the view renders nothing at all there.
config.define("std.widgets.toc", {
type = "object",
properties = {
minHeaders = {
type = "number",
default = 3,
description = "Minimum number of headers required before rendering a table of contents at all.",
ui = { category = "Widgets", label = "Minimum headers for TOC", priority = 3 },
},
}
})
-- Returns ATX headings as {name, pos, level}; defaults to the current page.
function widgets.tocHeaders(text)
local parsedMarkdown = markdown.parseMarkdown(text or editor.getText())
local headers = {}
for topLevelChild in parsedMarkdown.children do
if topLevelChild.type then
local headerLevel = string.match(topLevelChild.type, "^ATXHeading(%d+)")
if headerLevel then
local label = ""
table.remove(topLevelChild.children, 1)
for child in topLevelChild.children do
label = label .. string.trim(markdown.renderParseTree(child))
end
-- Strip link syntax to avoid nested brackets in TOC
label = string.gsub(label, "%[%[(.-)%]%]", "%1")
if label != "" then
table.insert(headers, {
name = label,
pos = topLevelChild.from,
level = tonumber(headerLevel)
})
end
end
end
end
return headers
end
-- priority: -1
view.define {
name = "std.toc",
title = "Table of Contents",
placeholder = "Header",
command = "Navigate: Table of Contents",
menu = { location = "view", group = "1_views", order = 1, label = "Table of Contents" },
dock = "rhs",
supportedDocks = { "page-top", "page-bottom", "lhs", "rhs", "bhs", "modal" },
defaultOpen = false,
refreshOnOpen = true,
source = function(ctx)
-- A document (not a page) has no markdown text for `tocHeaders` to read,
-- and `editor.getText()` would answer with whatever page was open before.
local path = editor.getCurrentPath()
if not string.match(path, "%.md$") then
return {}
end
local headers = widgets.tocHeaders()
if ctx.dock == "page-top" or ctx.dock == "page-bottom" then
local minHeaders = config.get("std.widgets.toc", {}).minHeaders or 3
if #headers < minHeaders then
return {}
end
end
-- Nest by ancestor chain: nearest shallower header is the parent.
local rows = {}
local stack = {}
local taken = {}
for _, header in ipairs(headers) do
while #stack > 0 and stack[#stack].level >= header.level do
table.remove(stack)
end
-- "/" is the tree's path separator; look-alike keeps it literal.
local nodePath = string.gsub(header.name, "/", "∕")
if #stack > 0 then
nodePath = stack[#stack].path .. "/" .. nodePath
end
while taken[nodePath] do
nodePath = nodePath .. " @" .. header.pos
end
taken[nodePath] = true
table.insert(stack, { level = header.level, path = nodePath })
table.insert(rows, {
name = nodePath,
header = header.name,
pos = header.pos,
})
end
return rows
end,
presentation = {
mode = "tree",
expandAll = true,
expansionScope = "page",
foldersFirst = false,
row = {
primary = "header",
label = "header",
cssClass = function() return "sb-nav-noband" end,
},
},
keymap = {
[" "] = function(obj)
editor.navigate { page = editor.getCurrentPage(), pos = obj.pos }
end,
},
onSelect = function(obj)
editor.navigate { page = editor.getCurrentPage(), pos = obj.pos }
end,
}
-- priority: 10
widgets = widgets or {}
local mentionTemplate = template.new [==[
**[[${_.page}@${_.start}]]**:
${_.snippet}
]==]
local function mentions(pageName)
pageName = pageName or editor.getCurrentPage()
return query[[
from r = index.relations()
where r.page != pageName
and r.to == pageName
and r.kind != "co-mention"
order by r.pageLastModified desc, r.range[1]
]]
end
function widgets.linkedMentionsMarkdown(pageName)
local rows = {}
for _, r in ipairs(mentions(pageName)) do
table.insert(rows, mentionTemplate({ page = r.page, snippet = r.snippet, start = r.range[1] }))
end
return table.concat(rows, "")
end
-- The source page as a path: a muted folder, then the page name as the link
local function mentionSource(r)
local folder, leaf = r.page:match("^(.*/)([^/]+)$")
return widget.new {
markdown = (folder or "") .. markdown.link(r.page .. "@" .. r.range[1], leaf or r.page),
cssClasses = { "sb-mention-source" },
evaluate = false,
}
end
local function linkedMentionsSummary(pageName)
local rows = {}
for _, r in ipairs(mentions(pageName)) do
local line = (r.snippet or ""):match("^[^\n]*")
local summary = widget.markdownSummary(line, { sourcePage = r.page })
table.insert(rows, "* " .. mentionSource(r) .. " — " .. summary)
end
return table.concat(rows, "\n")
end
function widgets.linkedMentions(pageName)
local md = linkedMentionsSummary(pageName)
if md ~= "" then
return widget.markdownBlock("# Linked Mentions\n\n" .. md)
end
end
view.define {
name = "std.linkedMentions",
title = "Linked Mentions",
command = "Navigate: Linked Mentions",
menu = { location = "view", group = "1_views", order = 2, label = "Linked Mentions" },
dock = "page-bottom",
supportedDocks = { "page-top", "page-bottom", "lhs", "rhs", "bhs", "modal" },
defaultOpen = true,
refreshOnOpen = true,
content = function()
local md = linkedMentionsSummary()
if md == "" then return "" end
return widget.markdown(md)
end,
}
/* A mention's source: interface type, muted folder, full-weight page name */
.sb-mention-source {
font-family: var(--ui-font);
font-size: 0.8em;
font-weight: 500;
color: var(--subtle-color);
}
/* `:is(#sb-main, body)` outranks the widget link rules scoped under #sb-main */
:is(#sb-main, body) .sb-mention-source a.wiki-link {
color: var(--ui-accent-text-color);
font-weight: 600;
text-decoration: none;
}
-- priority: 10
-- Linked tasks as Markdown without a heading, or "" when empty.
-- taskItem includes page@pos refs so checkboxes write back to their source.
function widgets.linkedTasksMarkdown(pageName)
pageName = pageName or editor.getCurrentPage()
local tasks = query[[
from t = index.tasks()
where not t.done and table.includes(t.ilinks, pageName)
order by t.page
select templates.taskItem(t)
]]
if #tasks == 0 then
return ""
end
return table.concat(tasks)
end
function widgets.linkedTasks(pageName)
local md = widgets.linkedTasksMarkdown(pageName)
if md != "" then
md = "# Linked Tasks\n" .. md
end
return widget.new {
markdown = md
}
end
-- priority: -1
-- A *content* view, like linked mentions: the tasks render as real markdown
-- tasks, so their checkboxes tick and write straight back to the page each
-- task lives on -- no need to navigate there first.
view.define {
name = "std.linkedTasks",
title = "Linked Tasks",
command = "Navigate: Linked Tasks",
menu = { location = "view", group = "1_views", order = 3, label = "Linked Tasks" },
dock = "page-top",
supportedDocks = { "page-top", "page-bottom", "lhs", "rhs", "bhs", "modal" },
defaultOpen = true,
refreshOnOpen = true,
content = function()
return widgets.linkedTasksMarkdown()
end,
}