@levino/shipyard-docs 0.5.2 → 0.6.0-rc-20260106213612

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/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import type { AstroIntegration } from 'astro'
4
4
  import { glob } from 'astro/loaders'
5
5
  import { z } from 'astro/zod'
6
6
 
7
+ // Re-export fallback utilities
8
+ export { extractFirstParagraph } from './fallbacks'
7
9
  // Re-export git metadata utilities
8
10
  export type { GitMetadata } from './gitMetadata'
9
11
  export { getEditUrl, getGitMetadata } from './gitMetadata'
@@ -20,37 +22,150 @@ export { getDocPath, getRouteParams } from './routeHelpers'
20
22
  export type { DocsData } from './sidebarEntries'
21
23
  export { toSidebarEntries } from './sidebarEntries'
22
24
 
23
- export const docsSchema = z.object({
24
- sidebar: z
25
- .object({
26
- render: z.boolean().default(true),
27
- label: z.string().optional(),
28
- })
29
- .default({ render: true }),
30
- title: z.string().optional(),
31
- description: z.string().optional(),
32
- sidebar_position: z.number().optional(),
33
- sidebar_label: z.string().optional(),
34
- sidebar_class_name: z.string().optional(),
35
- sidebar_custom_props: z.record(z.any()).optional(),
36
- pagination_next: z.string().nullable().optional(),
37
- pagination_prev: z.string().nullable().optional(),
38
- /**
39
- * Override the last update author for this specific page.
40
- * Set to false to hide the author for this page.
41
- */
42
- last_update_author: z.union([z.string(), z.literal(false)]).optional(),
43
- /**
44
- * Override the last update timestamp for this specific page.
45
- * Set to false to hide the timestamp for this page.
46
- */
47
- last_update_time: z.union([z.coerce.date(), z.literal(false)]).optional(),
48
- /**
49
- * Custom edit URL for this specific page.
50
- * Set to null to disable edit link for this page.
51
- */
52
- custom_edit_url: z.string().nullable().optional(),
53
- })
25
+ /**
26
+ * Schema for sidebar configuration grouped under a single object.
27
+ * Contains all sidebar-related fields for categories and pages.
28
+ */
29
+ const sidebarSchema = z
30
+ .object({
31
+ /** Sort order in sidebar (default: Infinity - sorted alphabetically after positioned items) */
32
+ position: z.number().optional(),
33
+ /** Display label in sidebar (default: title -> H1 -> filename) */
34
+ label: z.string().optional(),
35
+ /** CSS class(es) for styling the sidebar entry */
36
+ className: z.string().optional(),
37
+ /** Arbitrary metadata for custom sidebar components */
38
+ customProps: z.record(z.any()).optional(),
39
+ /** Can category be collapsed (default: true) */
40
+ collapsible: z.boolean().default(true),
41
+ /** Start collapsed (default: true) */
42
+ collapsed: z.boolean().default(true),
43
+ })
44
+ .refine((data) => !(data.collapsed === true && data.collapsible === false), {
45
+ message:
46
+ 'sidebar.collapsed cannot be true when sidebar.collapsible is false',
47
+ })
48
+
49
+ export const docsSchema = z
50
+ .object({
51
+ // === Page Metadata ===
52
+ /** Custom document ID (default: file path) */
53
+ id: z.string().optional(),
54
+ /** Reference title for SEO, pagination, previews (default: H1) */
55
+ title: z.string().optional(),
56
+ /** Override title for SEO/browser tab (default: title) - Docusaurus snake_case convention */
57
+ title_meta: z.string().optional(),
58
+ /** Meta description (default: first paragraph) */
59
+ description: z.string().optional(),
60
+ /** SEO keywords */
61
+ keywords: z.array(z.string()).optional(),
62
+ /** Social preview image (og:image) */
63
+ image: z.string().optional(),
64
+ /** Custom canonical URL */
65
+ canonicalUrl: z.string().optional(),
66
+ /** Custom canonical URL - snake_case alias for Docusaurus compatibility */
67
+ canonical_url: z.string().optional(),
68
+
69
+ // === Page Rendering ===
70
+ /** Whether to render a page (default: true) */
71
+ render: z.boolean().default(true),
72
+ /** Exclude from production builds (default: false) */
73
+ draft: z.boolean().default(false),
74
+ /** Render page but hide from sidebar (default: false) */
75
+ unlisted: z.boolean().default(false),
76
+ /** Custom URL slug */
77
+ slug: z.string().optional(),
78
+
79
+ // === Layout Options ===
80
+ /** Hide the H1 heading (default: false) */
81
+ hideTitle: z.boolean().default(false),
82
+ /** Hide the H1 heading (default: false) - snake_case alias for Docusaurus compatibility */
83
+ hide_title: z.boolean().optional(),
84
+ /** Hide the TOC (default: false) */
85
+ hideTableOfContents: z.boolean().default(false),
86
+ /** Hide the TOC (default: false) - snake_case alias for Docusaurus compatibility */
87
+ hide_table_of_contents: z.boolean().optional(),
88
+ /** Full-width page without sidebar (default: false) */
89
+ hideSidebar: z.boolean().default(false),
90
+ /** Min heading level in TOC (default: 2) */
91
+ tocMinHeadingLevel: z.number().min(1).max(6).default(2),
92
+ /** Max heading level in TOC (default: 3) */
93
+ tocMaxHeadingLevel: z.number().min(1).max(6).default(3),
94
+
95
+ // === Sidebar Configuration (grouped) ===
96
+ sidebar: sidebarSchema.default({ collapsible: true, collapsed: true }),
97
+
98
+ // === Pagination ===
99
+ /** Label shown in prev/next buttons */
100
+ paginationLabel: z.string().optional(),
101
+ /** Label shown in prev/next buttons - snake_case alias for Docusaurus compatibility */
102
+ pagination_label: z.string().optional(),
103
+ /** Next page ID, or null to disable */
104
+ paginationNext: z.string().nullable().optional(),
105
+ /** Next page ID - snake_case alias for Docusaurus compatibility */
106
+ pagination_next: z.string().nullable().optional(),
107
+ /** Previous page ID, or null to disable */
108
+ paginationPrev: z.string().nullable().optional(),
109
+ /** Previous page ID - snake_case alias for Docusaurus compatibility */
110
+ pagination_prev: z.string().nullable().optional(),
111
+
112
+ // === Git Metadata Overrides ===
113
+ /**
114
+ * Override the last update author for this specific page.
115
+ * Set to false to hide the author for this page.
116
+ */
117
+ lastUpdateAuthor: z.union([z.string(), z.literal(false)]).optional(),
118
+ /**
119
+ * Override the last update timestamp for this specific page.
120
+ * Set to false to hide the timestamp for this page.
121
+ */
122
+ lastUpdateTime: z.union([z.literal(false), z.coerce.date()]).optional(),
123
+ /**
124
+ * Custom edit URL for this specific page.
125
+ * Set to null to disable edit link for this page.
126
+ */
127
+ customEditUrl: z.string().nullable().optional(),
128
+
129
+ // === Custom Meta Tags ===
130
+ customMetaTags: z
131
+ .array(
132
+ z.object({
133
+ name: z.string().optional(),
134
+ property: z.string().optional(),
135
+ content: z.string(),
136
+ }),
137
+ )
138
+ .optional(),
139
+ /** Custom meta tags - snake_case alias for Docusaurus compatibility */
140
+ custom_meta_tags: z
141
+ .array(
142
+ z.object({
143
+ name: z.string().optional(),
144
+ property: z.string().optional(),
145
+ content: z.string(),
146
+ }),
147
+ )
148
+ .optional(),
149
+ })
150
+ .transform((data) => ({
151
+ ...data,
152
+ // Merge snake_case aliases into camelCase fields
153
+ hideTitle: data.hide_title ?? data.hideTitle,
154
+ hideTableOfContents:
155
+ data.hide_table_of_contents ?? data.hideTableOfContents,
156
+ canonicalUrl: data.canonical_url ?? data.canonicalUrl,
157
+ customMetaTags: data.custom_meta_tags ?? data.customMetaTags,
158
+ paginationLabel: data.pagination_label ?? data.paginationLabel,
159
+ // For nullable fields, we need to check if the snake_case key exists (not just use ??)
160
+ // because null ?? undefined = undefined, but we want null to be preserved
161
+ paginationNext:
162
+ 'pagination_next' in data ? data.pagination_next : data.paginationNext,
163
+ paginationPrev:
164
+ 'pagination_prev' in data ? data.pagination_prev : data.paginationPrev,
165
+ }))
166
+ .refine((data) => data.tocMinHeadingLevel <= data.tocMaxHeadingLevel, {
167
+ message: 'tocMinHeadingLevel must be <= tocMaxHeadingLevel',
168
+ })
54
169
 
