Update
- Added variables to show or hide tags at the start, inline, or end of results. This is useful if your tags are part of the sentence (e.g.
#toread this great book)- Remove ref arrow link for nested items/tasks. Pointless when the parent links to the right place
- Added missing Data table support
I’ve spent a couple of hours with AI modifying the default tag page to add some extra functionality - mainly supporting nested tasks and items.
If you want to use this, create a page named something like Library/Personal/Tag Page with:
---
description: Custom tag page override
tags: meta
---
```space-lua
-- Custom tag page override
-- Overrides the default tag virtual page with a customized implementation
virtualPage.define {
pattern = "tag:(.+)",
run = function(tagName)
-- Configuration: Control whether tags are kept in the display
-- Set to true to keep tags, false to remove them
local tagDisplayConfig = {
keepAtStart = true, -- Keep tags at the beginning (e.g., "#toread Book title")
keepInline = true, -- Keep tags in the middle (e.g., "This is a #test-item in a sentence")
keepAtEnd = true, -- Keep tags at the end (e.g., "Book title #toread")
}
local text = "# #" .. tagName .. "\n\n"
-- Get all objects tagged with this tag (both direct and inherited)
local directObjects = query[[
from index.tag(tagName)
order by page, ref
]]
-- Get items and tasks that inherit the tag (nested items)
local inheritedItems = query[[
from index.tag "item"
where table.includes(_.itags, tagName)
and (not _.tags or not table.includes(_.tags, tagName))
order by page, ref
]]
local inheritedTasks = query[[
from index.tag "task"
where table.includes(_.itags, tagName)
and (not _.tags or not table.includes(_.tags, tagName))
order by page, ref
]]
local inheritedParagraphs = query[[
from index.tag "paragraph"
where table.includes(_.itags, tagName)
order by page, ref
]]
local inheritedData = query[[
from index.tag "data"
where table.includes(_.itags, tagName)
and (not _.tags or not table.includes(_.tags, tagName))
order by page, ref
]]
-- Merge all objects, deduplicating by ref
-- This prevents duplicates when objects appear in both directObjects and inherited queries
-- (e.g., a paragraph with an inline tag appears in both directObjects and inheritedParagraphs)
local allObjects = {}
local seenRefs = {}
local function addIfNew(obj)
local ref = obj.ref
if ref and not seenRefs[ref] then
seenRefs[ref] = true
table.insert(allObjects, obj)
elseif not ref then
-- Objects without refs (like pages) can be added
table.insert(allObjects, obj)
end
end
for _, obj in ipairs(directObjects) do
addIfNew(obj)
end
for _, obj in ipairs(inheritedItems) do
addIfNew(obj)
end
for _, obj in ipairs(inheritedTasks) do
addIfNew(obj)
end
-- Only process inheritedParagraphs if there are any (optimization)
if #inheritedParagraphs > 0 then
for _, obj in ipairs(inheritedParagraphs) do
addIfNew(obj)
end
end
for _, obj in ipairs(inheritedData) do
addIfNew(obj)
end
-- Show parent tags if this is a hierarchical tag
local tagParts = tagName:split("/")
if #tagParts > 1 then
text = text .. "## Parent Tags\n"
for i = 1, #tagParts - 1 do
local parentTag = table.concat({table.unpack(tagParts, 1, i)}, "/")
text = text .. "* [[tag:" .. parentTag .. "|#" .. parentTag .. "]]\n"
end
text = text .. "\n"
end
-- Show child tags
local subTags = query[[
from index.tag "tag"
where _.name:startsWith(tagName .. "/")
select {name=_.name}
]]
if #subTags > 0 then
text = text .. "## Child Tags\n"
for _, subTag in ipairs(subTags) do
text = text .. "* [[tag:" .. subTag.name .. "|#" .. subTag.name .. "]]\n"
end
text = text .. "\n"
end
-- Group objects by type and filter
local taggedPages = {}
local taggedTasks = {}
local taggedItems = {}
local taggedParagraphs = {}
local taggedData = {}
local pagesWithTag = {}
-- Collect pages that have the tag (from tag-only lines)
for _, obj in ipairs(allObjects) do
if obj.itags and table.includes(obj.itags, "page") then
table.insert(taggedPages, obj)
pagesWithTag[obj.name] = true
end
end
-- Filter other object types
for _, obj in ipairs(allObjects) do
if obj.itags then
local pageName = obj.page or ""
local pageHasTag = pagesWithTag[pageName] or false
if table.includes(obj.itags, "task") then
-- If page has tag (from tag-only line), only include tasks with tag in text or directly
-- Otherwise include all tasks (they have tag directly or inherit from parent item)
if not pageHasTag or (obj.text or ""):find("#" .. tagName) or (obj.tags and table.includes(obj.tags, tagName)) then
table.insert(taggedTasks, obj)
end
elseif table.includes(obj.itags, "item") then
-- If page has tag (from tag-only line), only include items with tag in text or directly
-- Otherwise include all items (they have tag directly or inherit from parent item)
if not pageHasTag or (obj.text or ""):find("#" .. tagName) or (obj.tags and table.includes(obj.tags, tagName)) then
table.insert(taggedItems, obj)
end
elseif table.includes(obj.itags, "paragraph") then
-- Paragraph filtering:
-- - If page has tag (from tag-only line): only include if tag is in paragraph text
-- - Otherwise: include if paragraph has tag directly (from directObjects)
-- Note: Paragraphs with inline tags are already in directObjects, so we don't need
-- to check obj.tags separately - if it's here and page doesn't have tag, it has the tag
if pageHasTag then
-- Page has tag - only include if tag is in paragraph text
-- Check for tag pattern: #tagName followed by space, end of string, or punctuation
local paraText = obj.text or ""
if paraText:find("#" .. tagName .. "%s") or paraText:find("#" .. tagName .. "$") or paraText:find("%s#" .. tagName .. "%s") then
table.insert(taggedParagraphs, obj)
end
else
-- Page doesn't have tag - paragraph must have tag directly (it's from directObjects)
table.insert(taggedParagraphs, obj)
end
elseif table.includes(obj.itags, "data") then
-- If page has tag (from tag-only line), only include data with tag in text or directly
-- Otherwise include all data (they have tag directly or inherit from parent item)
if not pageHasTag or (obj.text or ""):find("#" .. tagName) or (obj.tags and table.includes(obj.tags, tagName)) then
table.insert(taggedData, obj)
end
end
end
end
if #taggedPages > 0 then
text = text .. "## Pages\n"
for _, page in ipairs(taggedPages) do
text = text .. "* [[" .. (page.name or "unknown") .. "]]\n"
end
text = text .. "\n"
end
-- Helper: extract numeric part from ref for sorting
local function getRefNumber(ref)
if not ref then return 0 end
local num = ref:match("@(%d+)")
return tonumber(num) or 0
end
-- Helper function to remove tags based on configuration
-- Uses tagDisplayConfig to determine which tags to keep/remove
local function removeStandaloneTag(text, tag)
if not text then return "" end
-- If all tags should be kept, return text as-is
if tagDisplayConfig.keepAtStart and tagDisplayConfig.keepInline and tagDisplayConfig.keepAtEnd then
return text
end
local tagPattern = "#" .. tag
local result = text
-- Remove tag at the start if configured
if not tagDisplayConfig.keepAtStart then
result = result:gsub("^%s*" .. tagPattern .. "%s+", "")
result = result:gsub("^%s*" .. tagPattern .. "%s*$", "")
end
-- Remove tag at the end if configured
if not tagDisplayConfig.keepAtEnd then
result = result:gsub("%s+" .. tagPattern .. "%s*$", "")
end
-- Remove inline tags if configured
if not tagDisplayConfig.keepInline then
result = result:gsub("%s+" .. tagPattern .. "%s+", " ")
end
return result
end
-- Find items that are children of tasks - these should appear in Tasks section
local taskRefs = {}
for _, task in ipairs(taggedTasks) do
taskRefs[task.ref] = true
end
-- Separate items into: items under tasks vs standalone items
local itemsUnderTasks = {}
local standaloneItems = {}
for _, item in ipairs(taggedItems) do
if item.parent and taskRefs[item.parent] then
-- This item is a child of a task - attach it to the task
if not itemsUnderTasks[item.parent] then
itemsUnderTasks[item.parent] = {}
end
table.insert(itemsUnderTasks[item.parent], item)
else
-- Standalone item (not under a task)
table.insert(standaloneItems, item)
end
end
-- Helper: render items/tasks with nested children
-- additionalChildren: optional map of parentRef -> children for mixed types (e.g., items under tasks)
local function renderWithChildren(items, isTask, additionalChildren)
local itemMap = {}
local rootItems = {}
for _, item in ipairs(items) do
itemMap[item.ref] = item
if not item.parent then
table.insert(rootItems, item)
end
end
table.sort(rootItems, function(a, b)
if (a.page or "") ~= (b.page or "") then
return (a.page or "") < (b.page or "")
end
return getRefNumber(a.ref) < getRefNumber(b.ref)
end)
local childrenMap = {}
for _, item in ipairs(items) do
if item.parent and itemMap[item.parent] then
if not childrenMap[item.parent] then
childrenMap[item.parent] = {}
end
table.insert(childrenMap[item.parent], item)
end
end
-- Merge additionalChildren (e.g., items under tasks) into childrenMap
if additionalChildren then
for parentRef, children in pairs(additionalChildren) do
if not childrenMap[parentRef] then
childrenMap[parentRef] = {}
end
for _, child in ipairs(children) do
table.insert(childrenMap[parentRef], child)
end
end
end
local function renderItem(item, indent)
indent = indent or 0
local indentStr = string.rep(" ", indent)
-- Determine if this is a task: check if it has done property
-- Items nested under tasks won't have done property, so they'll render as items
local itemIsTask = item.done ~= nil
local isNested = indent > 0
local refLink = isNested and "" or ("[[" .. (item.ref or "") .. "|↗]] ")
if itemIsTask then
local taskText = (item.text or ""):gsub("^%s*%[%s*[xX]?%s*%]%s*", "")
taskText = removeStandaloneTag(taskText, tagName)
local done = item.done and "x" or " "
text = text .. indentStr .. "* [" .. done .. "] " .. refLink .. taskText
if item.deadline then
text = text .. " 📅 " .. item.deadline
end
text = text .. "\n"
else
local itemText = removeStandaloneTag(item.text or "", tagName)
text = text .. indentStr .. "* " .. refLink .. itemText .. "\n"
end
if childrenMap[item.ref] then
table.sort(childrenMap[item.ref], function(a, b)
return getRefNumber(a.ref) < getRefNumber(b.ref)
end)
for _, child in ipairs(childrenMap[item.ref]) do
renderItem(child, indent + 1)
end
end
end
for _, item in ipairs(rootItems) do
renderItem(item, 0)
end
for _, item in ipairs(items) do
if item.parent and not itemMap[item.parent] then
renderItem(item, 0)
end
end
end
if #taggedTasks > 0 then
text = text .. "## Tasks\n"
-- Pass itemsUnderTasks so items nested under tasks appear in Tasks section
renderWithChildren(taggedTasks, true, itemsUnderTasks)
text = text .. "\n"
end
-- Only show standalone items (not nested under tasks)
if #standaloneItems > 0 then
text = text .. "## Items\n"
renderWithChildren(standaloneItems, false)
text = text .. "\n"
end
if #taggedParagraphs > 0 then
text = text .. "## Paragraphs\n"
for _, para in ipairs(taggedParagraphs) do
local paraText = removeStandaloneTag(para.text or "")
text = text .. "> [[" .. (para.ref or "") .. "|↗]] " .. paraText:gsub("\n", " ") .. "\n\n"
end
end
if #taggedData > 0 then
text = text .. "## Data\n"
-- Standard properties to exclude from display
local standardProps = {
ref = true,
page = true,
text = true,
tags = true,
itags = true,
parent = true,
pos = true,
tag = true
}
-- Collect all custom field names from all data objects
local allCustomFields = {}
for _, dataObj in ipairs(taggedData) do
for key, _ in pairs(dataObj) do
if not standardProps[key] and key ~= "_" and not allCustomFields[key] then
allCustomFields[key] = true
end
end
end
-- Convert to sorted array for consistent column order
local customFieldNames = {}
for key, _ in pairs(allCustomFields) do
table.insert(customFieldNames, key)
end
table.sort(customFieldNames)
-- Build table header
if #customFieldNames > 0 then
text = text .. "| ref | "
for _, fieldName in ipairs(customFieldNames) do
text = text .. fieldName .. " | "
end
text = text .. "\n|"
for _ = 1, #customFieldNames + 1 do
text = text .. "---|"
end
text = text .. "\n"
-- Build table rows
for _, dataObj in ipairs(taggedData) do
text = text .. "| [[" .. (dataObj.ref or "") .. "|↗]] | "
for _, fieldName in ipairs(customFieldNames) do
local value = dataObj[fieldName]
text = text .. tostring(value or "") .. " | "
end
text = text .. "\n"
end
else
-- No custom fields, just list refs
for _, dataObj in ipairs(taggedData) do
text = text .. "* [[" .. (dataObj.ref or "") .. "|↗]]\n"
end
end
text = text .. "\n"
end
if #allObjects == 0 then
text = text .. "No content tagged with #" .. tagName .. "\n"
end
return text
end
}
And a test page if you want to try it:
# Test Tag Page
## Test Section 1: Items with Tags
- #test-item First test item with a tag
- Nested item that should inherit the tag
- Another nested item
- #test-item Second test item with the same tag
- #test-diy Item tagged with diy
- #test-research Item tagged with research
## Test Section 2: Tasks with Tags
- [ ] #test-task First incomplete task
- [ ] Nested task that should inherit the tag
- Some nested bullet for this task
- [ ] Some task with a #test-ending-tag
- [x] #test-task Completed task with tag
- [ ] #test-diy Task tagged with diy
- [ ] #test-research Task tagged with research 📅 2026-02-15
## Test Section 3: Hierarchical Tags
- #test/parent/child Parent tag with hierarchy
- Item under hierarchical tag
- [ ] Task under hierarchical tag
## Test Section 4: Paragraphs with Tags
#test-paragraph
This is a paragraph that should appear in the tag page. It contains multiple sentences to test how paragraphs are displayed in the tag page override. #test-inline-paragraph
Another paragraph in the same section to test multiple inline tags. #test-inline-paragraph-2 #test-inline-paragraph-3
## Test Section 5: Mixed Content
- #test-mixed Main item with tag
- [ ] Nested #test-inline task
- Nested item
- Another nested item with more content
## Test Section 6: Multiple Tags on Same Item
- #test-item #test-diy Item with multiple tags
- [ ] #test-task #test-research Task with multiple tags


