Skip to content

Commit 3f5f798

Browse files
committed
feat: Add full support for nested navigation in MkDocs configuration
This commit implements complete support for nested navigation structures in the MkDocs 'nav' section, allowing for arbitrary nesting depth when publishing to Confluence. Key Changes: - Modified traverse() in context.js to track parent-child relationships - Added parentPath property to LocalPage to store section hierarchy - Rewrote syncPages() to process pages level-by-level (parents before children) - Updated README to highlight nesting support and remove limitation notice - Added comprehensive test fixtures for nested structures Benefits: - Pages now maintain their hierarchical structure in Confluence - First page in each section becomes the parent for that section - Supports arbitrary nesting depth (e.g., Section > Subsection > Page) - Fully backward compatible with existing flat structures Example: nav: - Home: index.md # Root level - Guides: # Section - Getting Started: guides/start.md # Child of Guides - Advanced: # Nested section - Config: guides/adv/config.md # Child of Advanced All tests pass (148/148) with 99.63% code coverage maintained.
1 parent 0122594 commit 3f5f798

16 files changed

Lines changed: 141 additions & 52 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ Atlassian Confluence Cloud wiki.
1616
* *PlantUML* see [`plantuml_renderer`](#plantuml_renderer) for available options
1717
* Add a common [prefix](#confluence_title_prefix) to all page titles
1818
* Restricts page update to [confluence_user](#confluence_user)
19+
* Supports nested navigation in the `nav` section of the [MkDocs Configuration](#mkdocs-configuration)
1920

2021
## Limitations
2122

22-
* Does not fully support nesting in the `nav` section of the [MkDocs Configuration](#mkdocs-configuration),
23-
flattens all pages to one level.
2423
* Does not publish pages not described in the `nav` section.
2524

2625
## Requirements

dist/index.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/confluence-syncer.js

Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,73 @@ async function findParentPage() {
112112
* @param {AssetRenderer} renderer - `AssetRenderer` instance
113113
*/
114114
async function syncPages(home, localPages, renderer) {
115-
// compute the union of local/remote pages that need to be synced
116-
const pages = await union(home, localPages);
117-
for (let page of pages) {
118-
await page.sync(renderer, confluence);
115+
// Build a map to track synced pages and their Confluence IDs
116+
const syncedPages = new Map();
117+
syncedPages.set(null, home); // root level pages use home as parent
118+
119+
// Group pages by their parent
120+
const pagesByParent = new Map();
121+
const sectionPages = new Map(); // Map section titles to their first page
122+
123+
for (const page of localPages) {
124+
const parentKey = page.parentPath || null;
125+
if (!pagesByParent.has(parentKey)) {
126+
pagesByParent.set(parentKey, []);
127+
}
128+
pagesByParent.get(parentKey).push(page);
129+
130+
// Track section pages (first page in a section becomes the section parent)
131+
if (page.parentPath && !sectionPages.has(page.parentPath)) {
132+
sectionPages.set(page.parentPath, page);
133+
}
119134
}
135+
136+
// Sync pages level by level to ensure parents exist before children
137+
const processLevel = async (parentKey) => {
138+
const pages = pagesByParent.get(parentKey) || [];
139+
const parentPageId = syncedPages.get(parentKey);
140+
141+
// Get remote pages for this level
142+
const remotePages = await confluence.getChildPages(parentPageId);
143+
const union = [];
144+
145+
for (let localPage of pages) {
146+
localPage.parentPageId = parentPageId;
147+
const remotePage = remotePages.get(localPage.meta.path);
148+
if (!remotePage) {
149+
union.push(localPage);
150+
continue;
151+
}
152+
remotePages.delete(localPage.meta.path);
153+
remotePage.localPage = localPage;
154+
union.push(remotePage);
155+
}
156+
157+
// Any remaining remote page not matching a local page should be deleted
158+
for (let remotePage of remotePages.values()) {
159+
union.push(remotePage);
160+
}
161+
162+
// Sync all pages at this level
163+
for (let page of union) {
164+
const syncedPage = await page.sync(renderer, confluence);
165+
// Track the synced page for its children to reference
166+
if (page.title && syncedPage && syncedPage.id) {
167+
syncedPages.set(page.title, syncedPage.id);
168+
}
169+
}
170+
171+
// Now process children of pages at this level
172+
for (let page of pages) {
173+
if (sectionPages.get(page.title)) {
174+
// This page has children (it's a section), process them
175+
await processLevel(page.title);
176+
}
177+
}
178+
};
179+
180+
// Start processing from root level
181+
await processLevel(null);
120182
}
121183

122184
/**
@@ -131,34 +193,6 @@ async function unpublish(remotePages) {
131193
}
132194
}
133195

134-
/**
135-
* Creates a union of remote and local pages that need to be synced with Confluence
136-
*
137-
* @param {number} parentPageId - The parent page to all pages
138-
* @param {Array<LocalPage>} localPages
139-
* @returns {Array<LocalPage|RemotePage>} An `array` of pages to be synced
140-
*/
141-
async function union(parentPageId, localPages) {
142-
const remotePages = await confluence.getChildPages(parentPageId);
143-
const union = [];
144-
for (let localPage of localPages) {
145-
localPage.parentPageId = parentPageId;
146-
const remotePage = remotePages.get(localPage.meta.path);
147-
if (!remotePage) {
148-
union.push(localPage);
149-
continue;
150-
}
151-
remotePages.delete(localPage.meta.path);
152-
remotePage.localPage = localPage;
153-
union.push(remotePage);
154-
}
155-
// Any remaining remote page not matching a local page should be deleted
156-
for (let remotePage of remotePages.values()) {
157-
union.push(remotePage);
158-
}
159-
return union;
160-
}
161-
162196
/**
163197
* Cleanup all pages from confluence
164198
*

lib/context.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,23 @@ function loadConfig(basePath) {
3939
* @param {*} nav - nav object from 'mkdocs.yml'
4040
* @param {*} basePath - the basepath to resolve files
4141
* @param {Array<LocalPage>} pages
42+
* @param {string|null} parentPath - the path of the parent page (null for root level)
4243
* @returns {Array<LocalPage>} The array with all pages from `nav`
4344
*/
44-
function traverse(repo_url, nav, basePath, pages = []) {
45+
function traverse(repo_url, nav, basePath, pages = [], parentPath = null) {
4546
nav.forEach((item) => {
4647
if (typeof item === 'string') {
4748
throw new Error(`No title for ${item}`);
4849
}
4950
const pageTitle = Object.keys(item)[0];
5051
const pagePath = Object.values(item)[0];
5152
if (Array.isArray(pagePath)) {
52-
traverse(repo_url, pagePath, basePath, pages);
53+
// This is a section with nested pages, traverse recursively with the section title as parent
54+
traverse(repo_url, pagePath, basePath, pages, pageTitle);
5355
} else {
5456
const page = getPage(repo_url, pageTitle, path.resolve(basePath, 'docs', pagePath));
5557
if (page) {
58+
page.parentPath = parentPath;
5659
pages.push(page);
5760
}
5861
}

lib/models/local-page.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import Page from './page.js';
1111
*
1212
*/
1313
class LocalPage extends Page {
14+
/**
15+
* Constructor
16+
*
17+
* @param {string} title - Page title
18+
* @param {Meta} meta - Page metadata
19+
*/
20+
constructor(title, meta) {
21+
super(title, meta);
22+
this.parentPath = null;
23+
}
24+
1425
/**
1526
* Html markup
1627
*

test/fixtures/context/simple.json

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"git_ref": "branch-name",
1212
"git_sha": "git-hub-sha",
1313
"publisher_version": "1.0.0"
14-
}
14+
},
15+
"parentPath": null
1516
},
1617
{
1718
"title": "Architecture",
@@ -22,7 +23,8 @@
2223
"git_ref": "branch-name",
2324
"git_sha": "git-hub-sha",
2425
"publisher_version": "1.0.0"
25-
}
26+
},
27+
"parentPath": null
2628
},
2729
{
2830
"title": "Database",
@@ -33,7 +35,8 @@
3335
"git_ref": "branch-name",
3436
"git_sha": "git-hub-sha",
3537
"publisher_version": "1.0.0"
36-
}
38+
},
39+
"parentPath": "References"
3740
},
3841
{
3942
"title": "API",
@@ -44,7 +47,8 @@
4447
"git_ref": "branch-name",
4548
"git_sha": "git-hub-sha",
4649
"publisher_version": "1.0.0"
47-
}
50+
},
51+
"parentPath": "References"
4852
},
4953
{
5054
"title": "How-To",
@@ -55,7 +59,8 @@
5559
"git_ref": "branch-name",
5660
"git_sha": "git-hub-sha",
5761
"publisher_version": "1.0.0"
58-
}
62+
},
63+
"parentPath": null
5964
}
6065
],
6166
"pageRefs": {
@@ -75,6 +80,7 @@
7580
"git_ref": "branch-name",
7681
"git_sha": "git-hub-sha",
7782
"publisher_version": "1.0.0"
78-
}
83+
},
84+
"parentPath": null
7985
}
8086
}

test/fixtures/context/with-prefix.json

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"git_sha": "git-hub-sha",
2020
"publisher_version": "1.0.0",
2121
"repo": "https://github.com/fixture-account/fixture-repo"
22-
}
22+
},
23+
"parentPath": null
2324
},
2425
{
2526
"title": "PFX: Architecture",
@@ -30,7 +31,8 @@
3031
"git_sha": "git-hub-sha",
3132
"publisher_version": "1.0.0",
3233
"repo": "https://github.com/fixture-account/fixture-repo"
33-
}
34+
},
35+
"parentPath": null
3436
},
3537
{
3638
"title": "PFX: Database",
@@ -41,7 +43,8 @@
4143
"git_sha": "git-hub-sha",
4244
"publisher_version": "1.0.0",
4345
"repo": "https://github.com/fixture-account/fixture-repo"
44-
}
46+
},
47+
"parentPath": "References"
4548
},
4649
{
4750
"title": "PFX: API",
@@ -52,7 +55,8 @@
5255
"git_sha": "git-hub-sha",
5356
"publisher_version": "1.0.0",
5457
"repo": "https://github.com/fixture-account/fixture-repo"
55-
}
58+
},
59+
"parentPath": "References"
5660
},
5761
{
5862
"title": "PFX: How-To",
@@ -63,7 +67,8 @@
6367
"git_sha": "git-hub-sha",
6468
"publisher_version": "1.0.0",
6569
"repo": "https://github.com/fixture-account/fixture-repo"
66-
}
70+
},
71+
"parentPath": null
6772
}
6873
],
6974
"readMe": {
@@ -75,6 +80,7 @@
7580
"git_sha": "git-hub-sha",
7681
"publisher_version": "1.0.0",
7782
"repo": "https://github.com/fixture-account/fixture-repo"
78-
}
83+
},
84+
"parentPath": null
7985
}
8086
}

test/fixtures/context/without-readme.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"publisher_version": "1.0.0",
1515
"git_ref": "branch-name",
1616
"git_sha": "git-hub-sha"
17-
}
17+
},
18+
"parentPath": null
1819
}
1920
]
2021
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Nested Site
2+
3+
This is a test for nested navigation.

0 commit comments

Comments
 (0)