Skip to content

Commit c4fba4f

Browse files
committed
Keep navigation menu in toctree order when mixing internal and external entries
A toctree that mixes internal pages and external links built the navigation menu (the document entry's menu entries) with all external entries grouped first, even though the on-page toctree kept the authored order. The sidebar/navbar therefore disagreed with the page; this was visible for nested toctrees. Internal and external menu entries are attached to the document entry by two separate transformers (InternalMenuEntryNodeTransformer and ExternalMenuEntryNodeTransformer), and the compiler runs one full tree traversal per transformer, so every external entry is appended in one pass and every internal entry in another. The result is grouped by type instead of following the toctree. ToctreeSortingTransformer now realigns the document entry's menu entries with the authored toctree order, emitting each toctree's entries as a contiguous block at the position of its first entry. Applied per toctree in document order, this also yields the correct order when a document has several mixed toctrees. It already ran at the right point to handle the reversed option; globbed toctrees are skipped, since their order comes from the glob expansion rather than an authored sequence. Reported downstream at TYPO3-Documentation/render-guides#1175
1 parent 33e63be commit c4fba4f

13 files changed

Lines changed: 437 additions & 7 deletions

File tree

packages/guides/src/Compiler/NodeTransformers/MenuNodeTransformers/ToctreeSortingTransformer.php

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@
1515

1616
use phpDocumentor\Guides\Compiler\CompilerContext;
1717
use phpDocumentor\Guides\Compiler\NodeTransformer;
18+
use phpDocumentor\Guides\Nodes\DocumentTree\DocumentEntryNode;
19+
use phpDocumentor\Guides\Nodes\DocumentTree\ExternalEntryNode;
20+
use phpDocumentor\Guides\Nodes\Menu\MenuEntryNode;
1821
use phpDocumentor\Guides\Nodes\Menu\TocNode;
1922
use phpDocumentor\Guides\Nodes\Node;
2023

24+
use function array_key_first;
2125
use function array_reverse;
26+
use function array_values;
27+
use function count;
2228
use function is_array;
29+
use function ksort;
2330

2431
/** @implements NodeTransformer<TocNode> */
2532
final class ToctreeSortingTransformer implements NodeTransformer
@@ -35,24 +42,110 @@ public function enterNode(Node $node, CompilerContext $compilerContext): Node
3542
return $node;
3643
}
3744

