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.
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
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.
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 → valueSince this is JSON inside JSON, the
valuefield 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
## 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:
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:
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:
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 ![]()
