Products
Services
Blog

Table of Contents for Children

Roma Bubyakin

Roma Bubyakin

Oct 21, 2025

Read 8 min

Article

Topics covered: Confluence Storage Format, Atlassian Document Format

Request

Danielle raised a question on 👉 community.atlassian.com

How do I create a table of contents that includes both child pages and their headers?

As the title says, I'm interested in creating a table of contents on a parent page that serves as navigation for the entire section. 

I have a parent page called 'Weekly Client Meetings'. It's blank and we're hoping to put a TOC on it. 

The parent page has 5 child pages, each representing a different client.

Each subpage has 50+ headers that are just dates of past sessions, each representing a table with a bunch of variables captured in each meeting.

The goal is for leadership to be able to navigate directly to a meeting report via the TOC on the parent page without needing to scroll a bunch and just generally have a less clean experience in the subpages. 

It should automatically update. I've seen this in the past and am having trouble replicating the experience.

The Real Problem!

It’s all about 5 children and 50+ headings. That’s a lot of content, and it should be easily accessible — ideally, from a single parent page.

Can User Macro deal with it? Let’s see…


Table of Contents for Children

Step 0: Preparation

To test the macro, we created 5 child pages. Each of them includes ~50 H1 headings.

image-20250717-212716.png

The macro will be installed and tested on the parent page: Weekly Client Meetings.

Step 1: Basic macro

This macro will be:

  • static — it looks more natural, without any iframes, and there is no need for JavaScript. Pure Velocity.

  • block macro — inline is too heavy for the amount of content

  • without a body — no need for any inner content

image-20250717-204921.png

The first version simply lists all direct children with an empty state if none are found.

## Get children of the current page
#set ( $children = $ConfluenceManager.get("/wiki/api/v2/pages/${page.id}/children").results )

#if($children.isEmpty())
    <b>Table of Contents for Children:</b><br>
    <i>No children pages were found for the current page</i>
    #stop ## No need to proceed further
#end

<ul>
  #foreach ( $child in $children )
    <li><b>$child.title</b></li>
  #end
</ul>

${page.id} is the ID of the current page (provided by the macro context).

.results must be accessed to retrieve the actual children list from the REST API response:

{
  "results": [
    {
      "title": "Page A",
      "id": 321321,
      ...
    },
    {
      "title": "Page B",
      "id": 654654,
      ...
    },
    {
      "title": "Page C",
      "id": 987987,
      ...
    }
  ],
  "_links": {
    "base": "https://subdomain.atlassian.net/wiki"
  }
}

Use #stop to end macro execution early (fail-fast approach). Nothing will run afterwards.

After saving the macro, it can be added to the testing page:

Once saved and added, the macro renders a list of child pages.

add-toc-macro-20250717-213514.gif

That’s a great start!

Step 2: Retrieve headings

Here’s the tricky part — getting headings. Atlassian offers multiple body formats for pages (Get page by id):
storage, atlas_doc_format, view, export_view, anonymous_export_view, styled_view, editor

The most suitable for programmatic use is https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/ (aka ADF). This is a JSON file that works well with Velocity and can be parsed easily.

To get the page’s ADF, we need to pass the body-format parameter:

/wiki/api/v2/pages/${child.id}?body-format=atlas_doc_format

The following response will be provided for the child page:

