polen 0.10.0-next.6 → 0.10.0-next.8
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/build/api/content/$$.d.ts +1 -0
- package/build/api/content/$$.d.ts.map +1 -1
- package/build/api/content/$$.js +1 -0
- package/build/api/content/$$.js.map +1 -1
- package/build/api/content/navbar.d.ts +10 -0
- package/build/api/content/navbar.d.ts.map +1 -0
- package/build/api/content/navbar.js +45 -0
- package/build/api/content/navbar.js.map +1 -0
- package/build/api/content/sidebar.d.ts +85 -5
- package/build/api/content/sidebar.d.ts.map +1 -1
- package/build/api/content/sidebar.js +151 -75
- package/build/api/content/sidebar.js.map +1 -1
- package/build/api/vite/plugins/pages.d.ts +1 -4
- package/build/api/vite/plugins/pages.d.ts.map +1 -1
- package/build/api/vite/plugins/pages.js +4 -42
- package/build/api/vite/plugins/pages.js.map +1 -1
- package/build/lib/file-router/scan.d.ts.map +1 -1
- package/build/lib/file-router/scan.js +6 -1
- package/build/lib/file-router/scan.js.map +1 -1
- package/build/sandbox.js +17 -2
- package/build/sandbox.js.map +1 -1
- package/build/template/components/HamburgerMenu.d.ts +9 -0
- package/build/template/components/HamburgerMenu.d.ts.map +1 -0
- package/build/template/components/HamburgerMenu.jsx +53 -0
- package/build/template/components/HamburgerMenu.jsx.map +1 -0
- package/build/template/components/NotFound.d.ts +2 -0
- package/build/template/components/NotFound.d.ts.map +1 -0
- package/build/template/components/NotFound.jsx +26 -0
- package/build/template/components/NotFound.jsx.map +1 -0
- package/build/template/components/ThemeToggle.jsx +2 -2
- package/build/template/routes/root.d.ts.map +1 -1
- package/build/template/routes/root.jsx +40 -30
- package/build/template/routes/root.jsx.map +1 -1
- package/package.json +1 -1
- package/src/api/content/$$.ts +1 -0
- package/src/api/content/navbar.test.ts +55 -0
- package/src/api/content/navbar.ts +61 -0
- package/src/api/content/sidebar.test.ts +297 -0
- package/src/api/content/sidebar.ts +235 -88
- package/src/api/vite/plugins/pages.ts +5 -51
- package/src/lib/file-router/scan.ts +7 -1
- package/src/lib/version-history/index.test.ts +12 -4
- package/src/sandbox.ts +20 -1
- package/src/template/components/HamburgerMenu.tsx +96 -0
- package/src/template/components/NotFound.tsx +28 -0
- package/src/template/components/ThemeToggle.tsx +5 -5
- package/src/template/contexts/ThemeContext.tsx +4 -4
- package/src/template/routes/root.tsx +59 -42
@@ -0,0 +1,297 @@
|
|
1
|
+
import * as fc from 'fast-check'
|
2
|
+
import { describe, expect, it } from 'vitest'
|
3
|
+
import type { Page } from './page.ts'
|
4
|
+
import type { ScanResult } from './scan.ts'
|
5
|
+
import { buildSidebarIndex, type Item, type SidebarIndex } from './sidebar.ts'
|
6
|
+
|
7
|
+
// Generators
|
8
|
+
const pathSegmentArb = fc.stringMatching(/^[a-z][a-z0-9-]{0,19}$/)
|
9
|
+
const fileNameArb = fc.oneof(fc.constant('index'), pathSegmentArb)
|
10
|
+
const pathArb = fc.array(pathSegmentArb, { minLength: 1, maxLength: 4 })
|
11
|
+
|
12
|
+
const pageArb: fc.Arbitrary<Page> = fc.record({
|
13
|
+
route: fc.record({
|
14
|
+
id: fc.string(),
|
15
|
+
parentId: fc.oneof(fc.constant(null), fc.string()),
|
16
|
+
logical: fc.record({
|
17
|
+
path: pathArb,
|
18
|
+
order: fc.option(fc.integer({ min: 0, max: 100 }), { nil: undefined }),
|
19
|
+
}),
|
20
|
+
file: fc.record({
|
21
|
+
path: fc.record({
|
22
|
+
absolute: fc.record({
|
23
|
+
root: fc.constant('/'),
|
24
|
+
dir: fc.string(),
|
25
|
+
base: fc.string(),
|
26
|
+
ext: fc.constant('.md'),
|
27
|
+
name: fileNameArb,
|
28
|
+
}),
|
29
|
+
relative: fc.record({
|
30
|
+
root: fc.constant(''),
|
31
|
+
dir: fc.string(),
|
32
|
+
base: fc.string(),
|
33
|
+
ext: fc.constant('.md'),
|
34
|
+
name: fileNameArb,
|
35
|
+
}),
|
36
|
+
}),
|
37
|
+
}),
|
38
|
+
}),
|
39
|
+
metadata: fc.record({
|
40
|
+
description: fc.option(fc.string(), { nil: undefined }),
|
41
|
+
hidden: fc.boolean(),
|
42
|
+
}),
|
43
|
+
})
|
44
|
+
|
45
|
+
const scanResultArb: fc.Arbitrary<ScanResult> = fc.record({
|
46
|
+
list: fc.array(pageArb, { maxLength: 50 }),
|
47
|
+
tree: fc.constant({ root: null }), // Tree isn't used in the new implementation
|
48
|
+
diagnostics: fc.constant([]),
|
49
|
+
})
|
50
|
+
|
51
|
+
describe('buildSidebarIndex properties', () => {
|
52
|
+
it('never includes hidden pages in any sidebar', () => {
|
53
|
+
fc.assert(
|
54
|
+
fc.property(scanResultArb, (scanResult) => {
|
55
|
+
const result = buildSidebarIndex(scanResult)
|
56
|
+
|
57
|
+
// Collect all page paths that appear in sidebars
|
58
|
+
const allSidebarPaths = new Set<string>()
|
59
|
+
|
60
|
+
for (const sidebar of Object.values(result)) {
|
61
|
+
for (const item of sidebar.items) {
|
62
|
+
if (item.type === 'ItemLink') {
|
63
|
+
allSidebarPaths.add(item.pathExp)
|
64
|
+
} else {
|
65
|
+
allSidebarPaths.add(item.pathExp)
|
66
|
+
for (const link of item.links) {
|
67
|
+
allSidebarPaths.add(link.pathExp)
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
// Check that no hidden page appears in sidebars
|
74
|
+
const hiddenPagePaths = scanResult.list
|
75
|
+
.filter(page => page.metadata.hidden)
|
76
|
+
.map(page => page.route.logical.path.join('/'))
|
77
|
+
|
78
|
+
for (const hiddenPath of hiddenPagePaths) {
|
79
|
+
expect(allSidebarPaths.has(hiddenPath)).toBe(false)
|
80
|
+
}
|
81
|
+
|
82
|
+
return true
|
83
|
+
}),
|
84
|
+
)
|
85
|
+
})
|
86
|
+
|
87
|
+
it('only creates sidebars for directories with index pages', () => {
|
88
|
+
fc.assert(
|
89
|
+
fc.property(scanResultArb, (scanResult) => {
|
90
|
+
const result = buildSidebarIndex(scanResult)
|
91
|
+
|
92
|
+
// Every sidebar key should correspond to a directory with an index page
|
93
|
+
for (const sidebarPath of Object.keys(result)) {
|
94
|
+
const topLevelDir = sidebarPath.slice(1) // Remove leading '/'
|
95
|
+
|
96
|
+
const hasIndexPage = scanResult.list.some(page =>
|
97
|
+
page.route.logical.path.length === 1
|
98
|
+
&& page.route.logical.path[0] === topLevelDir
|
99
|
+
&& page.route.file.path.relative.name === 'index'
|
100
|
+
&& !page.metadata.hidden
|
101
|
+
)
|
102
|
+
|
103
|
+
expect(hasIndexPage).toBe(true)
|
104
|
+
}
|
105
|
+
|
106
|
+
return true
|
107
|
+
}),
|
108
|
+
)
|
109
|
+
})
|
110
|
+
|
111
|
+
it('all items have valid non-empty titles and paths', () => {
|
112
|
+
fc.assert(
|
113
|
+
fc.property(scanResultArb, (scanResult) => {
|
114
|
+
const result = buildSidebarIndex(scanResult)
|
115
|
+
|
116
|
+
for (const sidebar of Object.values(result)) {
|
117
|
+
for (const item of sidebar.items) {
|
118
|
+
// Check item has required fields
|
119
|
+
expect(item.title).toBeTruthy()
|
120
|
+
expect(item.title.length).toBeGreaterThan(0)
|
121
|
+
expect(item.pathExp).toBeTruthy()
|
122
|
+
expect(item.pathExp.length).toBeGreaterThan(0)
|
123
|
+
|
124
|
+
if (item.type === 'ItemSection') {
|
125
|
+
expect(typeof item.isLinkToo).toBe('boolean')
|
126
|
+
expect(Array.isArray(item.links)).toBe(true)
|
127
|
+
|
128
|
+
// Check all links in section
|
129
|
+
for (const link of item.links) {
|
130
|
+
expect(link.type).toBe('ItemLink')
|
131
|
+
expect(link.title).toBeTruthy()
|
132
|
+
expect(link.title.length).toBeGreaterThan(0)
|
133
|
+
expect(link.pathExp).toBeTruthy()
|
134
|
+
expect(link.pathExp.length).toBeGreaterThan(0)
|
135
|
+
}
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
return true
|
141
|
+
}),
|
142
|
+
)
|
143
|
+
})
|
144
|
+
|
145
|
+
it('sections marked as linkable have corresponding index pages', () => {
|
146
|
+
fc.assert(
|
147
|
+
fc.property(scanResultArb, (scanResult) => {
|
148
|
+
const result = buildSidebarIndex(scanResult)
|
149
|
+
|
150
|
+
for (const sidebar of Object.values(result)) {
|
151
|
+
for (const item of sidebar.items) {
|
152
|
+
if (item.type === 'ItemSection' && item.isLinkToo) {
|
153
|
+
// This section should have an index page
|
154
|
+
const hasIndexPage = scanResult.list.some(page =>
|
155
|
+
page.route.logical.path.join('/') === item.pathExp
|
156
|
+
&& page.route.file.path.relative.name === 'index'
|
157
|
+
&& !page.metadata.hidden
|
158
|
+
)
|
159
|
+
|
160
|
+
expect(hasIndexPage).toBe(true)
|
161
|
+
}
|
162
|
+
}
|
163
|
+
}
|
164
|
+
|
165
|
+
return true
|
166
|
+
}),
|
167
|
+
)
|
168
|
+
})
|
169
|
+
|
170
|
+
it('no duplicate paths within a sidebar', () => {
|
171
|
+
fc.assert(
|
172
|
+
fc.property(scanResultArb, (scanResult) => {
|
173
|
+
const result = buildSidebarIndex(scanResult)
|
174
|
+
|
175
|
+
for (const sidebar of Object.values(result)) {
|
176
|
+
const paths = new Set<string>()
|
177
|
+
|
178
|
+
for (const item of sidebar.items) {
|
179
|
+
if (item.type === 'ItemLink') {
|
180
|
+
expect(paths.has(item.pathExp)).toBe(false)
|
181
|
+
paths.add(item.pathExp)
|
182
|
+
} else {
|
183
|
+
expect(paths.has(item.pathExp)).toBe(false)
|
184
|
+
paths.add(item.pathExp)
|
185
|
+
|
186
|
+
for (const link of item.links) {
|
187
|
+
expect(paths.has(link.pathExp)).toBe(false)
|
188
|
+
paths.add(link.pathExp)
|
189
|
+
}
|
190
|
+
}
|
191
|
+
}
|
192
|
+
}
|
193
|
+
|
194
|
+
return true
|
195
|
+
}),
|
196
|
+
)
|
197
|
+
})
|
198
|
+
|
199
|
+
it('deterministic - same input produces same output', () => {
|
200
|
+
fc.assert(
|
201
|
+
fc.property(scanResultArb, (scanResult) => {
|
202
|
+
const result1 = buildSidebarIndex(scanResult)
|
203
|
+
const result2 = buildSidebarIndex(scanResult)
|
204
|
+
|
205
|
+
expect(JSON.stringify(result1)).toBe(JSON.stringify(result2))
|
206
|
+
return true
|
207
|
+
}),
|
208
|
+
)
|
209
|
+
})
|
210
|
+
|
211
|
+
it('preserves hierarchical relationships', () => {
|
212
|
+
fc.assert(
|
213
|
+
fc.property(scanResultArb, (scanResult) => {
|
214
|
+
const result = buildSidebarIndex(scanResult)
|
215
|
+
|
216
|
+
// For each sidebar, verify that all items belong to that top-level directory
|
217
|
+
for (const [sidebarPath, sidebar] of Object.entries(result)) {
|
218
|
+
const expectedPrefix = sidebarPath.slice(1) // Remove leading '/'
|
219
|
+
|
220
|
+
const checkPath = (pathExp: string) => {
|
221
|
+
const segments = pathExp.split('/')
|
222
|
+
expect(segments[0]).toBe(expectedPrefix)
|
223
|
+
}
|
224
|
+
|
225
|
+
for (const item of sidebar.items) {
|
226
|
+
checkPath(item.pathExp)
|
227
|
+
|
228
|
+
if (item.type === 'ItemSection') {
|
229
|
+
for (const link of item.links) {
|
230
|
+
checkPath(link.pathExp)
|
231
|
+
}
|
232
|
+
}
|
233
|
+
}
|
234
|
+
}
|
235
|
+
|
236
|
+
return true
|
237
|
+
}),
|
238
|
+
)
|
239
|
+
})
|
240
|
+
})
|
241
|
+
|
242
|
+
// Keep a few specific scenario tests for regression
|
243
|
+
describe('buildSidebarIndex specific scenarios', () => {
|
244
|
+
const createPage = (path: string[], fileName = 'index', hidden = false): Page => ({
|
245
|
+
route: {
|
246
|
+
id: path.join('/'),
|
247
|
+
parentId: path.length > 1 ? path.slice(0, -1).join('/') : null,
|
248
|
+
logical: { path },
|
249
|
+
file: {
|
250
|
+
path: {
|
251
|
+
absolute: { root: '/', dir: `/pages/${path.join('/')}`, base: `${fileName}.md`, ext: '.md', name: fileName },
|
252
|
+
relative: { root: '', dir: path.join('/'), base: `${fileName}.md`, ext: '.md', name: fileName },
|
253
|
+
},
|
254
|
+
},
|
255
|
+
},
|
256
|
+
metadata: { description: undefined, hidden },
|
257
|
+
})
|
258
|
+
|
259
|
+
it('handles empty input', () => {
|
260
|
+
const scanResult: ScanResult = {
|
261
|
+
list: [],
|
262
|
+
tree: { root: null },
|
263
|
+
diagnostics: [],
|
264
|
+
}
|
265
|
+
const result = buildSidebarIndex(scanResult)
|
266
|
+
expect(result).toEqual({})
|
267
|
+
})
|
268
|
+
|
269
|
+
it('creates sections for nested directories with index pages', () => {
|
270
|
+
const pages = [
|
271
|
+
createPage(['guide'], 'index'),
|
272
|
+
createPage(['guide', 'advanced'], 'index'),
|
273
|
+
createPage(['guide', 'advanced', 'tips'], 'tips'),
|
274
|
+
createPage(['guide', 'advanced', 'patterns'], 'patterns'),
|
275
|
+
]
|
276
|
+
|
277
|
+
const scanResult: ScanResult = {
|
278
|
+
list: pages,
|
279
|
+
tree: { root: null },
|
280
|
+
diagnostics: [],
|
281
|
+
}
|
282
|
+
|
283
|
+
const result = buildSidebarIndex(scanResult)
|
284
|
+
|
285
|
+
expect(result['/guide']).toBeDefined()
|
286
|
+
expect(result['/guide']!.items).toHaveLength(1)
|
287
|
+
|
288
|
+
const section = result['/guide']!.items[0]
|
289
|
+
expect(section?.type).toBe('ItemSection')
|
290
|
+
|
291
|
+
if (section?.type === 'ItemSection') {
|
292
|
+
expect(section.title).toBe('Advanced')
|
293
|
+
expect(section.isLinkToo).toBe(true)
|
294
|
+
expect(section.links).toHaveLength(2)
|
295
|
+
}
|
296
|
+
})
|
297
|
+
})
|
@@ -1,136 +1,283 @@
|
|
1
1
|
import { FileRouter } from '#lib/file-router/index'
|
2
|
-
import { Tree } from '@wollybeard/kit'
|
3
2
|
import { Str } from '@wollybeard/kit'
|
4
3
|
import type { Page } from './page.ts'
|
4
|
+
import type { ScanResult } from './scan.ts'
|
5
5
|
|
6
|
+
/**
|
7
|
+
* Represents a complete sidebar structure with navigation items.
|
8
|
+
* This is the main data structure used to render sidebars in the UI.
|
9
|
+
*/
|
6
10
|
export interface Sidebar {
|
11
|
+
/** Array of navigation items that can be either direct links or sections containing multiple links */
|
7
12
|
items: Item[]
|
8
13
|
}
|
9
14
|
|
15
|
+
/**
|
16
|
+
* A sidebar navigation item that can be either a direct link or a section containing multiple links.
|
17
|
+
* @see {@link ItemLink} for direct navigation links
|
18
|
+
* @see {@link ItemSection} for grouped navigation sections
|
19
|
+
*/
|
10
20
|
export type Item = ItemLink | ItemSection
|
11
21
|
|
22
|
+
/**
|
23
|
+
* A direct navigation link in the sidebar.
|
24
|
+
* Used for pages that don't have child pages.
|
25
|
+
*
|
26
|
+
* @example
|
27
|
+
* ```ts
|
28
|
+
* const link: ItemLink = {
|
29
|
+
* type: 'ItemLink',
|
30
|
+
* title: 'Getting Started',
|
31
|
+
* pathExp: 'guide/getting-started'
|
32
|
+
* }
|
33
|
+
* ```
|
34
|
+
*/
|
12
35
|
export interface ItemLink {
|
36
|
+
/** Discriminator for TypeScript union types */
|
13
37
|
type: `ItemLink`
|
38
|
+
/** Display title for the link (e.g., "Getting Started") */
|
14
39
|
title: string
|
40
|
+
/** Path expression relative to the base path, without leading slash (e.g., "guide/getting-started") */
|
15
41
|
pathExp: string
|
16
42
|
}
|
17
43
|
|
44
|
+
/**
|
45
|
+
* A collapsible section in the sidebar that groups related links.
|
46
|
+
* Used for directories that contain multiple pages.
|
47
|
+
*
|
48
|
+
* @example
|
49
|
+
* ```ts
|
50
|
+
* const section: ItemSection = {
|
51
|
+
* type: 'ItemSection',
|
52
|
+
* title: 'Guide',
|
53
|
+
* pathExp: 'guide',
|
54
|
+
* isLinkToo: true, // Has an index page
|
55
|
+
* links: [
|
56
|
+
* { type: 'ItemLink', title: 'Installation', pathExp: 'guide/installation' },
|
57
|
+
* { type: 'ItemLink', title: 'Configuration', pathExp: 'guide/configuration' }
|
58
|
+
* ]
|
59
|
+
* }
|
60
|
+
* ```
|
61
|
+
*/
|
18
62
|
export interface ItemSection {
|
63
|
+
/** Discriminator for TypeScript union types */
|
19
64
|
type: `ItemSection`
|
65
|
+
/** Display title for the section (e.g., "Guide", "API Reference") */
|
20
66
|
title: string
|
67
|
+
/** Path expression for the section's index page, if it exists (e.g., "guide") */
|
21
68
|
pathExp: string
|
69
|
+
/** Whether this section also acts as a link (true if the directory has an index page) */
|
22
70
|
isLinkToo: boolean
|
71
|
+
/** Child navigation links within this section */
|
23
72
|
links: ItemLink[]
|
24
73
|
}
|
25
74
|
|
26
75
|
/**
|
27
|
-
*
|
76
|
+
* A mapping of route paths to their corresponding sidebar structures.
|
77
|
+
* Used to store different sidebars for different sections of a site.
|
78
|
+
*
|
79
|
+
* @example
|
80
|
+
* ```ts
|
81
|
+
* const sidebarIndex: SidebarIndex = {
|
82
|
+
* '/guide': { items: [...] }, // Sidebar for /guide section
|
83
|
+
* '/api': { items: [...] }, // Sidebar for /api section
|
84
|
+
* '/reference': { items: [...] } // Sidebar for /reference section
|
85
|
+
* }
|
86
|
+
* ```
|
28
87
|
*/
|
29
|
-
export
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
88
|
+
export type SidebarIndex = Record<string, Sidebar>
|
89
|
+
|
90
|
+
/**
|
91
|
+
* Builds sidebars for all top-level directories that contain both an index page and nested content.
|
92
|
+
*
|
93
|
+
* This function analyzes a scan result to identify which directories should have sidebars.
|
94
|
+
* A directory gets a sidebar if it meets these criteria:
|
95
|
+
* 1. It's a top-level directory (e.g., /guide, /api, /docs)
|
96
|
+
* 2. It has an index page (e.g., /guide/index.md)
|
97
|
+
* 3. It has nested pages (e.g., /guide/getting-started.md, /guide/configuration.md)
|
98
|
+
*
|
99
|
+
* @param scanResult - The result of scanning pages, containing both a flat list and tree structure
|
100
|
+
* @returns A mapping of route paths to sidebar structures
|
101
|
+
*
|
102
|
+
* @example
|
103
|
+
* ```ts
|
104
|
+
* const scanResult = await Content.scan({ dir: './pages' })
|
105
|
+
* const sidebars = buildSidebarIndex(scanResult)
|
106
|
+
* // Returns: {
|
107
|
+
* // '/guide': { items: [...] },
|
108
|
+
* // '/api': { items: [...] }
|
109
|
+
* // }
|
110
|
+
* ```
|
111
|
+
*/
|
112
|
+
export const buildSidebarIndex = (scanResult: ScanResult): SidebarIndex => {
|
113
|
+
const sidebarIndex: SidebarIndex = {}
|
39
114
|
|
40
|
-
|
115
|
+
// Group pages by their top-level directory
|
116
|
+
const pagesByTopLevelDir = new Map<string, Page[]>()
|
41
117
|
|
42
|
-
|
43
|
-
|
44
|
-
}
|
45
|
-
}
|
118
|
+
for (const page of scanResult.list) {
|
119
|
+
const topLevelDir = page.route.logical.path[0]
|
46
120
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
sections: ItemSection[],
|
53
|
-
): void => {
|
54
|
-
const page = node.value
|
55
|
-
const routeName = page.route.logical.path.slice(-1)[0] || 'index'
|
56
|
-
const currentPath = [...parentPath, routeName]
|
57
|
-
|
58
|
-
// If this page has children, treat it as a section
|
59
|
-
if (node.children.length > 0) {
|
60
|
-
const sectionPath = [...basePath, ...currentPath]
|
61
|
-
const sectionPathExp = FileRouter.pathToExpression(sectionPath)
|
62
|
-
const sectionTitle = Str.titlizeSlug(routeName)
|
63
|
-
|
64
|
-
// Check if any child is an index page
|
65
|
-
const hasIndexChild = node.children.some(child => isIndexPage(child.value))
|
66
|
-
|
67
|
-
const section: ItemSection = {
|
68
|
-
type: `ItemSection`,
|
69
|
-
title: sectionTitle,
|
70
|
-
pathExp: sectionPathExp.startsWith('/') ? sectionPathExp.slice(1) : sectionPathExp,
|
71
|
-
isLinkToo: hasIndexChild, // Section is linkable if it has an index page
|
72
|
-
links: [],
|
121
|
+
// Skip pages that are not in a directory or are hidden
|
122
|
+
if (!topLevelDir || page.metadata.hidden) continue
|
123
|
+
|
124
|
+
if (!pagesByTopLevelDir.has(topLevelDir)) {
|
125
|
+
pagesByTopLevelDir.set(topLevelDir, [])
|
73
126
|
}
|
127
|
+
pagesByTopLevelDir.get(topLevelDir)!.push(page)
|
128
|
+
}
|
74
129
|
|
75
|
-
|
76
|
-
|
77
|
-
|
130
|
+
// Build sidebar for each directory that has an index page
|
131
|
+
for (const [topLevelDir, pages] of pagesByTopLevelDir) {
|
132
|
+
const hasIndexPage = pages.some(page =>
|
133
|
+
page.route.logical.path.length === 1
|
134
|
+
&& FileRouter.routeIsFromIndexFile(page.route)
|
135
|
+
)
|
78
136
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
section.links.push(pageToItemLink(childPage, basePath))
|
85
|
-
}
|
86
|
-
}
|
137
|
+
// Skip directories without index pages
|
138
|
+
if (!hasIndexPage) continue
|
139
|
+
|
140
|
+
const pathExp = `/${topLevelDir}`
|
141
|
+
const sidebar = buildSidebarForDirectory(topLevelDir, pages)
|
87
142
|
|
88
|
-
|
89
|
-
|
90
|
-
// This is a standalone file - add as top-level link
|
91
|
-
if (!page.metadata.hidden && !isIndexPage(page)) {
|
92
|
-
links.push(pageToItemLink(page, basePath))
|
143
|
+
if (sidebar.items.length > 0) {
|
144
|
+
sidebarIndex[pathExp] = sidebar
|
93
145
|
}
|
94
146
|
}
|
147
|
+
|
148
|
+
return sidebarIndex
|
95
149
|
}
|
96
150
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
151
|
+
/**
|
152
|
+
* Builds a sidebar for a specific directory from its pages
|
153
|
+
*/
|
154
|
+
const buildSidebarForDirectory = (topLevelDir: string, pages: Page[]): Sidebar => {
|
155
|
+
const items: Item[] = []
|
156
|
+
|
157
|
+
// Group pages by their immediate parent path
|
158
|
+
const pagesByParent = new Map<string, Page[]>()
|
159
|
+
|
160
|
+
for (const page of pages) {
|
161
|
+
// Skip the index page at the top level directory
|
162
|
+
if (page.route.logical.path.length === 1 && FileRouter.routeIsFromIndexFile(page.route)) {
|
163
|
+
continue
|
107
164
|
}
|
108
165
|
|
109
|
-
//
|
110
|
-
|
111
|
-
|
166
|
+
// Get the immediate parent path (e.g., for ['guide', 'advanced', 'tips'], parent is ['guide', 'advanced'])
|
167
|
+
const parentPath = page.route.logical.path.slice(0, -1).join('/')
|
168
|
+
|
169
|
+
if (!pagesByParent.has(parentPath)) {
|
170
|
+
pagesByParent.set(parentPath, [])
|
112
171
|
}
|
172
|
+
pagesByParent.get(parentPath)!.push(page)
|
113
173
|
}
|
114
|
-
}
|
115
174
|
|
116
|
-
|
117
|
-
const
|
118
|
-
|
119
|
-
|
175
|
+
// Process top-level pages (direct children of the directory)
|
176
|
+
const topLevelPages = pagesByParent.get(topLevelDir) || []
|
177
|
+
|
178
|
+
// Sort pages by their directory order (extracted from file path)
|
179
|
+
const sortedTopLevelPages = [...topLevelPages].sort((a, b) => {
|
180
|
+
// For sections, we need to look at the directory name in the file path
|
181
|
+
const dirA = a.route.file.path.relative.dir.split('/').pop() || ''
|
182
|
+
const dirB = b.route.file.path.relative.dir.split('/').pop() || ''
|
183
|
+
|
184
|
+
// Extract order from directory names like "10_b", "20_c"
|
185
|
+
const orderMatchA = dirA.match(/^(\d+)[_-]/)
|
186
|
+
const orderMatchB = dirB.match(/^(\d+)[_-]/)
|
187
|
+
|
188
|
+
const orderA = orderMatchA ? parseInt(orderMatchA[1]!, 10) : Number.MAX_SAFE_INTEGER
|
189
|
+
const orderB = orderMatchB ? parseInt(orderMatchB[1]!, 10) : Number.MAX_SAFE_INTEGER
|
120
190
|
|
121
|
-
|
122
|
-
const titlePath = pageRelativePathExp.startsWith('/') ? pageRelativePathExp.slice(1) : pageRelativePathExp
|
191
|
+
if (orderA !== orderB) return orderA - orderB
|
123
192
|
|
124
|
-
|
125
|
-
|
193
|
+
// Fall back to alphabetical order
|
194
|
+
return dirA.localeCompare(dirB)
|
195
|
+
})
|
126
196
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
197
|
+
for (const page of sortedTopLevelPages) {
|
198
|
+
const pageName = page.route.logical.path[page.route.logical.path.length - 1]!
|
199
|
+
const childPath = page.route.logical.path.join('/')
|
200
|
+
const childPages = pagesByParent.get(childPath) || []
|
201
|
+
|
202
|
+
if (childPages.length > 0 || FileRouter.routeIsFromIndexFile(page.route)) {
|
203
|
+
// This is a section (has children or is an index page for a subdirectory)
|
204
|
+
const hasIndex = FileRouter.routeIsFromIndexFile(page.route)
|
205
|
+
|
206
|
+
const section: ItemSection = {
|
207
|
+
type: 'ItemSection',
|
208
|
+
title: Str.titlizeSlug(pageName),
|
209
|
+
pathExp: childPath,
|
210
|
+
isLinkToo: hasIndex,
|
211
|
+
links: [],
|
212
|
+
}
|
213
|
+
|
214
|
+
// Add direct children as links (sorted by order)
|
215
|
+
const sortedChildPages = [...childPages].sort((a, b) => {
|
216
|
+
const orderA = a.route.logical.order ?? Number.MAX_SAFE_INTEGER
|
217
|
+
const orderB = b.route.logical.order ?? Number.MAX_SAFE_INTEGER
|
218
|
+
return orderA - orderB
|
219
|
+
})
|
220
|
+
|
221
|
+
for (const childPage of sortedChildPages) {
|
222
|
+
if (!FileRouter.routeIsFromIndexFile(childPage.route)) {
|
223
|
+
section.links.push({
|
224
|
+
type: 'ItemLink',
|
225
|
+
title: Str.titlizeSlug(childPage.route.logical.path[childPage.route.logical.path.length - 1]!),
|
226
|
+
pathExp: childPage.route.logical.path.join('/'),
|
227
|
+
})
|
228
|
+
}
|
229
|
+
}
|
230
|
+
|
231
|
+
// Also add any deeper descendants as flat links
|
232
|
+
const allDescendants: Page[] = []
|
233
|
+
for (const [parentPath, pagesInParent] of pagesByParent) {
|
234
|
+
// Check if this path is a descendant (but not direct child)
|
235
|
+
if (parentPath.startsWith(childPath + '/')) {
|
236
|
+
for (const descendantPage of pagesInParent) {
|
237
|
+
if (!FileRouter.routeIsFromIndexFile(descendantPage.route)) {
|
238
|
+
allDescendants.push(descendantPage)
|
239
|
+
}
|
240
|
+
}
|
241
|
+
}
|
242
|
+
}
|
243
|
+
|
244
|
+
// Sort all descendants by their full path order
|
245
|
+
allDescendants.sort((a, b) => {
|
246
|
+
// Compare paths segment by segment, considering order at each level
|
247
|
+
const pathA = a.route.logical.path
|
248
|
+
const pathB = b.route.logical.path
|
249
|
+
const minLength = Math.min(pathA.length, pathB.length)
|
250
|
+
|
251
|
+
for (let i = 0; i < minLength; i++) {
|
252
|
+
const segmentCompare = pathA[i]!.localeCompare(pathB[i]!)
|
253
|
+
if (segmentCompare !== 0) return segmentCompare
|
254
|
+
}
|
255
|
+
|
256
|
+
return pathA.length - pathB.length
|
257
|
+
})
|
258
|
+
|
259
|
+
for (const descendantPage of allDescendants) {
|
260
|
+
section.links.push({
|
261
|
+
type: 'ItemLink',
|
262
|
+
title: Str.titlizeSlug(
|
263
|
+
descendantPage.route.logical.path[descendantPage.route.logical.path.length - 1]!,
|
264
|
+
),
|
265
|
+
pathExp: descendantPage.route.logical.path.join('/'),
|
266
|
+
})
|
267
|
+
}
|
268
|
+
|
269
|
+
if (section.links.length > 0 || section.isLinkToo) {
|
270
|
+
items.push(section)
|
271
|
+
}
|
272
|
+
} else {
|
273
|
+
// This is a simple link
|
274
|
+
items.push({
|
275
|
+
type: 'ItemLink',
|
276
|
+
title: Str.titlizeSlug(pageName),
|
277
|
+
pathExp: page.route.logical.path.join('/'),
|
278
|
+
})
|
279
|
+
}
|
131
280
|
}
|
132
|
-
}
|
133
281
|
|
134
|
-
|
135
|
-
return page.route.file.path.relative.name === 'index'
|
282
|
+
return { items }
|
136
283
|
}
|