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.
Files changed (48) hide show
  1. package/build/api/content/$$.d.ts +1 -0
  2. package/build/api/content/$$.d.ts.map +1 -1
  3. package/build/api/content/$$.js +1 -0
  4. package/build/api/content/$$.js.map +1 -1
  5. package/build/api/content/navbar.d.ts +10 -0
  6. package/build/api/content/navbar.d.ts.map +1 -0
  7. package/build/api/content/navbar.js +45 -0
  8. package/build/api/content/navbar.js.map +1 -0
  9. package/build/api/content/sidebar.d.ts +85 -5
  10. package/build/api/content/sidebar.d.ts.map +1 -1
  11. package/build/api/content/sidebar.js +151 -75
  12. package/build/api/content/sidebar.js.map +1 -1
  13. package/build/api/vite/plugins/pages.d.ts +1 -4
  14. package/build/api/vite/plugins/pages.d.ts.map +1 -1
  15. package/build/api/vite/plugins/pages.js +4 -42
  16. package/build/api/vite/plugins/pages.js.map +1 -1
  17. package/build/lib/file-router/scan.d.ts.map +1 -1
  18. package/build/lib/file-router/scan.js +6 -1
  19. package/build/lib/file-router/scan.js.map +1 -1
  20. package/build/sandbox.js +17 -2
  21. package/build/sandbox.js.map +1 -1
  22. package/build/template/components/HamburgerMenu.d.ts +9 -0
  23. package/build/template/components/HamburgerMenu.d.ts.map +1 -0
  24. package/build/template/components/HamburgerMenu.jsx +53 -0
  25. package/build/template/components/HamburgerMenu.jsx.map +1 -0
  26. package/build/template/components/NotFound.d.ts +2 -0
  27. package/build/template/components/NotFound.d.ts.map +1 -0
  28. package/build/template/components/NotFound.jsx +26 -0
  29. package/build/template/components/NotFound.jsx.map +1 -0
  30. package/build/template/components/ThemeToggle.jsx +2 -2
  31. package/build/template/routes/root.d.ts.map +1 -1
  32. package/build/template/routes/root.jsx +40 -30
  33. package/build/template/routes/root.jsx.map +1 -1
  34. package/package.json +1 -1
  35. package/src/api/content/$$.ts +1 -0
  36. package/src/api/content/navbar.test.ts +55 -0
  37. package/src/api/content/navbar.ts +61 -0
  38. package/src/api/content/sidebar.test.ts +297 -0
  39. package/src/api/content/sidebar.ts +235 -88
  40. package/src/api/vite/plugins/pages.ts +5 -51
  41. package/src/lib/file-router/scan.ts +7 -1
  42. package/src/lib/version-history/index.test.ts +12 -4
  43. package/src/sandbox.ts +20 -1
  44. package/src/template/components/HamburgerMenu.tsx +96 -0
  45. package/src/template/components/NotFound.tsx +28 -0
  46. package/src/template/components/ThemeToggle.tsx +5 -5
  47. package/src/template/contexts/ThemeContext.tsx +4 -4
  48. 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
- * Build sidebar from page tree structure
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 const buildFromPageTree = (pageTree: Tree.Tree<Page>, basePath: FileRouter.Path): Sidebar => {
30
- const links: ItemLink[] = []
31
- const sections: ItemSection[] = []
32
-
33
- // Process only the children of the root node
34
- if (pageTree.root) {
35
- for (const child of pageTree.root.children) {
36
- processPageNode(child, basePath, [], links, sections)
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
- const items = [...links, ...sections]
115
+ // Group pages by their top-level directory
116
+ const pagesByTopLevelDir = new Map<string, Page[]>()
41
117
 
42
- return {
43
- items,
44
- }
45
- }
118
+ for (const page of scanResult.list) {
119
+ const topLevelDir = page.route.logical.path[0]
46
120
 
47
- const processPageNode = (
48
- node: Tree.Node<Page>,
49
- basePath: FileRouter.Path,
50
- parentPath: string[],
51
- links: ItemLink[],
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
- // Process all children as links for this section
76
- for (const child of node.children) {
77
- const childPage = child.value
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
- if (child.children.length > 0) {
80
- // This child has children - only collect its descendants, not the child itself
81
- collectPagesFromNode(child, basePath, section.links)
82
- } else if (!childPage.metadata.hidden && !isIndexPage(childPage)) {
83
- // This is a leaf node - add it as a link
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
- sections.push(section)
89
- } else {
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
- const collectPagesFromNode = (
98
- node: Tree.Node<Page>,
99
- basePath: FileRouter.Path,
100
- links: ItemLink[],
101
- ): void => {
102
- // Only process children, not the node itself (to avoid double-adding intermediate directories)
103
- for (const child of node.children) {
104
- const childPage = child.value
105
- if (childPage && !childPage.metadata.hidden && !isIndexPage(childPage)) {
106
- links.push(pageToItemLink(childPage, basePath))
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
- // Recursively process grandchildren
110
- if (child.children.length > 0) {
111
- collectPagesFromNode(child, basePath, links)
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
- const pageToItemLink = (page: Page, basePath: FileRouter.Path): ItemLink => {
117
- const pagePathExp = FileRouter.routeToPathExpression(page.route)
118
- const pageRelative = FileRouter.makeRelativeUnsafe(page.route, basePath)
119
- const pageRelativePathExp = FileRouter.routeToPathExpression(pageRelative)
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
- // Remove leading slash for title generation
122
- const titlePath = pageRelativePathExp.startsWith('/') ? pageRelativePathExp.slice(1) : pageRelativePathExp
191
+ if (orderA !== orderB) return orderA - orderB
123
192
 
124
- // Use only the last segment for the title
125
- const titleSegment = pageRelative.logical.path[pageRelative.logical.path.length - 1] || titlePath
193
+ // Fall back to alphabetical order
194
+ return dirA.localeCompare(dirB)
195
+ })
126
196
 
127
- return {
128
- type: `ItemLink`,
129
- pathExp: pagePathExp.startsWith('/') ? pagePathExp.slice(1) : pagePathExp,
130
- title: Str.titlizeSlug(titleSegment),
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
- const isIndexPage = (page: Page): boolean => {
135
- return page.route.file.path.relative.name === 'index'
282
+ return { items }
136
283
  }