38-
if (!$node->isReversed()) {
45+
$entries = $node->getValue();
46+
if (!is_array($entries)) {
3947
return $node;
4048
}
4149

42-
$entries = $node->getValue();
4350
$documentEntry = $compilerContext->getDocumentNode()->getDocumentEntry();
44-
$documentMenuEntries = $documentEntry->getMenuEntries();
45-
if (is_array($entries)) {
51+
52+
if ($node->isReversed()) {
4653
$entries = array_reverse($entries);
47-
$documentMenuEntries = array_reverse($documentMenuEntries);
54+
$node->setValue($entries);
55+
$documentEntry->setMenuEntries(array_reverse($documentEntry->getMenuEntries()));
4856
}
4957

50-
$documentEntry->setMenuEntries($documentMenuEntries);
51-
$node->setValue($entries);
58+
// The document entry's menu entries (used to build the navigation menu)
59+
// are attached by separate transformers for internal and external menu
60+
// entries, each running in its own full tree traversal. As a result the
61+
// menu entries end up grouped by type instead of following the authored
62+
// toctree order, so the navigation menu disagrees with the order shown
63+
// on the page. Realign them with the toctree. Globbed toctrees are
64+
// skipped: their order is defined by the glob expansion, not by an
65+
// authored sequence.
66+
if (!$node->hasOption('glob')) {
67+
$documentEntry->setMenuEntries(
68+
$this->sortMenuEntriesByToctree($entries, $documentEntry->getMenuEntries()),
69+
);
70+
}
5271

5372
return $node;
5473
}
5574

75+
/**
76+
* Reorders the menu entries that belong to this toctree so they follow the
77+
* authored toctree order. The toctree's entries are emitted as one
78+
* contiguous block at the position of its first entry; entries that belong
79+
* to other toctrees of the same document keep their relative position.
80+
* Applied per toctree in document order, this yields the global authored
81+
* order even when a document has several toctrees.
82+
*
83+
* @param array<MenuEntryNode> $tocEntries
84+
* @param array<DocumentEntryNode|ExternalEntryNode> $menuEntries
85+
*
86+
* @return array<DocumentEntryNode|ExternalEntryNode>
87+
*/
88+
private function sortMenuEntriesByToctree(array $tocEntries, array $menuEntries): array
89+
{
90+
// Map each authored toctree entry to its position. The key match relies
91+
// on the entry urls having been resolved to the document file (internal)
92+
// or external url by the attach transformers (priority 4500), which run
93+
// before this pass.
94+
$order = [];
95+
$position = 0;
96+
foreach ($tocEntries as $tocEntry) {
97+
if (!($tocEntry instanceof MenuEntryNode)) {
98+
continue;
99+
}
100+
101+
$order[$tocEntry->getUrl()] = $position++;
102+
}
103+
104+
$ordered = [];
105+
$matchedIndexes = [];
106+
foreach ($menuEntries as $index => $menuEntry) {
107+
$key = self::menuEntryKey($menuEntry);
108+
if (!isset($order[$key])) {
109+
continue;
110+
}
111+
112+
$ordered[$order[$key]] = $menuEntry;
113+
$matchedIndexes[$index] = true;
114+
}
115+
116+
// Safety: bail out when entries do not map one-to-one (e.g. the rare
117+
// case of duplicate entries within a single toctree).
118+
if ($matchedIndexes === [] || count($ordered) !== count($matchedIndexes)) {
119+
return $menuEntries;
120+
}
121+
122+
ksort($ordered);
123+
$ordered = array_values($ordered);
124+
$firstIndex = array_key_first($matchedIndexes);
125+
126+
$result = [];
127+
foreach ($menuEntries as $index => $menuEntry) {
128+
if ($index === $firstIndex) {
129+
foreach ($ordered as $orderedEntry) {
130+
$result[] = $orderedEntry;
131+
}
132+
}
133+
134+
if (isset($matchedIndexes[$index])) {
135+
continue;
136+
}
137+
138+
$result[] = $menuEntry;
139+
}
140+
141+
return $result;
142+
}
143+
144+
private static function menuEntryKey(DocumentEntryNode|ExternalEntryNode $entry): string
145+
{
146+
return $entry instanceof DocumentEntryNode ? $entry->getFile() : $entry->getValue();
147+
}
148+
56149
public function leaveNode(Node $node, CompilerContext $compilerContext): Node|null
57150
{
58151
return $node;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<!DOCTYPE html>
2+
<html class="no-js" lang="en">
3+
<head>
4+
<title>Subpage Title</title>
5+
<!-- Required meta tags -->
6+
<meta charset="utf-8">
7+
<meta name="viewport" content="width=device-width, initial-scale=1">
8+
9+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
10+
</head>
11+
<body>
12+
<header class="">
13+
14+
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
15+
<div class="container">
16+
17+
<a class="navbar-brand" href="#">Navbar</a>
18+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
19+
<span class="navbar-toggler-icon"></span>
20+
</button>
21+
<div class="collapse navbar-collapse" id="navbarSupportedContent">
22+
23+
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
24+
<li class="nav-item">
25+
<a href="/subpage/index.html" class="nav-link current active" aria-current="page">
26+
Subpage Title
27+
</a>
28+
</li>
29+
<ul class="level-">
30+
<li class="nav-item">
31+
<a href="/subpage/index.html"
32+
class="nav-link current active" aria-current="page">Subpage Title</a>
33+
</li>
34+
</ul>
35+
</ul>
36+
37+
</div>
38+
</div>
39+
</nav>
40+
</header>
41+
<main id="main-content">
42+
<div class="container">
43+
<div class="container">
44+
<div class="row">
45+
<div class="col-lg-3">
46+
<nav class="nav flex-column">
47+
<ul class="menu-level-main">
48+
<li>
49+
<a href="/subpage/index.html"
50+
class="nav-link current active" aria-current="page">Subpage Title</a>
51+
<ul class="level-1">
52+
<li>
53+
<a href="/subpage/alpha.html"
54+
class="nav-link">Alpha</a>
55+
</li>
56+
<li>
57+
<a href="https://example.com/a"
58+
class="nav-link">External A</a>
59+
</li>
60+
<li>
61+
<a href="/subpage/beta.html"
62+
class="nav-link">Beta</a>
63+
</li>
64+
<li>
65+
<a href="https://example.org/b"
66+
class="nav-link">External B</a>
67+
</li>
68+
</ul>
69+
70+
</li>
71+
</ul>
72+
</nav>
73+
74+
</div>
75+
<div class="col-lg-9">
76+
77+
<nav aria-label="breadcrumb">
78+
<ol class="breadcrumb">
79+
<li class="breadcrumb-item"><a href="/index.html">Document Title</a></li>
80+
<li class="breadcrumb-item"><a href="/subpage/index.html">Subpage Title</a></li>
81+
</ol>
82+
</nav>
83+
<!-- content start -->
84+
<div class="section" id="subpage-title">
85+
<h1>Subpage Title</h1>
86+
<div class="toc">
87+
<p class="caption">First</p>
88+
<ul class="menu-level">
89+
<li class="toc-item">
90+
<a href="/subpage/alpha.html#alpha">Alpha</a>
91+
92+
93+
</li>
94+
<li class="toc-item">
95+
<a href="https://example.com/a">External A</a>
96+
97+
98+
</li>
99+
</ul>
100+
</div>
101+
<div class="toc">
102+
<p class="caption">Second</p>
103+
<ul class="menu-level">
104+
<li class="toc-item">
105+
<a href="/subpage/beta.html#beta">Beta</a>
106+
107+
108+
</li>
109+
<li class="toc-item">
110+
<a href="https://example.org/b">External B</a>
111+
112+
113+
</li>
114+
</ul>
115+
</div>
116+
</div>
117+
<!-- content end -->
118+
</div>
119+
</div>
120+
</div>
121+
</div>
122+
</main>
123+
124+
<!-- Optional JavaScript; choose one of the two! -->
125+
126+
<!-- Option 1: Bootstrap Bundle with Popper -->
127+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
128+
129+
<!-- Option 2: Separate Popper and Bootstrap JS -->
130+
<!--
131+
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
132+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
133+
-->
134+
</body>
135+
</html>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<guides xmlns="https://www.phpdoc.org/guides"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="https://www.phpdoc.org/guides packages/guides-cli/resources/schema/guides.xsd"
5+
theme="bootstrap"
6+
>
7+
<extension class="phpDocumentor\Guides\Bootstrap"/>
8+
</guides>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Document Title
2+
==============
3+
4+
.. toctree::
5+
6+
subpage/index
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
=====
2+
Alpha
3+
=====
4+
5+
Lorem Ipsum Dolor.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
====
2+
Beta
3+
====
4+
5+
Lorem Ipsum Dolor.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Subpage Title
2+
=============
3+
4+
.. toctree::
5+
:caption: First
6+
7+
alpha
8+
External A <https://example.com/a>
9+
10+
.. toctree::
11+
:caption: Second
12+
13+
beta
14+
External B <https://example.org/b>

0 commit comments

Comments
 (0)