Stand with Ukraine 🇺🇦

Page Tree View — Confluence Cloud Macro

navigation

This macro dynamically fetches and displays a collapsible tree view of pages, where each title is a clickable link to its corresponding page.

Try for free

User Parameters

Parent Page

Choose a parent page to generate the page tree (defaults to the current page if none is selected)

Template

## Set parent page variables (current page by default)
#set($parentPageId = $page.id)
#set($parentPageTitle = $page.title)

## If user parameter is provided, extract parent page data
#if($parameters["parentPage"])
    #set($parentPageParts = $StringUtils.split($parameters["parentPage"], ":"))
    #if($parentPageParts.size() > 1)
        #set($parentPageTitle = $parentPageParts[1])
        #set($parentPageSpace = $parentPageParts[0])
    #else
        #set($parentPageTitle = $parentPageParts[0])
        #set($parentPageSpace = $space.key)
    #end

    ## Search for the parent page by title and space
    #set($cql = "title='$parentPageTitle' and space='$parentPageSpace'")
    #set($parentSearchResult = $ConfluenceManager.get("/wiki/rest/api/content/search?cql=$cql"))
    #if($parentSearchResult.results.size() > 0)
        #set($parentPageId = $parentSearchResult.results[0].id)
    #end
#end

<style>
    ul {
        display: flex;
        flex-direction: column;
        width: 100%;
        gap: 6px;
        padding-left: 10px;
        min-height: 100px;
    }

    li {
        list-style: none;
        display: flex;
        align-items: center;
        flex-wrap: wrap;
    }

    .main-container {
        width: fit-content;
        height: 100vh;
        overflow: hidden;
    }

    .expand-arrow,
    .list-bullet {
        margin-right: 5px;
        min-width: 18px;
        min-height: 18px;
        text-align: center;
    }

    .expand-arrow {
        cursor: pointer;
        display: inline-block;
        width: 18px;
        height: 18px;
        background-size: contain;
        background-repeat: no-repeat;
        background-position: center;
    }

    .child-list {
        padding-left: 20px;
        display: none;
        opacity: 0;
        transition: opacity 0.3s ease-in-out;
        overflow: hidden;
    }

    .no-children {
        margin-top: 2px;
    }
</style>

<main class="main-container">
    <ul id="pageList">
        <li>
            <span class="expand-arrow" data-page-id="$parentPageId"></span>
            <a href="$baseUrl/pages/viewpage.action?pageId=$parentPageId" target="_blank">$parentPageTitle</a>
            <ul class="child-list" id="children-$parentPageId"></ul>
        </li>
    </ul>
</main>

<script>
    const childrenMaxLimit = 250;
    const baseURL = "$baseUrl";
    let arrowIcons = {
        collapsed: "https://api.iconify.design/mdi/chevron-right.svg",
        expanded: "https://api.iconify.design/mdi/chevron-down.svg"
    };
    let loadedChildren = new Set();

    document.addEventListener("DOMContentLoaded", function () {
        const rootPageId = "$parentPageId";
        const rootArrow = document.querySelector('.expand-arrow[data-page-id="' + rootPageId + '"]');
        checkHasChildren(rootPageId, rootArrow);
    });


    const setArrowIcon = (arrow, state) => {
        const iconURL = arrowIcons[state];
        const fallback = state === "collapsed" ? "▶": "▼";

        const img = new Image();
        img.src = iconURL;
        img.width = 18;
        img.height = 18;

        img.onload = () => {
            arrow.textContent = "";
            arrow.style.backgroundImage = 'url("' + iconURL + '")';
        };

        img.onerror = () => {
            arrow.style.backgroundImage = "none";
            arrow.textContent = fallback;
            arrow.style.fontSize = "16px";
            arrow.style.lineHeight = "18px";
        };
    };


    function checkHasChildren(parentId, arrow) {
        AP.request({
            url: "/api/v2/pages/" + parentId + "/children?limit=" + childrenMaxLimit,
            type: "GET",
            contentType: "application/json",
            success: function (response) {
                const data = JSON.parse(response);
                const li = arrow.closest("li");

                if (data.results.length > 0) {
                    setArrowIcon(arrow, "collapsed");

                    arrow.onclick = function () {
                        toggleChildren(parentId, arrow);
                    };
                } else {
                    arrow.outerHTML = '<span class="list-bullet">•</span>';
                    li.classList.add("no-children");
                }
            },
            error: function (xhr) {
                console.error("Failed to check children for page " + parentId + ":", xhr.status, xhr.statusText);
            }
        });
    }

    function toggleChildren(parentId, arrow) {
        const sublist = document.getElementById("children-" + parentId);
        if (!sublist) return;

        const isExpanded = sublist.style.display === "block";

        if (isExpanded) {
            sublist.style.opacity = "0";
            setTimeout(function () {
                sublist.style.display = "none";
                setArrowIcon(arrow, "collapsed");
            }, 300);
        } else {
            sublist.style.display = "block";
            setTimeout(function () {
                sublist.style.opacity = "1";
                setArrowIcon(arrow, "expanded");
            }, 10);

            if (!loadedChildren.has(parentId)) {
                loadedChildren.add(parentId);
                AP.request({
                    url: "/api/v2/pages/" + parentId + "/children?limit=" + childrenMaxLimit,
                    type: "GET",
                    contentType: "application/json",
                    success: function (response) {
                        const data = JSON.parse(response);
                        renderPages(data.results, sublist);
                    },
                    error: function (xhr) {
                        console.error("Failed to fetch children for page " + parentId + ":", xhr.status, xhr.statusText);
                    }
                });
            }
        }
    }

    function renderPages(items, container) {
        items.forEach(function (item) {
            const li = document.createElement("li");

            const link = document.createElement("a");
            link.href = baseURL + "/pages/viewpage.action?pageId=" + item.id;
            link.textContent = item.title;
            link.target = "_blank";

            const sublist = document.createElement("ul");
            sublist.classList.add("child-list");
            sublist.id = "children-" + item.id;

            const arrow = document.createElement("span");
            arrow.className = "expand-arrow";
            arrow.setAttribute("data-page-id", item.id);

            li.appendChild(arrow);
            li.appendChild(link);
            container.appendChild(li);
            container.appendChild(sublist);

            checkHasChildren(item.id, arrow);
        });
    }
</script>