Improved(?) tag page template

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
3 Likes