55
170
  /**
56
171
  * Configuration for llms.txt generation.
@@ -276,7 +391,10 @@ import Layout from '@levino/shipyard-docs/astro/Layout.astro'
276
391
  export async function getStaticPaths() {
277
392
  const collectionName = ${JSON.stringify(resolvedCollectionName)}
278
393
  const routeBasePath = ${JSON.stringify(normalizedBasePath)}
279
- const docs = await getCollection(collectionName)
394
+ const allDocs = await getCollection(collectionName)
395
+
396
+ // Filter out pages with render: false - they should not generate pages
397
+ const docs = allDocs.filter((doc) => doc.data.render !== false)
280
398
 
281
399
  const getParams = (slug) => {
282
400
  if (i18n) {
@@ -309,13 +427,13 @@ const docsConfig = docsConfigs[routeBasePath] ?? {
309
427
 
310
428
  const { Content, headings } = await render(entry)
311
429
 
312
- const { custom_edit_url, last_update_author, last_update_time } = entry.data
430
+ const { customEditUrl, lastUpdateAuthor, lastUpdateTime, hideTableOfContents, hideTitle, keywords, image, canonicalUrl, customMetaTags, title_meta: titleMeta } = entry.data
313
431
 
314
432
  let editUrl
315
- if (custom_edit_url === null) {
433
+ if (customEditUrl === null) {
316
434
  editUrl = undefined
317
- } else if (custom_edit_url) {
318
- editUrl = custom_edit_url
435
+ } else if (customEditUrl) {
436
+ editUrl = customEditUrl
319
437
  } else {
320
438
  editUrl = getEditUrl(docsConfig.editUrl, entry.id)
321
439
  }
@@ -324,32 +442,32 @@ let lastUpdated
324
442
  let lastAuthor
325
443
 
326
444
  if (
327
- (docsConfig.showLastUpdateTime && last_update_time !== false) ||
328
- (docsConfig.showLastUpdateAuthor && last_update_author !== false)
445
+ (docsConfig.showLastUpdateTime && lastUpdateTime !== false) ||
446
+ (docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false)
329
447
  ) {
330
448
  const filePath = entry.filePath
331
449
 
332
450
  if (filePath) {
333
451
  const gitMetadata = getGitMetadata(filePath)
334
452
 
335
- if (docsConfig.showLastUpdateTime && last_update_time !== false) {
453
+ if (docsConfig.showLastUpdateTime && lastUpdateTime !== false) {
336
454
  lastUpdated =
337
- last_update_time instanceof Date
338
- ? last_update_time
455
+ lastUpdateTime instanceof Date
456
+ ? lastUpdateTime
339
457
  : gitMetadata.lastUpdated
340
458
  }
341
459
 
342
- if (docsConfig.showLastUpdateAuthor && last_update_author !== false) {
460
+ if (docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false) {
343
461
  lastAuthor =
344
- typeof last_update_author === 'string'
345
- ? last_update_author
462
+ typeof lastUpdateAuthor === 'string'
463
+ ? lastUpdateAuthor
346
464
  : gitMetadata.lastAuthor
347
465
  }
348
466
  }
349
467
  }
350
468
  ---
351
469
 
352
- <Layout headings={headings} routeBasePath={routeBasePath} editUrl={editUrl} lastUpdated={lastUpdated} lastAuthor={lastAuthor}>
470
+ <Layout headings={headings} routeBasePath={routeBasePath} editUrl={editUrl} lastUpdated={lastUpdated} lastAuthor={lastAuthor} hideTableOfContents={hideTableOfContents} hideTitle={hideTitle} keywords={keywords} image={image} canonicalUrl={canonicalUrl} customMetaTags={customMetaTags} titleMeta={titleMeta}>
353
471
  <Content />
354
472
  </Layout>
355
473
  `
@@ -424,10 +542,13 @@ export const getStaticPaths: GetStaticPaths = async () => {
424
542
 
425
543
  // When i18n is enabled, only include docs from the default locale
426
544
  const defaultLocale = i18n?.defaultLocale
427
- const docs = defaultLocale
545
+ const localeDocs = defaultLocale
428
546
  ? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
429
547
  : allDocs
430
548
 
549
+ // Filter out unlisted and non-rendered pages
550
+ const docs = localeDocs.filter((doc) => !doc.data.unlisted && doc.data.render !== false)
551
+
431
552
  return docs.map((doc) => {
432
553
  const cleanId = doc.id.replace(/\\.md$/, '')
433
554
  // For i18n, strip the locale prefix from the slug
@@ -491,10 +612,13 @@ export const GET: APIRoute = async ({ site }) => {
491
612
 
492
613
  // When i18n is enabled, only include docs from the default locale
493
614
  const defaultLocale = i18n?.defaultLocale
494
- const docs = defaultLocale
615
+ const localeDocs = defaultLocale
495
616
  ? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
496
617
  : allDocs
497
618
 
619
+ // Filter out unlisted and non-rendered pages
620
+ const docs = localeDocs.filter((doc) => !doc.data.unlisted && doc.data.render !== false)
621
+
498
622
  const entries = await Promise.all(
499
623
  docs.map(async (doc) => {
500
624
  const { headings } = await render(doc)
@@ -521,7 +645,7 @@ export const GET: APIRoute = async ({ site }) => {
521
645
  path,
522
646
  title: doc.data.title ?? h1?.text ?? doc.id,
523
647
  description: doc.data.description,
524
- position: doc.data.sidebar_position,
648
+ position: doc.data.sidebar?.position,
525
649
  }
526
650
  })
527
651
  )
@@ -556,10 +680,13 @@ export const GET: APIRoute = async ({ site }) => {
556
680
 
557
681
  // When i18n is enabled, only include docs from the default locale
558
682
  const defaultLocale = i18n?.defaultLocale
559
- const docs = defaultLocale
683
+ const localeDocs = defaultLocale
560
684
  ? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
561
685
  : allDocs
562
686
 
687
+ // Filter out unlisted and non-rendered pages
688
+ const docs = localeDocs.filter((doc) => !doc.data.unlisted && doc.data.render !== false)
689
+
563
690
  const entries = await Promise.all(
564
691
  docs.map(async (doc) => {
565
692
  const { headings } = await render(doc)
@@ -589,7 +716,7 @@ export const GET: APIRoute = async ({ site }) => {
589
716
  path,
590
717
  title: doc.data.title ?? h1?.text ?? doc.id,
591
718
  description: doc.data.description,
592
- position: doc.data.sidebar_position,
719
+ position: doc.data.sidebar?.position,
593
720
  content: rawContent,
594
721
  }
595
722
  })
@@ -31,21 +31,25 @@ describe('getPaginationInfo', () => {
31
31
  const createDocs = (): DocsData[] => [
32
32
  {
33
33
  id: 'intro.md',
34
+ fileId: 'intro.md',
34
35
  title: 'Introduction',
35
36
  path: '/docs/intro',
36
37
  },
37
38
  {
38
39
  id: 'guide/getting-started.md',
40
+ fileId: 'guide/getting-started.md',
39
41
  title: 'Getting Started',
40
42
  path: '/docs/guide/getting-started',
41
43
  },
42
44
  {
43
45
  id: 'guide/advanced.md',
46
+ fileId: 'guide/advanced.md',
44
47
  title: 'Advanced',
45
48
  path: '/docs/guide/advanced',
46
49
  },
47
50
  {
48
51
  id: 'api.md',
52
+ fileId: 'api.md',
49
53
  title: 'API Reference',
50
54
  path: '/docs/api',
51
55
  },
@@ -102,27 +106,31 @@ describe('getPaginationInfo', () => {
102
106
  expect(pagination).toEqual({})
103
107
  })
104
108
 
105
- it('should respect pagination_next override', () => {
109
+ it('should respect paginationNext override', () => {
106
110
  const sidebar = createSidebar()
107
111
  const docs: DocsData[] = [
108
112
  {
109
113
  id: 'intro.md',
114
+ fileId: 'intro.md',
110
115
  title: 'Introduction',
111
116
  path: '/docs/intro',
112
- pagination_next: 'api.md', // Skip directly to api
117
+ paginationNext: 'api.md', // Skip directly to api
113
118
  },
114
119
  {
115
120
  id: 'guide/getting-started.md',
121
+ fileId: 'guide/getting-started.md',
116
122
  title: 'Getting Started',
117
123
  path: '/docs/guide/getting-started',
118
124
  },
119
125
  {
120
126
  id: 'guide/advanced.md',
127
+ fileId: 'guide/advanced.md',
121
128
  title: 'Advanced',
122
129
  path: '/docs/guide/advanced',
123
130
  },
124
131
  {
125
132
  id: 'api.md',
133
+ fileId: 'api.md',
126
134
  title: 'API Reference',
127
135
  path: '/docs/api',
128
136
  },
@@ -137,29 +145,33 @@ describe('getPaginationInfo', () => {
137
145
  })
138
146
  })
139
147
 
140
- it('should respect pagination_prev override', () => {
148
+ it('should respect paginationPrev override', () => {
141
149
  const sidebar = createSidebar()
142
150
  const docs: DocsData[] = [
143
151
  {
144
152
  id: 'intro.md',
153
+ fileId: 'intro.md',
145
154
  title: 'Introduction',
146
155
  path: '/docs/intro',
147
156
  },
148
157
  {
149
158
  id: 'guide/getting-started.md',
159
+ fileId: 'guide/getting-started.md',
150
160
  title: 'Getting Started',
151
161
  path: '/docs/guide/getting-started',
152
162
  },
153
163
  {
154
164
  id: 'guide/advanced.md',
165
+ fileId: 'guide/advanced.md',
155
166
  title: 'Advanced',
156
167
  path: '/docs/guide/advanced',
157
168
  },
158
169
  {
159
170
  id: 'api.md',
171
+ fileId: 'api.md',
160
172
  title: 'API Reference',
161
173
  path: '/docs/api',
162
- pagination_prev: 'intro.md', // Skip back to intro
174
+ paginationPrev: 'intro.md', // Skip back to intro
163
175
  },
164
176
  ]
165
177
 
@@ -172,17 +184,19 @@ describe('getPaginationInfo', () => {
172
184
  expect(pagination.next).toBeUndefined()
173
185
  })
174
186
 
175
- it('should disable next pagination when pagination_next is null', () => {
187
+ it('should disable next pagination when paginationNext is null', () => {
176
188
  const sidebar = createSidebar()
177
189
  const docs: DocsData[] = [
178
190
  {
179
191
  id: 'intro.md',
192
+ fileId: 'intro.md',
180
193
  title: 'Introduction',
181
194
  path: '/docs/intro',
182
- pagination_next: null, // Explicitly disable
195
+ paginationNext: null, // Explicitly disable
183
196
  },
184
197
  {
185
198
  id: 'guide/getting-started.md',
199
+ fileId: 'guide/getting-started.md',
186
200
  title: 'Getting Started',
187
201
  path: '/docs/guide/getting-started',
188
202
  },
@@ -194,19 +208,21 @@ describe('getPaginationInfo', () => {
194
208
  expect(pagination.next).toBeUndefined()
195
209
  })
196
210
 
197
- it('should disable prev pagination when pagination_prev is null', () => {
211
+ it('should disable prev pagination when paginationPrev is null', () => {
198
212
  const sidebar = createSidebar()
199
213
  const docs: DocsData[] = [
200
214
  {
201
215
  id: 'intro.md',
216
+ fileId: 'intro.md',
202
217
  title: 'Introduction',
203
218
  path: '/docs/intro',
204
219
  },
205
220
  {
206
221
  id: 'guide/getting-started.md',
222
+ fileId: 'guide/getting-started.md',
207
223
  title: 'Getting Started',
208
224
  path: '/docs/guide/getting-started',
209
- pagination_prev: null, // Explicitly disable
225
+ paginationPrev: null, // Explicitly disable
210
226
  },
211
227
  ]
212
228
 
@@ -228,10 +244,11 @@ describe('getPaginationInfo', () => {
228
244
  const docs: DocsData[] = [
229
245
  {
230
246
  id: 'guide/getting-started.md',
247
+ fileId: 'guide/getting-started.md',
231
248
  title: 'Getting Started',
232
249
  path: '/docs/guide/getting-started',
233
- pagination_next: null,
234
- pagination_prev: null,
250
+ paginationNext: null,
251
+ paginationPrev: null,
235
252
  },
236
253
  ]
237
254
 
@@ -249,12 +266,14 @@ describe('getPaginationInfo', () => {
249
266
  const docs: DocsData[] = [
250
267
  {
251
268
  id: 'intro.md',
269
+ fileId: 'intro.md',
252
270
  title: 'Introduction',
253
271
  path: '/docs/intro',
254
272
  sidebarLabel: 'Intro', // Custom label
255
273
  },
256
274
  {
257
275
  id: 'guide/getting-started.md',
276
+ fileId: 'guide/getting-started.md',
258
277
  title: 'Getting Started',
259
278
  path: '/docs/guide/getting-started',
260
279
  },
@@ -266,8 +285,39 @@ describe('getPaginationInfo', () => {
266
285
  docs,
267
286
  )
268
287
 
288
+ // sidebarLabel is used for the title, so it should be 'Intro' not 'Introduction'
269
289
  expect(pagination.prev).toEqual({
270
- title: 'Introduction',
290
+ title: 'Intro',
291
+ href: '/docs/intro',
292
+ })
293
+ })
294
+
295
+ it('should use paginationLabel when available', () => {
296
+ const sidebar = createSidebar()
297
+ const docs: DocsData[] = [
298
+ {
299
+ id: 'intro.md',
300
+ title: 'Introduction',
301
+ path: '/docs/intro',
302
+ sidebarLabel: 'Intro',
303
+ paginationLabel: 'Start Here', // Custom pagination label takes priority
304
+ },
305
+ {
306
+ id: 'guide/getting-started.md',
307
+ title: 'Getting Started',
308
+ path: '/docs/guide/getting-started',
309
+ paginationPrev: 'intro.md',
310
+ },
311
+ ]
312
+
313
+ const pagination = getPaginationInfo(
314
+ '/docs/guide/getting-started',
315
+ sidebar,
316
+ docs,
317
+ )
318
+
319
+ expect(pagination.prev).toEqual({
320
+ title: 'Start Here',
271
321
  href: '/docs/intro',
272
322
  })
273
323
  })
@@ -306,16 +356,19 @@ describe('getPaginationInfo', () => {
306
356
  const docs: DocsData[] = [
307
357
  {
308
358
  id: 'guide/basics/page-1.md',
359
+ fileId: 'guide/basics/page-1.md',
309
360
  title: 'Page 1',
310
361
  path: '/docs/guide/basics/page-1',
311
362
  },
312
363
  {
313
364
  id: 'guide/basics/page-2.md',
365
+ fileId: 'guide/basics/page-2.md',
314
366
  title: 'Page 2',
315
367
  path: '/docs/guide/basics/page-2',
316
368
  },
317
369
  {
318
370
  id: 'guide/advanced/page-3.md',
371
+ fileId: 'guide/advanced/page-3.md',
319
372
  title: 'Page 3',
320
373
  path: '/docs/guide/advanced/page-3',
321
374
  },
@@ -337,17 +390,19 @@ describe('getPaginationInfo', () => {
337
390
  })
338
391
  })
339
392
 
340
- it('should handle invalid pagination_next reference gracefully', () => {
393
+ it('should handle invalid paginationNext reference gracefully', () => {
341
394
  const sidebar = createSidebar()
342
395
  const docs: DocsData[] = [
343
396
  {
344
397
  id: 'intro.md',
398
+ fileId: 'intro.md',
345
399
  title: 'Introduction',
346
400
  path: '/docs/intro',
347
- pagination_next: 'nonexistent.md', // Invalid reference
401
+ paginationNext: 'nonexistent.md', // Invalid reference
348
402
  },
349
403
  {
350
404
  id: 'guide/getting-started.md',
405
+ fileId: 'guide/getting-started.md',
351
406
  title: 'Getting Started',
352
407
  path: '/docs/guide/getting-started',
353
408
  },
@@ -359,19 +414,21 @@ describe('getPaginationInfo', () => {
359
414
  expect(pagination.next).toBeUndefined()
360
415
  })
361
416
 
362
- it('should handle invalid pagination_prev reference gracefully', () => {
417
+ it('should handle invalid paginationPrev reference gracefully', () => {
363
418
  const sidebar = createSidebar()
364
419
  const docs: DocsData[] = [
365
420
  {
366
421
  id: 'intro.md',
422
+ fileId: 'intro.md',
367
423
  title: 'Introduction',
368
424
  path: '/docs/intro',
369
425
  },
370
426
  {
371
427
  id: 'guide/getting-started.md',
428
+ fileId: 'guide/getting-started.md',
372
429
  title: 'Getting Started',
373
430
  path: '/docs/guide/getting-started',
374
- pagination_prev: 'nonexistent.md', // Invalid reference
431
+ paginationPrev: 'nonexistent.md', // Invalid reference
375
432
  },
376
433
  ]
377
434
 
@@ -384,4 +441,47 @@ describe('getPaginationInfo', () => {
384
441
  // Should not have a prev link because the reference is invalid
385
442
  expect(pagination.prev).toBeUndefined()
386
443
  })
444
+
445
+ it('should exclude unlisted pages from pagination (via sidebar)', () => {
446
+ // Note: unlisted pages are filtered from sidebar entries before being passed
447
+ // to getPaginationInfo, so they won't appear in pagination
448
+ const sidebar: Entry = {
449
+ intro: {
450
+ label: 'Introduction',
451
+ href: '/docs/intro',
452
+ },
453
+ // Hidden page is NOT in sidebar
454
+ visible: {
455
+ label: 'Visible',
456
+ href: '/docs/visible',
457
+ },
458
+ }
459
+
460
+ const docs: DocsData[] = [
461
+ {
462
+ id: 'intro.md',
463
+ title: 'Introduction',
464
+ path: '/docs/intro',
465
+ },
466
+ {
467
+ id: 'hidden.md',
468
+ title: 'Hidden Page',
469
+ path: '/docs/hidden',
470
+ unlisted: true,
471
+ },
472
+ {
473
+ id: 'visible.md',
474
+ title: 'Visible',
475
+ path: '/docs/visible',
476
+ },
477
+ ]
478
+
479
+ const pagination = getPaginationInfo('/docs/intro', sidebar, docs)
480
+
481
+ // Next should be 'visible', not 'hidden'
482
+ expect(pagination.next).toEqual({
483
+ title: 'Visible',
484
+ href: '/docs/visible',
485
+ })
486
+ })
387
487
  })