{
  "parentId": "335183874",
  "spaceId": "287604741",
  "ownerId": "557058:d4143166-d5dc-4424-9a20-9b0d10eb61a3",
  "lastOwnerId": null,
  "createdAt": "2025-01-14T09:54:09.046Z",
  "authorId": "557058:d4143166-d5dc-4424-9a20-9b0d10eb61a3",
  "parentType": "page",
  "version": {
    "number": 10,
    "message": "",
    "minorEdit": false,
    "authorId": "557058:d4143166-d5dc-4424-9a20-9b0d10eb61a3",
    "createdAt": "2025-07-17T21:24:48.304Z",
    "ncsStepVersion": "43"
  },
  "position": 53,
  "body": {
    "atlas_doc_format": {
      "representation": "atlas_doc_format",
      "value": "{\"type\":\"doc\",\"content\":[{\"type\":\"heading\",\"attrs\":{\"level\":1},\"content\":[{\"text\":\"2025-01-14\",\"type\":\"text\"}]},{\"type\":\"paragraph\",\"content\":[{\"text\":\"Reviewed annual performance metrics and set KPIs for Q1.\",\"type\":\"text\"}]},{\"type\":\"heading\",\"attrs\":

Key points:

  • The page content is located at body → atlas_doc_format → value

  • Since this is JSON inside JSON, the value field contains escaped characters. However, User Macro automatically unescapes it for convenience

So after executing this line:

#set ( $body = $ConfluenceManager.get("/wiki/api/v2/pages/${child.id}?body-format=atlas_doc_format").body.atlas_doc_format.value )

$body variable becomes a convenient page body representation that may be further manipulated.

After unescaping and beautifying ADF, this looks like the following:

{
   "type":"doc",
   "content":[
      {
         "type":"heading",
         "attrs":{
            "level":1
         },
         "content":[
            {
               "text":"2025-01-14",
               "type":"text"
            }
         ]
      },
      {
         "type":"paragraph",
         "content":[
            {
               "text":"Reviewed annual performance metrics and set KPIs for Q1.",
               "type":"text"
            }
         ]
      },
      {
         "type":"heading",
         "attrs":{
            "level":2
         },
         "content":[
            {
               "text":"h2 heading",
               "type":"text"
            }
         ]
      },
      ...
   ],
   "version":1
}

The first level element is content with an array of items:

#foreach ( $item in $body.content )

Items needed for the macro have type==heading and attrs.level==1:

#if ( $item.type == "heading" && $item.attrs.level == 1)

The actual text of the heading is placed under content.text

Not all headings will work smoothly
If a heading includes formatting or emojis, content[].text may not return the full text
## Get children of the current page
#set ( $children = $ConfluenceManager.get("/wiki/api/v2/pages/${page.id}/children").results )

#if($children.isEmpty())
    <b>Table of Contents for Children:</b><br>
    <i>No children pages were found for the current page</i>
    #stop ## No need to proceed further
#end

<ul>
#foreach ( $child in $children )
  <li><b>$child.title</b><br>

  ## Get child content in JSON format
  #set ( $body = $ConfluenceManager.get("/wiki/api/v2/pages/${child.id}?body-format=atlas_doc_format").body.atlas_doc_format.value )
  #foreach ( $item in $body.content )
    ## Triggers only on H1 headings
    #if ( $item.type == "heading" && $item.attrs.level == 1)
      #set ( $h1 = $item.content[0].text )
$h1,
    #end
  #end

  </li>

#end
</ul>

This is the result:

image-20250717-215111.png

And it looks much better now. However, ToC is not interactive yet.

Step 3: Make ToC elements clickable

First and foremost, let’s replace commas , with pipes |and get rid of the trailing ones:

  #set ( $divider = "" ) ## skip first devider
  #foreach ( $item in $body.content )
      ...
      $divider
      $h1
      #set ( $divider = "|" )
    #end
  #end

We insert the divider before each heading but initialize it only after the first — producing output like:
first | second | third

An alternative approach looks cleaner but doesn’t work reliably when only certain items (like H1 headings) are rendered:

#foreach( $item in $items )
    $customer.Name #if( $foreach.hasNext ) | #end
#else

Now let’s render an anchor link to a child page using Confluence Storage Format. Long story short, this is the only approach that works reliably:

      <ac:link ac:anchor="$h1">
        <ri:page ri:content-title="$child.title"/>
        <ac:plain-text-link-body><![CDATA[$h1]]></ac:plain-text-link-body>
      </ac:link>

Here is the final template:

## Get children of the current page
#set ( $children = $ConfluenceManager.get("/wiki/api/v2/pages/${page.id}/children").results )

#if($children.isEmpty())
    <b>Table of Contents for Children:</b><br>
    <i>No children pages were found for the current page</i>
    #stop ## No need to proceed further
#end

<ul>
#foreach ( $child in $children )
  <li><b>$child.title</b><br>

  ## Get child content in JSON format
  #set ( $body = $ConfluenceManager.get("/wiki/api/v2/pages/${child.id}?body-format=atlas_doc_format").body.atlas_doc_format.value )
  #set ( $divider = "" ) ## skip first devider
  #foreach ( $item in $body.content )
    ## Triggers only on H1 headings
    #if ( $item.type == "heading" && $item.attrs.level == 1)
      #set ( $h1 = $item.content[0].text )
      $divider
      ## Render anchor link
      <ac:link ac:anchor="$h1">
        <ri:page ri:content-title="$child.title"/>
        <ac:plain-text-link-body><![CDATA[$h1]]></ac:plain-text-link-body>
      </ac:link>
      #set ( $divider = "|" )
    #end
  #end

  </li>

#end
</ul>

And here’s how it renders on the test page:

image-20250718-095851.png

Now that the macro is complete, we’ll publish it as Table of Contents for Children.

So, anyone can easily copy it from the Library tab:

copy-from-library-20250717-093004.gif

5 child pages, 50+ headings, all in a compact view — looks like it’s done!

The REAL problem has been solved — with User Macro for Confluence Cloud (wink)

Got Questions?

Fill out the form, and we'll guide you through every step of the way