astro-better-docs-sidebar 0.2.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +7 -0
- package/README.md +32 -0
- package/Sidebar.astro +48 -161
- package/SidebarItem.astro +5 -8
- package/package.json +27 -6
- package/tree.js +186 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Better Static Sites
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -122,6 +122,38 @@ hidden: true
|
|
|
122
122
|
---
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
## Non-routable folder index pages
|
|
126
|
+
|
|
127
|
+
Set `route: false` in a folder's `index.mdx` to prevent the sidebar from linking to it while still using it to set the folder's title and `order`. The folder heading appears as a plain label with no link.
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
# docs/core-concepts/index.mdx
|
|
131
|
+
---
|
|
132
|
+
title: Core Concepts
|
|
133
|
+
order: 2
|
|
134
|
+
route: false
|
|
135
|
+
---
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
This is equivalent to `sidebar.folderBehavior: unclickable` but scoped to the index page itself rather than the `folderBehavior` prop. Use it when a folder should never have a clickable header, regardless of the global `folderBehavior` setting.
|
|
139
|
+
|
|
140
|
+
Make sure your content collection schema includes `route`:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const docs = defineCollection({
|
|
144
|
+
schema: z.object({
|
|
145
|
+
title: z.string(),
|
|
146
|
+
order: z.number().optional(),
|
|
147
|
+
hidden: z.boolean().optional(),
|
|
148
|
+
route: z.boolean().optional(),
|
|
149
|
+
sidebar: z.object({
|
|
150
|
+
label: z.string().optional(),
|
|
151
|
+
folderBehavior: z.enum(['page', 'unclickable', 'overview']).optional(),
|
|
152
|
+
}).optional(),
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
125
157
|
## Controlling sort order
|
|
126
158
|
|
|
127
159
|
Set `order` in front matter to control sort position within a folder. Lower numbers sort first. Pages without `order` sort last, then alphabetically by title.
|
package/Sidebar.astro
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import SidebarItem from './SidebarItem.astro';
|
|
3
|
+
import { sidebarFor } from './tree.js';
|
|
3
4
|
|
|
4
5
|
type FolderBehavior = 'page' | 'unclickable' | 'overview';
|
|
5
6
|
|
|
@@ -19,122 +20,11 @@ const {
|
|
|
19
20
|
overviewLabel = 'Overview',
|
|
20
21
|
} = Astro.props;
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
if (!p) return "";
|
|
24
|
-
let clean = decodeURI(p);
|
|
25
|
-
if (clean.startsWith(basePath)) clean = clean.substring(basePath.length);
|
|
26
|
-
return clean.replace(/\.(md|mdx|html)$/i, '').replace(/\/index$/i, '').replace(/^\/+|\/+$/g, '');
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const safeCurrentPath = normalizeForMatch(Astro.url.pathname);
|
|
30
|
-
const formatTitle = (str: string) => str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
31
|
-
|
|
32
|
-
function buildTree(pages: any[]) {
|
|
33
|
-
const root: any = { children: {}, title: 'Documentation', href: basePath, order: -1 };
|
|
34
|
-
for (const page of pages) {
|
|
35
|
-
const { order, hidden, title: dataTitle } = page.data;
|
|
36
|
-
const label = page.data?.sidebar?.label;
|
|
37
|
-
const pageFolderBehavior: FolderBehavior | undefined = page.data?.sidebar?.folderBehavior;
|
|
38
|
-
if (hidden === true) continue;
|
|
39
|
-
|
|
40
|
-
const slug = page.slug || page.id || "";
|
|
41
|
-
const parts = slug.split('/');
|
|
42
|
-
let current = root;
|
|
43
|
-
|
|
44
|
-
if (slug === "index" || slug === "") {
|
|
45
|
-
root.title = label || dataTitle || root.title;
|
|
46
|
-
if (order !== undefined) root.order = order;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
for (let i = 0; i < parts.length; i++) {
|
|
51
|
-
const part = parts[i];
|
|
52
|
-
|
|
53
|
-
if (part === 'index') {
|
|
54
|
-
const effective: FolderBehavior = pageFolderBehavior || folderBehavior;
|
|
55
|
-
|
|
56
|
-
current.title = label || dataTitle || current.title;
|
|
57
|
-
if (order !== undefined) current.order = order;
|
|
58
|
-
|
|
59
|
-
// route: false pages still contribute title/order but produce no link
|
|
60
|
-
if (page.data?.route !== false) {
|
|
61
|
-
const href = `${basePath}/${slug.replace(/\/index$/, "")}`.replace(/\/+/g, '/').replace(/\/$/, "");
|
|
62
|
-
if (effective === 'page') {
|
|
63
|
-
current.href = href;
|
|
64
|
-
} else if (effective === 'overview') {
|
|
65
|
-
// inject a synthetic first child that links to the index page
|
|
66
|
-
current.children['__sidebar_overview__'] = {
|
|
67
|
-
name: '__sidebar_overview__',
|
|
68
|
-
title: overviewLabel,
|
|
69
|
-
href,
|
|
70
|
-
children: {},
|
|
71
|
-
sortedChildren: [],
|
|
72
|
-
isOpen: false,
|
|
73
|
-
isActive: false,
|
|
74
|
-
order: -Infinity,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
// 'unclickable': no href set, no overview child
|
|
78
|
-
}
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!current.children[part]) {
|
|
83
|
-
current.children[part] = {
|
|
84
|
-
name: part,
|
|
85
|
-
title: formatTitle(part),
|
|
86
|
-
href: null,
|
|
87
|
-
children: {},
|
|
88
|
-
isOpen: false,
|
|
89
|
-
isActive: false,
|
|
90
|
-
order: Infinity,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (i === parts.length - 1) {
|
|
95
|
-
current.children[part].title = label || dataTitle || formatTitle(part);
|
|
96
|
-
current.children[part].href = `${basePath}/${slug.replace(/\.(md|mdx)$/i, '')}`.replace(/\/+/g, '/').replace(/\/$/, "");
|
|
97
|
-
if (order !== undefined) current.children[part].order = order;
|
|
98
|
-
}
|
|
99
|
-
current = current.children[part];
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return root;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const treeRoot = buildTree(collection);
|
|
106
|
-
|
|
107
|
-
function markActiveAndOpen(node: any): boolean {
|
|
108
|
-
node.isCurrentPage = node.href != null && normalizeForMatch(node.href) === safeCurrentPath;
|
|
109
|
-
let hasActiveChild = false;
|
|
110
|
-
for (const key in node.children) {
|
|
111
|
-
if (markActiveAndOpen(node.children[key])) hasActiveChild = true;
|
|
112
|
-
}
|
|
113
|
-
node.isActive = node.isCurrentPage || hasActiveChild;
|
|
114
|
-
node.isOpen = node.isActive;
|
|
115
|
-
return node.isActive;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
markActiveAndOpen(treeRoot);
|
|
119
|
-
|
|
120
|
-
function getSortedArray(nodeChildren: Record<string, any>) {
|
|
121
|
-
const arr = Object.values(nodeChildren);
|
|
122
|
-
arr.sort((a, b) => {
|
|
123
|
-
const vA = a.order === undefined ? Infinity : a.order;
|
|
124
|
-
const vB = b.order === undefined ? Infinity : b.order;
|
|
125
|
-
return vA !== vB ? vA - vB : a.title.localeCompare(b.title);
|
|
126
|
-
});
|
|
127
|
-
arr.forEach(child => {
|
|
128
|
-
child.sortedChildren = child.children && Object.keys(child.children).length > 0 ? getSortedArray(child.children) : [];
|
|
129
|
-
});
|
|
130
|
-
return arr;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const treeArray = getSortedArray(treeRoot.children);
|
|
23
|
+
const treeArray = sidebarFor(collection, { basePath, folderBehavior, overviewLabel }, Astro.url.pathname);
|
|
134
24
|
---
|
|
135
25
|
|
|
136
26
|
|
|
137
|
-
<nav
|
|
27
|
+
<nav>
|
|
138
28
|
{treeArray.map(node => <SidebarItem node={node} />)}
|
|
139
29
|
</nav>
|
|
140
30
|
|
|
@@ -142,61 +32,58 @@ const treeArray = getSortedArray(treeRoot.children);
|
|
|
142
32
|
@reference "tailwindcss";
|
|
143
33
|
|
|
144
34
|
aside.aside { @apply w-64 shrink-0 border-r border-gray-200 bg-white pt-2 pr-2; }
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
/* DEPTH LINE */
|
|
148
|
-
.nav details > div { @apply flex flex-col gap-0.5 pl-3 ml-3 border-l border-slate-200; }
|
|
35
|
+
#side-nav nav { @apply flex flex-col gap-1; }
|
|
149
36
|
|
|
150
|
-
/*
|
|
151
|
-
|
|
152
|
-
.s::-webkit-details-marker { display: none; }
|
|
37
|
+
/* depth line */
|
|
38
|
+
#side-nav nav details > div { @apply flex flex-col gap-0.5 pl-3 ml-3 border-l border-slate-200; }
|
|
153
39
|
|
|
154
|
-
/*
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
span.l { @apply cursor-default; }
|
|
40
|
+
/* folder header row */
|
|
41
|
+
#side-nav nav summary { @apply flex items-center justify-between cursor-pointer select-none rounded gap-1 transition-colors text-slate-700; list-style: none; }
|
|
42
|
+
#side-nav nav summary::-webkit-details-marker { display: none; }
|
|
158
43
|
|
|
159
|
-
/*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
44
|
+
/* leaf links and folder labels */
|
|
45
|
+
#side-nav nav :is(a, span) { @apply flex-1 text-sm py-1 px-2 rounded transition-colors no-underline block text-slate-700; }
|
|
46
|
+
#side-nav nav a { @apply cursor-pointer; }
|
|
47
|
+
#side-nav nav span { @apply cursor-default; }
|
|
163
48
|
|
|
164
|
-
/*
|
|
165
|
-
|
|
49
|
+
/* chevron */
|
|
50
|
+
#side-nav nav i { @apply flex items-center justify-center w-7 h-7 rounded text-gray-400 shrink-0 transition-transform; }
|
|
51
|
+
#side-nav nav i::after { content: ''; @apply w-[0.4rem] h-[0.4rem] border-r-2 border-b-2 border-current block -rotate-45 transition-transform box-border; }
|
|
52
|
+
#side-nav nav details[open] > summary i::after { @apply rotate-45; }
|
|
166
53
|
|
|
167
|
-
/*
|
|
168
|
-
|
|
169
|
-
a.l:hover { @apply text-indigo-600; }
|
|
170
|
-
:is(details:not([open]) > .s, details:not(:has(a.l)) > .s):hover .l { @apply text-indigo-600; }
|
|
54
|
+
/* clicking a closed folder opens it; disables the link's own navigation */
|
|
55
|
+
#side-nav nav details:not([open]) > summary a { pointer-events: none; }
|
|
171
56
|
|
|
172
|
-
/* HOVERS
|
|
173
|
-
|
|
174
|
-
details[open] >
|
|
57
|
+
/* HOVERS */
|
|
58
|
+
#side-nav nav a:not(summary a):hover { @apply bg-slate-100 text-indigo-600; }
|
|
59
|
+
#side-nav nav details:not([open]) > summary:hover,
|
|
60
|
+
#side-nav nav summary:not(:has(a)):hover { @apply bg-slate-100; }
|
|
61
|
+
#side-nav nav details:not([open]) > summary:hover :is(a, span),
|
|
62
|
+
#side-nav nav summary:not(:has(a)):hover span { @apply text-indigo-600; }
|
|
63
|
+
#side-nav nav details[open] > summary:has(a) a:hover { @apply bg-slate-100 text-indigo-600; }
|
|
64
|
+
#side-nav nav details[open] > summary:has(a) i:hover { @apply bg-slate-100 text-slate-700; }
|
|
175
65
|
|
|
176
66
|
/* ACTIVE & ANCESTOR STATES */
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
67
|
+
#side-nav nav a[aria-current="page"],
|
|
68
|
+
#side-nav nav summary:has(> a[aria-current="page"]) :is(a, span) { @apply font-semibold text-indigo-600 bg-indigo-50; }
|
|
69
|
+
#side-nav nav a[data-a],
|
|
70
|
+
#side-nav nav summary[data-a] :is(a, span) { @apply font-semibold text-slate-900; }
|
|
71
|
+
#side-nav nav summary:has(> [aria-current="page"]) i,
|
|
72
|
+
#side-nav nav details:has([aria-current="page"]) > summary i { @apply text-indigo-500; }
|
|
73
|
+
|
|
74
|
+
/* DARK MODE */
|
|
182
75
|
.dark aside.aside { @apply border-gray-800 bg-gray-900; }
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
.dark :
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
.dark
|
|
189
|
-
|
|
190
|
-
.dark
|
|
191
|
-
.dark a
|
|
192
|
-
.dark
|
|
193
|
-
|
|
194
|
-
.dark
|
|
195
|
-
.dark
|
|
196
|
-
|
|
197
|
-
.dark .l[aria-current="page"],
|
|
198
|
-
.dark .s:has(> .l[aria-current="page"]) .l { @apply text-indigo-400 bg-indigo-950/40; }
|
|
199
|
-
|
|
200
|
-
.dark .l[data-a],
|
|
201
|
-
.dark .s[data-a] .l { @apply text-white; }
|
|
76
|
+
.dark #side-nav nav :is(a, span) { @apply text-slate-400; }
|
|
77
|
+
.dark #side-nav nav details > div { @apply border-slate-700; }
|
|
78
|
+
.dark #side-nav nav a:not(summary a):hover { @apply bg-slate-800 text-indigo-400; }
|
|
79
|
+
.dark #side-nav nav details:not([open]) > summary:hover,
|
|
80
|
+
.dark #side-nav nav summary:not(:has(a)):hover { @apply bg-slate-800; }
|
|
81
|
+
.dark #side-nav nav details:not([open]) > summary:hover :is(a, span),
|
|
82
|
+
.dark #side-nav nav summary:not(:has(a)):hover span { @apply text-indigo-400; }
|
|
83
|
+
.dark #side-nav nav details[open] > summary:has(a) a:hover { @apply bg-slate-800 text-indigo-400; }
|
|
84
|
+
.dark #side-nav nav details[open] > summary:has(a) i:hover { @apply bg-slate-800 text-slate-200; }
|
|
85
|
+
.dark #side-nav nav a[aria-current="page"],
|
|
86
|
+
.dark #side-nav nav summary:has(> a[aria-current="page"]) :is(a, span) { @apply text-indigo-400 bg-indigo-950/40; }
|
|
87
|
+
.dark #side-nav nav a[data-a],
|
|
88
|
+
.dark #side-nav nav summary[data-a] :is(a, span) { @apply text-white; }
|
|
202
89
|
</style>
|
package/SidebarItem.astro
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
2
|
+
import { titleHtml } from "./tree.js";
|
|
3
3
|
|
|
4
4
|
const { node } = Astro.props;
|
|
5
5
|
const childNodes = node.sortedChildren || [];
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
const htmlTitle = remark.parseInline(node.title);
|
|
7
|
+
const htmlTitle = node.htmlTitle ?? titleHtml(node.title);
|
|
9
8
|
const isAnc = node.isActive && !node.isCurrentPage;
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
{childNodes.length > 0 ? (
|
|
13
12
|
<details open={node.isOpen || undefined}>
|
|
14
|
-
<summary
|
|
13
|
+
<summary data-a={isAnc || undefined}>
|
|
15
14
|
{node.href ? (
|
|
16
15
|
<a
|
|
17
16
|
href={`${node.href}/`}
|
|
18
|
-
class="l"
|
|
19
17
|
aria-current={node.isCurrentPage ? "page" : undefined}
|
|
20
18
|
set:html={htmlTitle}
|
|
21
19
|
></a>
|
|
22
20
|
) : (
|
|
23
|
-
<span
|
|
21
|
+
<span set:html={htmlTitle}></span>
|
|
24
22
|
)}
|
|
25
|
-
<i
|
|
23
|
+
<i></i>
|
|
26
24
|
</summary>
|
|
27
25
|
<div>
|
|
28
26
|
{childNodes.map((child: any) => <Astro.self node={child} />)}
|
|
@@ -31,7 +29,6 @@ const isAnc = node.isActive && !node.isCurrentPage;
|
|
|
31
29
|
) : (
|
|
32
30
|
<a
|
|
33
31
|
href={node.href}
|
|
34
|
-
class="l"
|
|
35
32
|
aria-current={node.isCurrentPage ? "page" : undefined}
|
|
36
33
|
data-a={isAnc || undefined}
|
|
37
34
|
set:html={htmlTitle}
|
package/package.json
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-better-docs-sidebar",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"astro",
|
|
6
|
+
"sidebar",
|
|
7
|
+
"docs",
|
|
8
|
+
"navigation"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/better-static-sites/better-static-sites.github.io.git",
|
|
14
|
+
"directory": "astro-better-docs-sidebar"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://better-static-sites.github.io",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/better-static-sites/better-static-sites.github.io/issues"
|
|
19
|
+
},
|
|
4
20
|
"type": "module",
|
|
5
21
|
"exports": {
|
|
6
22
|
"./Sidebar.astro": "./Sidebar.astro",
|
|
7
23
|
"./SidebarItem.astro": "./SidebarItem.astro"
|
|
8
24
|
},
|
|
9
|
-
"
|
|
10
|
-
"astro"
|
|
11
|
-
|
|
25
|
+
"files": [
|
|
26
|
+
"*.astro",
|
|
27
|
+
"tree.js"
|
|
28
|
+
],
|
|
12
29
|
"dependencies": {
|
|
13
30
|
"marked": "^17.0.0"
|
|
14
31
|
},
|
|
15
|
-
"
|
|
16
|
-
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"astro": ">=4.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test test/"
|
|
37
|
+
}
|
|
17
38
|
}
|
package/tree.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar tree building, shared across every page of a build.
|
|
3
|
+
*
|
|
4
|
+
* The tree only depends on the collection and the sidebar options, so it is
|
|
5
|
+
* built and sorted once per distinct input. Each page then gets a view that
|
|
6
|
+
* copies just the nodes on its active path, with the open/active flags set.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Marked } from 'marked';
|
|
10
|
+
|
|
11
|
+
const marked = new Marked();
|
|
12
|
+
const titleHtmlCache = new Map();
|
|
13
|
+
|
|
14
|
+
/** Inline-markdown title HTML, parsed once per distinct title. */
|
|
15
|
+
export function titleHtml(title) {
|
|
16
|
+
let html = titleHtmlCache.get(title);
|
|
17
|
+
if (html === undefined) {
|
|
18
|
+
html = marked.parseInline(title);
|
|
19
|
+
titleHtmlCache.set(title, html);
|
|
20
|
+
}
|
|
21
|
+
return html;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeForMatch(p, basePath) {
|
|
25
|
+
if (!p) return "";
|
|
26
|
+
let clean = decodeURI(p);
|
|
27
|
+
if (clean.startsWith(basePath)) clean = clean.substring(basePath.length);
|
|
28
|
+
return clean.replace(/\.(md|mdx|html)$/i, '').replace(/\/index$/i, '').replace(/^\/+|\/+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const formatTitle = (str) => str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
32
|
+
|
|
33
|
+
function buildTree(pages, { basePath, folderBehavior, overviewLabel }) {
|
|
34
|
+
const root = { children: {}, title: 'Documentation', href: basePath, order: -1 };
|
|
35
|
+
for (const page of pages) {
|
|
36
|
+
const { order, hidden, title: dataTitle } = page.data;
|
|
37
|
+
const label = page.data?.sidebar?.label;
|
|
38
|
+
const pageFolderBehavior = page.data?.sidebar?.folderBehavior;
|
|
39
|
+
if (hidden === true) continue;
|
|
40
|
+
|
|
41
|
+
const slug = page.slug || page.id || "";
|
|
42
|
+
const parts = slug.split('/');
|
|
43
|
+
let current = root;
|
|
44
|
+
|
|
45
|
+
if (slug === "index" || slug === "") {
|
|
46
|
+
root.title = label || dataTitle || root.title;
|
|
47
|
+
if (order !== undefined) root.order = order;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < parts.length; i++) {
|
|
52
|
+
const part = parts[i];
|
|
53
|
+
|
|
54
|
+
if (part === 'index') {
|
|
55
|
+
const effective = pageFolderBehavior || folderBehavior;
|
|
56
|
+
|
|
57
|
+
current.title = label || dataTitle || current.title;
|
|
58
|
+
if (order !== undefined) current.order = order;
|
|
59
|
+
|
|
60
|
+
// route: false pages still contribute title/order but produce no link
|
|
61
|
+
if (page.data?.route !== false) {
|
|
62
|
+
const href = `${basePath}/${slug.replace(/\/index$/, "")}`.replace(/\/+/g, '/').replace(/\/$/, "");
|
|
63
|
+
if (effective === 'page') {
|
|
64
|
+
current.href = href;
|
|
65
|
+
} else if (effective === 'overview') {
|
|
66
|
+
// inject a synthetic first child that links to the index page
|
|
67
|
+
current.children['__sidebar_overview__'] = {
|
|
68
|
+
name: '__sidebar_overview__',
|
|
69
|
+
title: overviewLabel,
|
|
70
|
+
href,
|
|
71
|
+
children: {},
|
|
72
|
+
sortedChildren: [],
|
|
73
|
+
isOpen: false,
|
|
74
|
+
isActive: false,
|
|
75
|
+
order: -Infinity,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// 'unclickable': no href set, no overview child
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!current.children[part]) {
|
|
84
|
+
current.children[part] = {
|
|
85
|
+
name: part,
|
|
86
|
+
title: formatTitle(part),
|
|
87
|
+
href: null,
|
|
88
|
+
children: {},
|
|
89
|
+
isOpen: false,
|
|
90
|
+
isActive: false,
|
|
91
|
+
order: Infinity,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (i === parts.length - 1) {
|
|
96
|
+
current.children[part].title = label || dataTitle || formatTitle(part);
|
|
97
|
+
current.children[part].href = `${basePath}/${slug.replace(/\.(md|mdx)$/i, '')}`.replace(/\/+/g, '/').replace(/\/$/, "");
|
|
98
|
+
if (order !== undefined) current.children[part].order = order;
|
|
99
|
+
}
|
|
100
|
+
current = current.children[part];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getSortedArray(nodeChildren) {
|
|
107
|
+
const arr = Object.values(nodeChildren);
|
|
108
|
+
arr.sort((a, b) => {
|
|
109
|
+
const vA = a.order === undefined ? Infinity : a.order;
|
|
110
|
+
const vB = b.order === undefined ? Infinity : b.order;
|
|
111
|
+
return vA !== vB ? vA - vB : a.title.localeCompare(b.title);
|
|
112
|
+
});
|
|
113
|
+
arr.forEach(child => {
|
|
114
|
+
child.sortedChildren = child.children && Object.keys(child.children).length > 0 ? getSortedArray(child.children) : [];
|
|
115
|
+
});
|
|
116
|
+
return arr;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Everything buildTree reads, so equal keys always build equal trees.
|
|
120
|
+
function cacheKey(pages, opts) {
|
|
121
|
+
const parts = [JSON.stringify([opts.basePath, opts.folderBehavior, opts.overviewLabel])];
|
|
122
|
+
for (const page of pages) {
|
|
123
|
+
const d = page.data ?? {};
|
|
124
|
+
parts.push(JSON.stringify([page.slug, page.id, d.order, d.hidden, d.title, d.sidebar?.label, d.sidebar?.folderBehavior, d.route]));
|
|
125
|
+
}
|
|
126
|
+
return parts.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// dev servers rebuild on every content change; keep only recent trees
|
|
130
|
+
const MAX_CACHED_TREES = 8;
|
|
131
|
+
const cache = new Map();
|
|
132
|
+
|
|
133
|
+
function cachedTree(pages, opts) {
|
|
134
|
+
const key = cacheKey(pages, opts);
|
|
135
|
+
let entry = cache.get(key);
|
|
136
|
+
if (entry) return entry;
|
|
137
|
+
|
|
138
|
+
const root = buildTree(pages, opts);
|
|
139
|
+
const nodes = getSortedArray(root.children);
|
|
140
|
+
|
|
141
|
+
// flags start false; each page's view flips them along its active path
|
|
142
|
+
const parents = new Map();
|
|
143
|
+
const byHref = new Map();
|
|
144
|
+
const index = (list, parent) => {
|
|
145
|
+
for (const node of list) {
|
|
146
|
+
node.isCurrentPage = false;
|
|
147
|
+
node.isActive = false;
|
|
148
|
+
node.isOpen = false;
|
|
149
|
+
node.htmlTitle = titleHtml(node.title);
|
|
150
|
+
if (parent) parents.set(node, parent);
|
|
151
|
+
if (node.href != null) {
|
|
152
|
+
const match = normalizeForMatch(node.href, opts.basePath);
|
|
153
|
+
if (!byHref.has(match)) byHref.set(match, []);
|
|
154
|
+
byHref.get(match).push(node);
|
|
155
|
+
}
|
|
156
|
+
index(node.sortedChildren, node);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
index(nodes, null);
|
|
160
|
+
|
|
161
|
+
entry = { nodes, parents, byHref };
|
|
162
|
+
cache.set(key, entry);
|
|
163
|
+
if (cache.size > MAX_CACHED_TREES) cache.delete(cache.keys().next().value);
|
|
164
|
+
return entry;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sorted top-level sidebar nodes for one page, with isCurrentPage, isActive,
|
|
169
|
+
* and isOpen set as they would be on a freshly built tree. Shared nodes are
|
|
170
|
+
* never mutated; nodes on the active path are copies.
|
|
171
|
+
*/
|
|
172
|
+
export function sidebarFor(pages, opts, pathname) {
|
|
173
|
+
const { nodes, parents, byHref } = cachedTree(pages, opts);
|
|
174
|
+
const current = new Set(byHref.get(normalizeForMatch(pathname, opts.basePath)) ?? []);
|
|
175
|
+
if (current.size === 0) return nodes;
|
|
176
|
+
|
|
177
|
+
const active = new Set();
|
|
178
|
+
for (const node of current) {
|
|
179
|
+
for (let n = node; n && !active.has(n); n = parents.get(n)) active.add(n);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const view = (list) => list.map(node => active.has(node)
|
|
183
|
+
? { ...node, isCurrentPage: current.has(node), isActive: true, isOpen: true, sortedChildren: view(node.sortedChildren) }
|
|
184
|
+
: node);
|
|
185
|
+
return view(nodes);
|
|
186
|
+
}
|