@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/pagination.ts CHANGED
@@ -78,9 +78,9 @@ export const getPaginationInfo = (
78
78
  (doc) => normalizePath(doc.path) === normalizedCurrentPath,
79
79
  )
80
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]
81
+ // Check for explicit pagination overrides in frontmatter (using camelCase field names)
82
+ const paginationNext = currentDoc?.paginationNext
83
+ const paginationPrev = currentDoc?.paginationPrev
84
84
 
85
85
  // If explicitly disabled (null), return empty pagination
86
86
  if (paginationNext === null && paginationPrev === null) {
@@ -88,6 +88,7 @@ export const getPaginationInfo = (
88
88
  }
89
89
 
90
90
  // Flatten the sidebar to get ordered list of pages
91
+ // Note: unlisted pages are already excluded from sidebar entries
91
92
  const flatPages = flattenSidebarEntries(sidebarEntries)
92
93
 
93
94
  // Find current page index (using normalized paths for comparison)
@@ -116,16 +117,43 @@ export const getPaginationInfo = (
116
117
  return {}
117
118
  }
118
119
 
120
+ /**
121
+ * Gets the display title for a doc, using paginationLabel if available,
122
+ * then falling back to sidebarLabel, then title.
123
+ */
124
+ const getDisplayTitle = (doc: DocsData): string =>
125
+ doc.paginationLabel ?? doc.sidebarLabel ?? doc.title
126
+
127
+ /**
128
+ * Finds a doc by its ID, checking both the content collection ID and custom frontmatter ID.
129
+ * This supports Docusaurus-style custom document IDs for pagination references.
130
+ */
131
+ const findDocById = (targetId: string): DocsData | undefined =>
132
+ allDocs.find((doc) => doc.id === targetId || doc.customId === targetId)
133
+
134
+ /**
135
+ * Gets the pagination link for a page, using paginationLabel for the title
136
+ * if available in the doc's frontmatter.
137
+ */
138
+ const getPaginationLink = (page: FlattenedEntry): PaginationLink => {
139
+ const normalizedHref = normalizePath(page.href)
140
+ const doc = allDocs.find((d) => normalizePath(d.path) === normalizedHref)
141
+ return {
142
+ title: doc ? getDisplayTitle(doc) : page.title,
143
+ href: page.href,
144
+ }
145
+ }
146
+
119
147
  const result: PaginationInfo = {}
120
148
 
121
149
  // Special handling for index pages: they come before all sidebar items
122
150
  if (isIndexPage) {
123
151
  // Index page has no previous, and first sidebar item as next
124
152
  if (paginationPrev !== null && typeof paginationPrev === 'string') {
125
- const targetDoc = allDocs.find((doc) => doc.id === paginationPrev)
153
+ const targetDoc = findDocById(paginationPrev)
126
154
  if (targetDoc?.path) {
127
155
  result.prev = {
128
- title: targetDoc.sidebarLabel ?? targetDoc.title,
156
+ title: getDisplayTitle(targetDoc),
129
157
  href: targetDoc.path,
130
158
  }
131
159
  }
@@ -136,16 +164,16 @@ export const getPaginationInfo = (
136
164
  // Explicitly disabled
137
165
  result.next = undefined
138
166
  } else if (typeof paginationNext === 'string') {
139
- const targetDoc = allDocs.find((doc) => doc.id === paginationNext)
167
+ const targetDoc = findDocById(paginationNext)
140
168
  if (targetDoc?.path) {
141
169
  result.next = {
142
- title: targetDoc.sidebarLabel ?? targetDoc.title,
170
+ title: getDisplayTitle(targetDoc),
143
171
  href: targetDoc.path,
144
172
  }
145
173
  }
146
174
  } else if (flatPages.length > 0) {
147
175
  // Use the first sidebar item as next
148
- result.next = flatPages[0]
176
+ result.next = getPaginationLink(flatPages[0])
149
177
  }
150
178
 
151
179
  return result
@@ -156,17 +184,17 @@ export const getPaginationInfo = (
156
184
  // Explicitly disabled
157
185
  result.prev = undefined
158
186
  } else if (typeof paginationPrev === 'string') {
159
- // Explicitly set to a specific page ID
160
- const targetDoc = allDocs.find((doc) => doc.id === paginationPrev)
187
+ // Explicitly set to a specific page ID (supports custom frontmatter IDs)
188
+ const targetDoc = findDocById(paginationPrev)
161
189
  if (targetDoc?.path) {
162
190
  result.prev = {
163
- title: targetDoc.sidebarLabel ?? targetDoc.title,
191
+ title: getDisplayTitle(targetDoc),
164
192
  href: targetDoc.path,
165
193
  }
166
194
  }
167
195
  } else if (currentIndex > 0) {
168
196
  // Use the previous page in sidebar order
169
- result.prev = flatPages[currentIndex - 1]
197
+ result.prev = getPaginationLink(flatPages[currentIndex - 1])
170
198
  }
171
199
 
172
200
  // Handle next page
@@ -174,17 +202,17 @@ export const getPaginationInfo = (
174
202
  // Explicitly disabled
175
203
  result.next = undefined
176
204
  } else if (typeof paginationNext === 'string') {
177
- // Explicitly set to a specific page ID
178
- const targetDoc = allDocs.find((doc) => doc.id === paginationNext)
205
+ // Explicitly set to a specific page ID (supports custom frontmatter IDs)
206
+ const targetDoc = findDocById(paginationNext)
179
207
  if (targetDoc?.path) {
180
208
  result.next = {
181
- title: targetDoc.sidebarLabel ?? targetDoc.title,
209
+ title: getDisplayTitle(targetDoc),
182
210
  href: targetDoc.path,
183
211
  }
184
212
  }
185
213
  } else if (currentIndex < flatPages.length - 1) {
186
214
  // Use the next page in sidebar order
187
- result.next = flatPages[currentIndex + 1]
215
+ result.next = getPaginationLink(flatPages[currentIndex + 1])
188
216
  }
189
217
 
190
218
  return result
@@ -0,0 +1,467 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { docsSchema } from './index'
3
+
4
+ describe('docsSchema', () => {
5
+ it('should accept valid sidebar configuration', () => {
6
+ const validData = {
7
+ sidebar: {
8
+ position: 1,
9
+ label: 'Test Label',
10
+ className: 'custom-class',
11
+ customProps: { badge: 'New' },
12
+ collapsible: true,
13
+ collapsed: false,
14
+ },
15
+ }
16
+
17
+ const result = docsSchema.safeParse(validData)
18
+ expect(result.success).toBe(true)
19
+ if (result.success) {
20
+ expect(result.data.sidebar.position).toBe(1)
21
+ expect(result.data.sidebar.label).toBe('Test Label')
22
+ expect(result.data.sidebar.className).toBe('custom-class')
23
+ expect(result.data.sidebar.customProps).toEqual({ badge: 'New' })
24
+ expect(result.data.sidebar.collapsible).toBe(true)
25
+ expect(result.data.sidebar.collapsed).toBe(false)
26
+ }
27
+ })
28
+
29
+ it('should reject collapsed: true with collapsible: false', () => {
30
+ const invalidData = {
31
+ sidebar: {
32
+ collapsible: false,
33
+ collapsed: true,
34
+ },
35
+ }
36
+
37
+ const result = docsSchema.safeParse(invalidData)
38
+ expect(result.success).toBe(false)
39
+ if (!result.success) {
40
+ expect(result.error.issues[0].message).toBe(
41
+ 'sidebar.collapsed cannot be true when sidebar.collapsible is false',
42
+ )
43
+ }
44
+ })
45
+
46
+ it('should accept collapsible: false with collapsed: false', () => {
47
+ const validData = {
48
+ sidebar: {
49
+ collapsible: false,
50
+ collapsed: false,
51
+ },
52
+ }
53
+
54
+ const result = docsSchema.safeParse(validData)
55
+ expect(result.success).toBe(true)
56
+ })
57
+
58
+ it('should reject tocMinHeadingLevel > tocMaxHeadingLevel', () => {
59
+ const invalidData = {
60
+ tocMinHeadingLevel: 4,
61
+ tocMaxHeadingLevel: 2,
62
+ }
63
+
64
+ const result = docsSchema.safeParse(invalidData)
65
+ expect(result.success).toBe(false)
66
+ if (!result.success) {
67
+ expect(result.error.issues[0].message).toBe(
68
+ 'tocMinHeadingLevel must be <= tocMaxHeadingLevel',
69
+ )
70
+ }
71
+ })
72
+
73
+ it('should accept valid tocMinHeadingLevel <= tocMaxHeadingLevel', () => {
74
+ const validData = {
75
+ tocMinHeadingLevel: 2,
76
+ tocMaxHeadingLevel: 4,
77
+ }
78
+
79
+ const result = docsSchema.safeParse(validData)
80
+ expect(result.success).toBe(true)
81
+ })
82
+
83
+ it('should apply correct default values for sidebar', () => {
84
+ const emptyData = {}
85
+
86
+ const result = docsSchema.safeParse(emptyData)
87
+ expect(result.success).toBe(true)
88
+ if (result.success) {
89
+ expect(result.data.sidebar.collapsible).toBe(true)
90
+ expect(result.data.sidebar.collapsed).toBe(true)
91
+ }
92
+ })
93
+
94
+ it('should apply correct default values for page rendering fields', () => {
95
+ const emptyData = {}
96
+
97
+ const result = docsSchema.safeParse(emptyData)
98
+ expect(result.success).toBe(true)
99
+ if (result.success) {
100
+ expect(result.data.render).toBe(true)
101
+ expect(result.data.draft).toBe(false)
102
+ expect(result.data.unlisted).toBe(false)
103
+ }
104
+ })
105
+
106
+ it('should apply correct default values for layout options', () => {
107
+ const emptyData = {}
108
+
109
+ const result = docsSchema.safeParse(emptyData)
110
+ expect(result.success).toBe(true)
111
+ if (result.success) {
112
+ expect(result.data.hideTitle).toBe(false)
113
+ expect(result.data.hideTableOfContents).toBe(false)
114
+ expect(result.data.hideSidebar).toBe(false)
115
+ expect(result.data.tocMinHeadingLevel).toBe(2)
116
+ expect(result.data.tocMaxHeadingLevel).toBe(3)
117
+ }
118
+ })
119
+
120
+ it('should accept all page metadata fields', () => {
121
+ const validData = {
122
+ id: 'custom-id',
123
+ title: 'Page Title',
124
+ description: 'Page description',
125
+ keywords: ['keyword1', 'keyword2'],
126
+ image: '/images/og-image.png',
127
+ canonicalUrl: 'https://example.com/page',
128
+ }
129
+
130
+ const result = docsSchema.safeParse(validData)
131
+ expect(result.success).toBe(true)
132
+ if (result.success) {
133
+ expect(result.data.id).toBe('custom-id')
134
+ expect(result.data.title).toBe('Page Title')
135
+ expect(result.data.description).toBe('Page description')
136
+ expect(result.data.keywords).toEqual(['keyword1', 'keyword2'])
137
+ expect(result.data.image).toBe('/images/og-image.png')
138
+ expect(result.data.canonicalUrl).toBe('https://example.com/page')
139
+ }
140
+ })
141
+
142
+ it('should accept page rendering fields', () => {
143
+ const validData = {
144
+ render: false,
145
+ draft: true,
146
+ unlisted: true,
147
+ slug: 'custom-slug',
148
+ }
149
+
150
+ const result = docsSchema.safeParse(validData)
151
+ expect(result.success).toBe(true)
152
+ if (result.success) {
153
+ expect(result.data.render).toBe(false)
154
+ expect(result.data.draft).toBe(true)
155
+ expect(result.data.unlisted).toBe(true)
156
+ expect(result.data.slug).toBe('custom-slug')
157
+ }
158
+ })
159
+
160
+ it('should accept pagination fields', () => {
161
+ const validData = {
162
+ paginationLabel: 'Custom Label',
163
+ paginationNext: 'next-page.md',
164
+ paginationPrev: null,
165
+ }
166
+
167
+ const result = docsSchema.safeParse(validData)
168
+ expect(result.success).toBe(true)
169
+ if (result.success) {
170
+ expect(result.data.paginationLabel).toBe('Custom Label')
171
+ expect(result.data.paginationNext).toBe('next-page.md')
172
+ expect(result.data.paginationPrev).toBeNull()
173
+ }
174
+ })
175
+
176
+ it('should transform pagination_label snake_case to camelCase', () => {
177
+ const validData = {
178
+ pagination_label: 'Custom Nav Label',
179
+ }
180
+
181
+ const result = docsSchema.safeParse(validData)
182
+ expect(result.success).toBe(true)
183
+ if (result.success) {
184
+ expect(result.data.paginationLabel).toBe('Custom Nav Label')
185
+ }
186
+ })
187
+
188
+ it('should accept git metadata override fields', () => {
189
+ const validData = {
190
+ lastUpdateAuthor: 'John Doe',
191
+ lastUpdateTime: '2024-01-15',
192
+ customEditUrl: 'https://github.com/org/repo/edit/main/docs/page.md',
193
+ }
194
+
195
+ const result = docsSchema.safeParse(validData)
196
+ expect(result.success).toBe(true)
197
+ if (result.success) {
198
+ expect(result.data.lastUpdateAuthor).toBe('John Doe')
199
+ expect(result.data.lastUpdateTime).toBeInstanceOf(Date)
200
+ expect(result.data.customEditUrl).toBe(
201
+ 'https://github.com/org/repo/edit/main/docs/page.md',
202
+ )
203
+ }
204
+ })
205
+
206
+ it('should accept false for git metadata hide options', () => {
207
+ const validData = {
208
+ lastUpdateAuthor: false,
209
+ lastUpdateTime: false,
210
+ customEditUrl: null,
211
+ }
212
+
213
+ const result = docsSchema.safeParse(validData)
214
+ expect(result.success).toBe(true)
215
+ if (result.success) {
216
+ expect(result.data.lastUpdateAuthor).toBe(false)
217
+ expect(result.data.lastUpdateTime).toBe(false)
218
+ expect(result.data.customEditUrl).toBeNull()
219
+ }
220
+ })
221
+
222
+ it('should accept custom meta tags', () => {
223
+ const validData = {
224
+ customMetaTags: [
225
+ { name: 'author', content: 'John Doe' },
226
+ { property: 'og:type', content: 'article' },
227
+ { content: 'some content' },
228
+ ],
229
+ }
230
+
231
+ const result = docsSchema.safeParse(validData)
232
+ expect(result.success).toBe(true)
233
+ if (result.success) {
234
+ expect(result.data.customMetaTags).toHaveLength(3)
235
+ expect(result.data.customMetaTags?.[0]).toEqual({
236
+ name: 'author',
237
+ content: 'John Doe',
238
+ })
239
+ }
240
+ })
241
+
242
+ it('should reject invalid heading level values', () => {
243
+ const invalidMin = { tocMinHeadingLevel: 0 }
244
+ const invalidMax = { tocMaxHeadingLevel: 7 }
245
+
246
+ const resultMin = docsSchema.safeParse(invalidMin)
247
+ const resultMax = docsSchema.safeParse(invalidMax)
248
+
249
+ expect(resultMin.success).toBe(false)
250
+ expect(resultMax.success).toBe(false)
251
+ })
252
+
253
+ it('should accept valid heading level boundary values', () => {
254
+ const validData = {
255
+ tocMinHeadingLevel: 1,
256
+ tocMaxHeadingLevel: 6,
257
+ }
258
+
259
+ const result = docsSchema.safeParse(validData)
260
+ expect(result.success).toBe(true)
261
+ if (result.success) {
262
+ expect(result.data.tocMinHeadingLevel).toBe(1)
263
+ expect(result.data.tocMaxHeadingLevel).toBe(6)
264
+ }
265
+ })
266
+
267
+ it('should transform hide_table_of_contents to hideTableOfContents', () => {
268
+ const validData = {
269
+ hide_table_of_contents: true,
270
+ }
271
+
272
+ const result = docsSchema.safeParse(validData)
273
+ expect(result.success).toBe(true)
274
+ if (result.success) {
275
+ expect(result.data.hideTableOfContents).toBe(true)
276
+ }
277
+ })
278
+
279
+ it('should transform hide_title to hideTitle', () => {
280
+ const validData = {
281
+ hide_title: true,
282
+ }
283
+
284
+ const result = docsSchema.safeParse(validData)
285
+ expect(result.success).toBe(true)
286
+ if (result.success) {
287
+ expect(result.data.hideTitle).toBe(true)
288
+ }
289
+ })
290
+
291
+ it('should transform canonical_url to canonicalUrl', () => {
292
+ const validData = {
293
+ canonical_url: 'https://example.com/canonical',
294
+ }
295
+
296
+ const result = docsSchema.safeParse(validData)
297
+ expect(result.success).toBe(true)
298
+ if (result.success) {
299
+ expect(result.data.canonicalUrl).toBe('https://example.com/canonical')
300
+ }
301
+ })
302
+
303
+ it('should transform custom_meta_tags to customMetaTags', () => {
304
+ const validData = {
305
+ custom_meta_tags: [
306
+ { name: 'robots', content: 'noindex, nofollow' },
307
+ { name: 'author', content: 'Test Author' },
308
+ ],
309
+ }
310
+
311
+ const result = docsSchema.safeParse(validData)
312
+ expect(result.success).toBe(true)
313
+ if (result.success) {
314
+ expect(result.data.customMetaTags).toHaveLength(2)
315
+ expect(result.data.customMetaTags?.[0]).toEqual({
316
+ name: 'robots',
317
+ content: 'noindex, nofollow',
318
+ })
319
+ }
320
+ })
321
+
322
+ it('should accept sidebar.position in nested sidebar object', () => {
323
+ const validData = {
324
+ sidebar: {
325
+ position: 42,
326
+ },
327
+ }
328
+
329
+ const result = docsSchema.safeParse(validData)
330
+ expect(result.success).toBe(true)
331
+ if (result.success) {
332
+ expect(result.data.sidebar.position).toBe(42)
333
+ }
334
+ })
335
+
336
+ it('should accept sidebar.label in nested sidebar object', () => {
337
+ const validData = {
338
+ sidebar: {
339
+ label: 'My Custom Label',
340
+ },
341
+ }
342
+
343
+ const result = docsSchema.safeParse(validData)
344
+ expect(result.success).toBe(true)
345
+ if (result.success) {
346
+ expect(result.data.sidebar.label).toBe('My Custom Label')
347
+ }
348
+ })
349
+
350
+ it('should accept sidebar.className in nested sidebar object', () => {
351
+ const validData = {
352
+ sidebar: {
353
+ className: 'my-custom-class',
354
+ },
355
+ }
356
+
357
+ const result = docsSchema.safeParse(validData)
358
+ expect(result.success).toBe(true)
359
+ if (result.success) {
360
+ expect(result.data.sidebar.className).toBe('my-custom-class')
361
+ }
362
+ })
363
+
364
+ it('should accept sidebar.customProps in nested sidebar object', () => {
365
+ const validData = {
366
+ sidebar: {
367
+ customProps: {
368
+ badge: 'New',
369
+ badgeType: 'success',
370
+ },
371
+ },
372
+ }
373
+
374
+ const result = docsSchema.safeParse(validData)
375
+ expect(result.success).toBe(true)
376
+ if (result.success) {
377
+ expect(result.data.sidebar.customProps).toEqual({
378
+ badge: 'New',
379
+ badgeType: 'success',
380
+ })
381
+ }
382
+ })
383
+
384
+ it('should accept full sidebar configuration object', () => {
385
+ const validData = {
386
+ sidebar: {
387
+ position: 10,
388
+ collapsible: false,
389
+ collapsed: false,
390
+ customProps: { featured: true },
391
+ },
392
+ }
393
+
394
+ const result = docsSchema.safeParse(validData)
395
+ expect(result.success).toBe(true)
396
+ if (result.success) {
397
+ expect(result.data.sidebar.position).toBe(10)
398
+ expect(result.data.sidebar.collapsible).toBe(false)
399
+ expect(result.data.sidebar.collapsed).toBe(false)
400
+ expect(result.data.sidebar.customProps).toEqual({ featured: true })
401
+ }
402
+ })
403
+
404
+ it('should transform pagination_next snake_case to camelCase', () => {
405
+ const validData = {
406
+ pagination_next: 'my-custom-doc-id',
407
+ }
408
+
409
+ const result = docsSchema.safeParse(validData)
410
+ expect(result.success).toBe(true)
411
+ if (result.success) {
412
+ expect(result.data.paginationNext).toBe('my-custom-doc-id')
413
+ }
414
+ })
415
+
416
+ it('should transform pagination_prev snake_case to camelCase', () => {
417
+ const validData = {
418
+ pagination_prev: 'another-doc-id',
419
+ }
420
+
421
+ const result = docsSchema.safeParse(validData)
422
+ expect(result.success).toBe(true)
423
+ if (result.success) {
424
+ expect(result.data.paginationPrev).toBe('another-doc-id')
425
+ }
426
+ })
427
+
428
+ it('should handle pagination_prev: null to disable pagination', () => {
429
+ const validData = {
430
+ pagination_prev: null,
431
+ }
432
+
433
+ const result = docsSchema.safeParse(validData)
434
+ expect(result.success).toBe(true)
435
+ if (result.success) {
436
+ expect(result.data.paginationPrev).toBeNull()
437
+ }
438
+ })
439
+
440
+ it('should transform pagination_next and pagination_prev together', () => {
441
+ const validData = {
442
+ pagination_next: 'next-doc',
443
+ pagination_prev: null,
444
+ }
445
+
446
+ const result = docsSchema.safeParse(validData)
447
+ expect(result.success).toBe(true)
448
+ if (result.success) {
449
+ expect(result.data.paginationNext).toBe('next-doc')
450
+ expect(result.data.paginationPrev).toBeNull()
451
+ }
452
+ })
453
+
454
+ it('should accept title_meta for SEO title override', () => {
455
+ const validData = {
456
+ title: 'Display Title',
457
+ title_meta: 'SEO Optimized Title | Site Name',
458
+ }
459
+
460
+ const result = docsSchema.safeParse(validData)
461
+ expect(result.success).toBe(true)
462
+ if (result.success) {
463
+ expect(result.data.title).toBe('Display Title')
464
+ expect(result.data.title_meta).toBe('SEO Optimized Title | Site Name')
465
+ }
466
+ })
467
+ })