@levino/shipyard-docs 0.4.7 → 0.5.0-rc-20251126212334
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/astro/DocPagination.astro +97 -0
- package/astro/Layout.astro +25 -4
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/pagination.test.ts +387 -0
- package/src/pagination.ts +191 -0
- package/src/sidebarEntries.ts +2 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { PaginationLink } from '../src/pagination'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
prev?: PaginationLink
|
|
6
|
+
next?: PaginationLink
|
|
7
|
+
prevLabel?: string
|
|
8
|
+
nextLabel?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { prev, next, prevLabel = 'Previous', nextLabel = 'Next' } = Astro.props
|
|
12
|
+
|
|
13
|
+
// Only render if there's at least one link
|
|
14
|
+
const shouldRender = prev || next
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
shouldRender && (
|
|
19
|
+
<nav
|
|
20
|
+
class="pagination-nav mt-8 border-t border-base-300 pt-6"
|
|
21
|
+
aria-label="Docs navigation"
|
|
22
|
+
>
|
|
23
|
+
{prev ? (
|
|
24
|
+
<a
|
|
25
|
+
href={prev.href}
|
|
26
|
+
class="pagination-nav__link pagination-nav__link--prev"
|
|
27
|
+
rel="prev"
|
|
28
|
+
>
|
|
29
|
+
<span class="pagination-nav__sublabel">{prevLabel}</span>
|
|
30
|
+
<span class="pagination-nav__label">{prev.title}</span>
|
|
31
|
+
</a>
|
|
32
|
+
) : (
|
|
33
|
+
<span />
|
|
34
|
+
)}
|
|
35
|
+
|
|
36
|
+
{next ? (
|
|
37
|
+
<a
|
|
38
|
+
href={next.href}
|
|
39
|
+
class="pagination-nav__link pagination-nav__link--next"
|
|
40
|
+
rel="next"
|
|
41
|
+
>
|
|
42
|
+
<span class="pagination-nav__sublabel">{nextLabel}</span>
|
|
43
|
+
<span class="pagination-nav__label">{next.title}</span>
|
|
44
|
+
</a>
|
|
45
|
+
) : (
|
|
46
|
+
<span />
|
|
47
|
+
)}
|
|
48
|
+
</nav>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.pagination-nav {
|
|
54
|
+
display: grid;
|
|
55
|
+
grid-template-columns: repeat(2, 1fr);
|
|
56
|
+
gap: 1rem;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.pagination-nav__link {
|
|
60
|
+
display: block;
|
|
61
|
+
padding: 1rem;
|
|
62
|
+
border: 1px solid oklch(var(--bc) / 0.2);
|
|
63
|
+
border-radius: var(--rounded-box, 1rem);
|
|
64
|
+
transition: border-color 0.2s;
|
|
65
|
+
line-height: 1.25;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.pagination-nav__link:hover {
|
|
69
|
+
border-color: oklch(var(--p));
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.pagination-nav__link--next {
|
|
74
|
+
text-align: right;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.pagination-nav__sublabel {
|
|
78
|
+
display: block;
|
|
79
|
+
font-size: 0.75rem;
|
|
80
|
+
opacity: 0.7;
|
|
81
|
+
margin-bottom: 0.25rem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.pagination-nav__label {
|
|
85
|
+
display: block;
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
word-break: break-word;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.pagination-nav__link--prev .pagination-nav__label::before {
|
|
91
|
+
content: '« ';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.pagination-nav__link--next .pagination-nav__label::after {
|
|
95
|
+
content: ' »';
|
|
96
|
+
}
|
|
97
|
+
</style>
|
package/astro/Layout.astro
CHANGED
|
@@ -5,8 +5,10 @@ import type { NavigationTree } from '@levino/shipyard-base'
|
|
|
5
5
|
import { Breadcrumbs, TableOfContents } from '@levino/shipyard-base/components'
|
|
6
6
|
import BaseLayout from '@levino/shipyard-base/layouts/Page.astro'
|
|
7
7
|
import { Array as EffectArray, Option } from 'effect'
|
|
8
|
+
import { getPaginationInfo } from '../src/pagination'
|
|
8
9
|
import type { DocsData } from '../src/sidebarEntries'
|
|
9
10
|
import { toSidebarEntries } from '../src/sidebarEntries'
|
|
11
|
+
import DocPagination from './DocPagination.astro'
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
headings?: { depth: number; text: string; slug: string }[]
|
|
@@ -28,10 +30,21 @@ const { headings = [], routeBasePath = 'docs', docsData } = Astro.props
|
|
|
28
30
|
// Normalize the route base path
|
|
29
31
|
const normalizedBasePath = routeBasePath.replace(/^\/+|\/+$/g, '')
|
|
30
32
|
|
|
31
|
-
const getPath = (id: string) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const getPath = (id: string) => {
|
|
34
|
+
// Remove the .md extension if present and handle index pages
|
|
35
|
+
const cleanId = id.replace(/\.md$/, '')
|
|
36
|
+
const isIndex = cleanId.endsWith('/index') || cleanId === 'index'
|
|
37
|
+
|
|
38
|
+
if (i18n) {
|
|
39
|
+
// For i18n, slice off the locale prefix (e.g., 'en/')
|
|
40
|
+
const pathPart = cleanId.slice(3)
|
|
41
|
+
const finalPath = isIndex ? pathPart.replace(/\/?index$/, '') : pathPart
|
|
42
|
+
return `/${Astro.currentLocale}/${normalizedBasePath}/${finalPath}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const finalPath = isIndex ? cleanId.replace(/\/?index$/, '') : cleanId
|
|
46
|
+
return `/${normalizedBasePath}/${finalPath}`
|
|
47
|
+
}
|
|
35
48
|
|
|
36
49
|
// Use provided docsData or fetch from the default 'docs' collection
|
|
37
50
|
const docs =
|
|
@@ -48,6 +61,8 @@ const docs =
|
|
|
48
61
|
sidebar_label,
|
|
49
62
|
sidebar_class_name,
|
|
50
63
|
sidebar_custom_props,
|
|
64
|
+
pagination_next,
|
|
65
|
+
pagination_prev,
|
|
51
66
|
},
|
|
52
67
|
} = doc
|
|
53
68
|
return {
|
|
@@ -68,6 +83,8 @@ const docs =
|
|
|
68
83
|
sidebarLabel: sidebar_label,
|
|
69
84
|
sidebarClassName: sidebar_class_name,
|
|
70
85
|
sidebarCustomProps: sidebar_custom_props,
|
|
86
|
+
pagination_next,
|
|
87
|
+
pagination_prev,
|
|
71
88
|
}
|
|
72
89
|
}),
|
|
73
90
|
)
|
|
@@ -79,6 +96,9 @@ const entries: NavigationTree =
|
|
|
79
96
|
i18n && Astro.currentLocale
|
|
80
97
|
? (fullTree[Astro.currentLocale]?.subEntry ?? {})
|
|
81
98
|
: fullTree
|
|
99
|
+
|
|
100
|
+
// Compute pagination info for the current page
|
|
101
|
+
const pagination = getPaginationInfo(Astro.url.pathname, entries, docs)
|
|
82
102
|
---
|
|
83
103
|
|
|
84
104
|
<BaseLayout sidebarNavigation={entries}>
|
|
@@ -88,6 +108,7 @@ const entries: NavigationTree =
|
|
|
88
108
|
<Breadcrumbs navigation={entries} />
|
|
89
109
|
<TableOfContents links={headings} class="xl:hidden" />
|
|
90
110
|
<slot />
|
|
111
|
+
<DocPagination prev={pagination.prev} next={pagination.next} />
|
|
91
112
|
</div>
|
|
92
113
|
</div>
|
|
93
114
|
<div class="hidden xl:block col-span-3">
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { AstroIntegration } from 'astro'
|
|
|
2
2
|
import { glob } from 'astro/loaders'
|
|
3
3
|
import { z } from 'astro/zod'
|
|
4
4
|
|
|
5
|
+
// Re-export pagination types and utilities
|
|
6
|
+
export type { PaginationInfo, PaginationLink } from './pagination'
|
|
7
|
+
export { getPaginationInfo } from './pagination'
|
|
5
8
|
export type { DocsRouteConfig } from './routeHelpers'
|
|
6
9
|
// Re-export route helpers
|
|
7
10
|
export { getDocPath, getRouteParams } from './routeHelpers'
|
|
@@ -21,6 +24,8 @@ export const docsSchema = z.object({
|
|
|
21
24
|
sidebar_position: z.number().optional(),
|
|
22
25
|
sidebar_label: z.string().optional(),
|
|
23
26
|
sidebar_class_name: z.string().optional(),
|
|
27
|
+
pagination_next: z.string().nullable().optional(),
|
|
28
|
+
pagination_prev: z.string().nullable().optional(),
|
|
24
29
|
})
|
|
25
30
|
|
|
26
31
|
/**
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import type { Entry } from '@levino/shipyard-base'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { getPaginationInfo } from './pagination'
|
|
4
|
+
import type { DocsData } from './sidebarEntries'
|
|
5
|
+
|
|
6
|
+
describe('getPaginationInfo', () => {
|
|
7
|
+
const createSidebar = (): Entry => ({
|
|
8
|
+
intro: {
|
|
9
|
+
label: 'Introduction',
|
|
10
|
+
href: '/docs/intro',
|
|
11
|
+
},
|
|
12
|
+
guide: {
|
|
13
|
+
label: 'Guide',
|
|
14
|
+
subEntry: {
|
|
15
|
+
'getting-started': {
|
|
16
|
+
label: 'Getting Started',
|
|
17
|
+
href: '/docs/guide/getting-started',
|
|
18
|
+
},
|
|
19
|
+
advanced: {
|
|
20
|
+
label: 'Advanced',
|
|
21
|
+
href: '/docs/guide/advanced',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
api: {
|
|
26
|
+
label: 'API Reference',
|
|
27
|
+
href: '/docs/api',
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const createDocs = (): DocsData[] => [
|
|
32
|
+
{
|
|
33
|
+
id: 'intro.md',
|
|
34
|
+
title: 'Introduction',
|
|
35
|
+
path: '/docs/intro',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'guide/getting-started.md',
|
|
39
|
+
title: 'Getting Started',
|
|
40
|
+
path: '/docs/guide/getting-started',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'guide/advanced.md',
|
|
44
|
+
title: 'Advanced',
|
|
45
|
+
path: '/docs/guide/advanced',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'api.md',
|
|
49
|
+
title: 'API Reference',
|
|
50
|
+
path: '/docs/api',
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
it('should return prev and next for a middle page', () => {
|
|
55
|
+
const sidebar = createSidebar()
|
|
56
|
+
const docs = createDocs()
|
|
57
|
+
const pagination = getPaginationInfo(
|
|
58
|
+
'/docs/guide/getting-started',
|
|
59
|
+
sidebar,
|
|
60
|
+
docs,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
expect(pagination.prev).toEqual({
|
|
64
|
+
title: 'Introduction',
|
|
65
|
+
href: '/docs/intro',
|
|
66
|
+
})
|
|
67
|
+
expect(pagination.next).toEqual({
|
|
68
|
+
title: 'Advanced',
|
|
69
|
+
href: '/docs/guide/advanced',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should return only next for first page', () => {
|
|
74
|
+
const sidebar = createSidebar()
|
|
75
|
+
const docs = createDocs()
|
|
76
|
+
const pagination = getPaginationInfo('/docs/intro', sidebar, docs)
|
|
77
|
+
|
|
78
|
+
expect(pagination.prev).toBeUndefined()
|
|
79
|
+
expect(pagination.next).toEqual({
|
|
80
|
+
title: 'Getting Started',
|
|
81
|
+
href: '/docs/guide/getting-started',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should return only prev for last page', () => {
|
|
86
|
+
const sidebar = createSidebar()
|
|
87
|
+
const docs = createDocs()
|
|
88
|
+
const pagination = getPaginationInfo('/docs/api', sidebar, docs)
|
|
89
|
+
|
|
90
|
+
expect(pagination.prev).toEqual({
|
|
91
|
+
title: 'Advanced',
|
|
92
|
+
href: '/docs/guide/advanced',
|
|
93
|
+
})
|
|
94
|
+
expect(pagination.next).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should return empty object for page not in sidebar', () => {
|
|
98
|
+
const sidebar = createSidebar()
|
|
99
|
+
const docs = createDocs()
|
|
100
|
+
const pagination = getPaginationInfo('/docs/unknown', sidebar, docs)
|
|
101
|
+
|
|
102
|
+
expect(pagination).toEqual({})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should respect pagination_next override', () => {
|
|
106
|
+
const sidebar = createSidebar()
|
|
107
|
+
const docs: DocsData[] = [
|
|
108
|
+
{
|
|
109
|
+
id: 'intro.md',
|
|
110
|
+
title: 'Introduction',
|
|
111
|
+
path: '/docs/intro',
|
|
112
|
+
pagination_next: 'api.md', // Skip directly to api
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'guide/getting-started.md',
|
|
116
|
+
title: 'Getting Started',
|
|
117
|
+
path: '/docs/guide/getting-started',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'guide/advanced.md',
|
|
121
|
+
title: 'Advanced',
|
|
122
|
+
path: '/docs/guide/advanced',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'api.md',
|
|
126
|
+
title: 'API Reference',
|
|
127
|
+
path: '/docs/api',
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
const pagination = getPaginationInfo('/docs/intro', sidebar, docs)
|
|
132
|
+
|
|
133
|
+
expect(pagination.prev).toBeUndefined()
|
|
134
|
+
expect(pagination.next).toEqual({
|
|
135
|
+
title: 'API Reference',
|
|
136
|
+
href: '/docs/api',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should respect pagination_prev override', () => {
|
|
141
|
+
const sidebar = createSidebar()
|
|
142
|
+
const docs: DocsData[] = [
|
|
143
|
+
{
|
|
144
|
+
id: 'intro.md',
|
|
145
|
+
title: 'Introduction',
|
|
146
|
+
path: '/docs/intro',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'guide/getting-started.md',
|
|
150
|
+
title: 'Getting Started',
|
|
151
|
+
path: '/docs/guide/getting-started',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'guide/advanced.md',
|
|
155
|
+
title: 'Advanced',
|
|
156
|
+
path: '/docs/guide/advanced',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'api.md',
|
|
160
|
+
title: 'API Reference',
|
|
161
|
+
path: '/docs/api',
|
|
162
|
+
pagination_prev: 'intro.md', // Skip back to intro
|
|
163
|
+
},
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
const pagination = getPaginationInfo('/docs/api', sidebar, docs)
|
|
167
|
+
|
|
168
|
+
expect(pagination.prev).toEqual({
|
|
169
|
+
title: 'Introduction',
|
|
170
|
+
href: '/docs/intro',
|
|
171
|
+
})
|
|
172
|
+
expect(pagination.next).toBeUndefined()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should disable next pagination when pagination_next is null', () => {
|
|
176
|
+
const sidebar = createSidebar()
|
|
177
|
+
const docs: DocsData[] = [
|
|
178
|
+
{
|
|
179
|
+
id: 'intro.md',
|
|
180
|
+
title: 'Introduction',
|
|
181
|
+
path: '/docs/intro',
|
|
182
|
+
pagination_next: null, // Explicitly disable
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'guide/getting-started.md',
|
|
186
|
+
title: 'Getting Started',
|
|
187
|
+
path: '/docs/guide/getting-started',
|
|
188
|
+
},
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
const pagination = getPaginationInfo('/docs/intro', sidebar, docs)
|
|
192
|
+
|
|
193
|
+
expect(pagination.prev).toBeUndefined()
|
|
194
|
+
expect(pagination.next).toBeUndefined()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('should disable prev pagination when pagination_prev is null', () => {
|
|
198
|
+
const sidebar = createSidebar()
|
|
199
|
+
const docs: DocsData[] = [
|
|
200
|
+
{
|
|
201
|
+
id: 'intro.md',
|
|
202
|
+
title: 'Introduction',
|
|
203
|
+
path: '/docs/intro',
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: 'guide/getting-started.md',
|
|
207
|
+
title: 'Getting Started',
|
|
208
|
+
path: '/docs/guide/getting-started',
|
|
209
|
+
pagination_prev: null, // Explicitly disable
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
const pagination = getPaginationInfo(
|
|
214
|
+
'/docs/guide/getting-started',
|
|
215
|
+
sidebar,
|
|
216
|
+
docs,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
expect(pagination.prev).toBeUndefined()
|
|
220
|
+
expect(pagination.next).toEqual({
|
|
221
|
+
title: 'Advanced',
|
|
222
|
+
href: '/docs/guide/advanced',
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should disable all pagination when both are null', () => {
|
|
227
|
+
const sidebar = createSidebar()
|
|
228
|
+
const docs: DocsData[] = [
|
|
229
|
+
{
|
|
230
|
+
id: 'guide/getting-started.md',
|
|
231
|
+
title: 'Getting Started',
|
|
232
|
+
path: '/docs/guide/getting-started',
|
|
233
|
+
pagination_next: null,
|
|
234
|
+
pagination_prev: null,
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
const pagination = getPaginationInfo(
|
|
239
|
+
'/docs/guide/getting-started',
|
|
240
|
+
sidebar,
|
|
241
|
+
docs,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(pagination).toEqual({})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should use sidebarLabel when available', () => {
|
|
248
|
+
const sidebar = createSidebar()
|
|
249
|
+
const docs: DocsData[] = [
|
|
250
|
+
{
|
|
251
|
+
id: 'intro.md',
|
|
252
|
+
title: 'Introduction',
|
|
253
|
+
path: '/docs/intro',
|
|
254
|
+
sidebarLabel: 'Intro', // Custom label
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'guide/getting-started.md',
|
|
258
|
+
title: 'Getting Started',
|
|
259
|
+
path: '/docs/guide/getting-started',
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
const pagination = getPaginationInfo(
|
|
264
|
+
'/docs/guide/getting-started',
|
|
265
|
+
sidebar,
|
|
266
|
+
docs,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
expect(pagination.prev).toEqual({
|
|
270
|
+
title: 'Introduction',
|
|
271
|
+
href: '/docs/intro',
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should handle nested sidebar structure correctly', () => {
|
|
276
|
+
const sidebar: Entry = {
|
|
277
|
+
guide: {
|
|
278
|
+
label: 'Guide',
|
|
279
|
+
subEntry: {
|
|
280
|
+
basics: {
|
|
281
|
+
label: 'Basics',
|
|
282
|
+
subEntry: {
|
|
283
|
+
'page-1': {
|
|
284
|
+
label: 'Page 1',
|
|
285
|
+
href: '/docs/guide/basics/page-1',
|
|
286
|
+
},
|
|
287
|
+
'page-2': {
|
|
288
|
+
label: 'Page 2',
|
|
289
|
+
href: '/docs/guide/basics/page-2',
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
advanced: {
|
|
294
|
+
label: 'Advanced',
|
|
295
|
+
subEntry: {
|
|
296
|
+
'page-3': {
|
|
297
|
+
label: 'Page 3',
|
|
298
|
+
href: '/docs/guide/advanced/page-3',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const docs: DocsData[] = [
|
|
307
|
+
{
|
|
308
|
+
id: 'guide/basics/page-1.md',
|
|
309
|
+
title: 'Page 1',
|
|
310
|
+
path: '/docs/guide/basics/page-1',
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'guide/basics/page-2.md',
|
|
314
|
+
title: 'Page 2',
|
|
315
|
+
path: '/docs/guide/basics/page-2',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: 'guide/advanced/page-3.md',
|
|
319
|
+
title: 'Page 3',
|
|
320
|
+
path: '/docs/guide/advanced/page-3',
|
|
321
|
+
},
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
const pagination = getPaginationInfo(
|
|
325
|
+
'/docs/guide/basics/page-2',
|
|
326
|
+
sidebar,
|
|
327
|
+
docs,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
expect(pagination.prev).toEqual({
|
|
331
|
+
title: 'Page 1',
|
|
332
|
+
href: '/docs/guide/basics/page-1',
|
|
333
|
+
})
|
|
334
|
+
expect(pagination.next).toEqual({
|
|
335
|
+
title: 'Page 3',
|
|
336
|
+
href: '/docs/guide/advanced/page-3',
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('should handle invalid pagination_next reference gracefully', () => {
|
|
341
|
+
const sidebar = createSidebar()
|
|
342
|
+
const docs: DocsData[] = [
|
|
343
|
+
{
|
|
344
|
+
id: 'intro.md',
|
|
345
|
+
title: 'Introduction',
|
|
346
|
+
path: '/docs/intro',
|
|
347
|
+
pagination_next: 'nonexistent.md', // Invalid reference
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: 'guide/getting-started.md',
|
|
351
|
+
title: 'Getting Started',
|
|
352
|
+
path: '/docs/guide/getting-started',
|
|
353
|
+
},
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
const pagination = getPaginationInfo('/docs/intro', sidebar, docs)
|
|
357
|
+
|
|
358
|
+
// Should not have a next link because the reference is invalid
|
|
359
|
+
expect(pagination.next).toBeUndefined()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('should handle invalid pagination_prev reference gracefully', () => {
|
|
363
|
+
const sidebar = createSidebar()
|
|
364
|
+
const docs: DocsData[] = [
|
|
365
|
+
{
|
|
366
|
+
id: 'intro.md',
|
|
367
|
+
title: 'Introduction',
|
|
368
|
+
path: '/docs/intro',
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'guide/getting-started.md',
|
|
372
|
+
title: 'Getting Started',
|
|
373
|
+
path: '/docs/guide/getting-started',
|
|
374
|
+
pagination_prev: 'nonexistent.md', // Invalid reference
|
|
375
|
+
},
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
const pagination = getPaginationInfo(
|
|
379
|
+
'/docs/guide/getting-started',
|
|
380
|
+
sidebar,
|
|
381
|
+
docs,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
// Should not have a prev link because the reference is invalid
|
|
385
|
+
expect(pagination.prev).toBeUndefined()
|
|
386
|
+
})
|
|
387
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { Entry } from '@levino/shipyard-base'
|
|
2
|
+
import type { DocsData } from './sidebarEntries'
|
|
3
|
+
|
|
4
|
+
export interface PaginationLink {
|
|
5
|
+
title: string
|
|
6
|
+
href: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PaginationInfo {
|
|
10
|
+
prev?: PaginationLink
|
|
11
|
+
next?: PaginationLink
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FlattenedEntry {
|
|
15
|
+
title: string
|
|
16
|
+
href: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes a path by removing trailing slashes (except for root path).
|
|
21
|
+
* This ensures consistent path comparison regardless of trailing slash variations.
|
|
22
|
+
*/
|
|
23
|
+
const normalizePath = (path: string): string => {
|
|
24
|
+
if (path === '/') return path
|
|
25
|
+
return path.replace(/\/+$/, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Flattens a hierarchical sidebar entry structure into an ordered array of pages.
|
|
30
|
+
* This preserves the order in which pages appear in the sidebar, including nested items.
|
|
31
|
+
*
|
|
32
|
+
* @param entries - The hierarchical sidebar entries
|
|
33
|
+
* @returns An array of flattened entries with title and href
|
|
34
|
+
*/
|
|
35
|
+
const flattenSidebarEntries = (entries: Entry): FlattenedEntry[] => {
|
|
36
|
+
const result: FlattenedEntry[] = []
|
|
37
|
+
|
|
38
|
+
const traverse = (entryRecord: Entry) => {
|
|
39
|
+
for (const [_key, value] of Object.entries(entryRecord)) {
|
|
40
|
+
// Add the current entry if it has an href (i.e., it's a page, not just a category)
|
|
41
|
+
if (value.href && value.label) {
|
|
42
|
+
result.push({
|
|
43
|
+
title: value.label,
|
|
44
|
+
href: value.href,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Recursively traverse children
|
|
49
|
+
if (value.subEntry) {
|
|
50
|
+
traverse(value.subEntry)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
traverse(entries)
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Finds the previous and next pages for a given document based on its position
|
|
61
|
+
* in the sidebar navigation.
|
|
62
|
+
*
|
|
63
|
+
* @param currentPath - The path of the current page (e.g., '/docs/getting-started')
|
|
64
|
+
* @param sidebarEntries - The hierarchical sidebar entries
|
|
65
|
+
* @param allDocs - All documentation pages (for looking up frontmatter overrides)
|
|
66
|
+
* @returns An object containing prev and/or next pagination links
|
|
67
|
+
*/
|
|
68
|
+
export const getPaginationInfo = (
|
|
69
|
+
currentPath: string,
|
|
70
|
+
sidebarEntries: Entry,
|
|
71
|
+
allDocs: readonly DocsData[],
|
|
72
|
+
): PaginationInfo => {
|
|
73
|
+
// Normalize the current path to handle trailing slash variations
|
|
74
|
+
const normalizedCurrentPath = normalizePath(currentPath)
|
|
75
|
+
|
|
76
|
+
// Find the current doc to check for pagination overrides
|
|
77
|
+
const currentDoc = allDocs.find(
|
|
78
|
+
(doc) => normalizePath(doc.path) === normalizedCurrentPath,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
// Check for explicit pagination overrides in frontmatter
|
|
82
|
+
const paginationNext = currentDoc?.['pagination_next' as keyof DocsData]
|
|
83
|
+
const paginationPrev = currentDoc?.['pagination_prev' as keyof DocsData]
|
|
84
|
+
|
|
85
|
+
// If explicitly disabled (null), return empty pagination
|
|
86
|
+
if (paginationNext === null && paginationPrev === null) {
|
|
87
|
+
return {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Flatten the sidebar to get ordered list of pages
|
|
91
|
+
const flatPages = flattenSidebarEntries(sidebarEntries)
|
|
92
|
+
|
|
93
|
+
// Find current page index (using normalized paths for comparison)
|
|
94
|
+
const currentIndex = flatPages.findIndex(
|
|
95
|
+
(page) => normalizePath(page.href) === normalizedCurrentPath,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Check if this is an index/landing page not in the sidebar
|
|
99
|
+
// Index pages are identified by having a doc but not being in the sidebar.
|
|
100
|
+
// They typically have a path like /en/docs or /docs (the base docs path).
|
|
101
|
+
// We detect this by checking if the current doc exists but isn't in flatPages.
|
|
102
|
+
// Additionally, check if the doc ID matches common index patterns:
|
|
103
|
+
// - Just a locale (e.g., 'en' from 'en/index.md')
|
|
104
|
+
// - Empty or 'index'
|
|
105
|
+
const docIdParts = currentDoc?.id?.split('/') ?? []
|
|
106
|
+
const lastIdPart = docIdParts[docIdParts.length - 1]
|
|
107
|
+
const isIndexPage =
|
|
108
|
+
currentIndex === -1 &&
|
|
109
|
+
currentDoc &&
|
|
110
|
+
// Doc exists but not in sidebar - could be an index page
|
|
111
|
+
// Check if the doc ID suggests it's an index (locale-only ID, 'index', or empty after locale)
|
|
112
|
+
(docIdParts.length === 1 || lastIdPart === 'index' || lastIdPart === '')
|
|
113
|
+
|
|
114
|
+
if (currentIndex === -1 && !isIndexPage) {
|
|
115
|
+
// Current page not found in sidebar and not an index page - no pagination
|
|
116
|
+
return {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const result: PaginationInfo = {}
|
|
120
|
+
|
|
121
|
+
// Special handling for index pages: they come before all sidebar items
|
|
122
|
+
if (isIndexPage) {
|
|
123
|
+
// Index page has no previous, and first sidebar item as next
|
|
124
|
+
if (paginationPrev !== null && typeof paginationPrev === 'string') {
|
|
125
|
+
const targetDoc = allDocs.find((doc) => doc.id === paginationPrev)
|
|
126
|
+
if (targetDoc?.path) {
|
|
127
|
+
result.prev = {
|
|
128
|
+
title: targetDoc.sidebarLabel ?? targetDoc.title,
|
|
129
|
+
href: targetDoc.path,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// No prev for index page (unless explicitly set above)
|
|
134
|
+
|
|
135
|
+
if (paginationNext === null) {
|
|
136
|
+
// Explicitly disabled
|
|
137
|
+
result.next = undefined
|
|
138
|
+
} else if (typeof paginationNext === 'string') {
|
|
139
|
+
const targetDoc = allDocs.find((doc) => doc.id === paginationNext)
|
|
140
|
+
if (targetDoc?.path) {
|
|
141
|
+
result.next = {
|
|
142
|
+
title: targetDoc.sidebarLabel ?? targetDoc.title,
|
|
143
|
+
href: targetDoc.path,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else if (flatPages.length > 0) {
|
|
147
|
+
// Use the first sidebar item as next
|
|
148
|
+
result.next = flatPages[0]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Handle previous page
|
|
155
|
+
if (paginationPrev === null) {
|
|
156
|
+
// Explicitly disabled
|
|
157
|
+
result.prev = undefined
|
|
158
|
+
} else if (typeof paginationPrev === 'string') {
|
|
159
|
+
// Explicitly set to a specific page ID
|
|
160
|
+
const targetDoc = allDocs.find((doc) => doc.id === paginationPrev)
|
|
161
|
+
if (targetDoc?.path) {
|
|
162
|
+
result.prev = {
|
|
163
|
+
title: targetDoc.sidebarLabel ?? targetDoc.title,
|
|
164
|
+
href: targetDoc.path,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (currentIndex > 0) {
|
|
168
|
+
// Use the previous page in sidebar order
|
|
169
|
+
result.prev = flatPages[currentIndex - 1]
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Handle next page
|
|
173
|
+
if (paginationNext === null) {
|
|
174
|
+
// Explicitly disabled
|
|
175
|
+
result.next = undefined
|
|
176
|
+
} else if (typeof paginationNext === 'string') {
|
|
177
|
+
// Explicitly set to a specific page ID
|
|
178
|
+
const targetDoc = allDocs.find((doc) => doc.id === paginationNext)
|
|
179
|
+
if (targetDoc?.path) {
|
|
180
|
+
result.next = {
|
|
181
|
+
title: targetDoc.sidebarLabel ?? targetDoc.title,
|
|
182
|
+
href: targetDoc.path,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} else if (currentIndex < flatPages.length - 1) {
|
|
186
|
+
// Use the next page in sidebar order
|
|
187
|
+
result.next = flatPages[currentIndex + 1]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
}
|