@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.
@@ -37,7 +37,7 @@ describe('toSidebarEntries', () => {
37
37
  })
38
38
  })
39
39
 
40
- it('should respect sidebar_label', () => {
40
+ it('should respect sidebar.label', () => {
41
41
  const docs: DocsData[] = [
42
42
  {
43
43
  id: 'guide/intro.md',
@@ -51,7 +51,7 @@ describe('toSidebarEntries', () => {
51
51
  expect(entries.guide.subEntry?.intro.label).toBe('Intro')
52
52
  })
53
53
 
54
- it('should respect sidebar_position', () => {
54
+ it('should respect sidebar.position', () => {
55
55
  const docs: DocsData[] = [
56
56
  {
57
57
  id: 'guide/b.md',
@@ -80,7 +80,7 @@ describe('toSidebarEntries', () => {
80
80
  expect(keys).toEqual(['a', 'b', 'c'])
81
81
  })
82
82
 
83
- it('should apply sidebar_class_name', () => {
83
+ it('should apply sidebar.className', () => {
84
84
  const docs: DocsData[] = [
85
85
  {
86
86
  id: 'page.md',
@@ -117,9 +117,9 @@ describe('toSidebarEntries', () => {
117
117
  expect(keys).toEqual(['a', 'z'])
118
118
  })
119
119
 
120
- it('should apply index.md sidebar_position to parent category for top-level sorting', () => {
120
+ it('should apply index.md sidebar.position to parent category for top-level sorting', () => {
121
121
  const docs: DocsData[] = [
122
- // "zebra" folder with index.md that has sidebar_position: 1
122
+ // "zebra" folder with index.md that has sidebar.position: 1
123
123
  {
124
124
  id: 'zebra/index.md',
125
125
  title: 'Zebra Section',
@@ -144,9 +144,9 @@ describe('toSidebarEntries', () => {
144
144
  expect(keys).toEqual(['zebra', 'apple'])
145
145
  })
146
146
 
147
- it('should position category first when index.md has explicit sidebar_position', () => {
147
+ it('should position category first when index.md has explicit sidebar.position', () => {
148
148
  const docs: DocsData[] = [
149
- // A category with index.md having sidebar_position: 0
149
+ // A category with index.md having sidebar.position: 0
150
150
  {
151
151
  id: 'sidebar-demo/index.md',
152
152
  title: 'Sidebar Demo',
@@ -170,4 +170,142 @@ describe('toSidebarEntries', () => {
170
170
  // So sidebar-demo comes first, then others alphabetically
171
171
  expect(keys).toEqual(['sidebar-demo', 'garden-beds', 'harvesting'])
172
172
  })
173
+
174
+ it('should include collapsible/collapsed in category entries', () => {
175
+ const docs: DocsData[] = [
176
+ {
177
+ id: 'guide/index.md',
178
+ title: 'Guide',
179
+ path: '/docs/guide',
180
+ collapsible: true,
181
+ collapsed: false,
182
+ },
183
+ {
184
+ id: 'guide/intro.md',
185
+ title: 'Introduction',
186
+ path: '/docs/guide/intro',
187
+ },
188
+ ]
189
+
190
+ const entries = toSidebarEntries(docs)
191
+
192
+ expect(entries.guide.collapsible).toBe(true)
193
+ expect(entries.guide.collapsed).toBe(false)
194
+ })
195
+
196
+ it('should apply default collapsible/collapsed values when not specified', () => {
197
+ const docs: DocsData[] = [
198
+ {
199
+ id: 'guide/index.md',
200
+ title: 'Guide',
201
+ path: '/docs/guide',
202
+ },
203
+ {
204
+ id: 'guide/intro.md',
205
+ title: 'Introduction',
206
+ path: '/docs/guide/intro',
207
+ },
208
+ ]
209
+
210
+ const entries = toSidebarEntries(docs)
211
+
212
+ // Default values: collapsible: true, collapsed: true
213
+ expect(entries.guide.collapsible).toBe(true)
214
+ expect(entries.guide.collapsed).toBe(true)
215
+ })
216
+
217
+ it('should filter unlisted pages from sidebar', () => {
218
+ const docs: DocsData[] = [
219
+ {
220
+ id: 'guide/intro.md',
221
+ title: 'Introduction',
222
+ path: '/docs/guide/intro',
223
+ },
224
+ {
225
+ id: 'guide/hidden.md',
226
+ title: 'Hidden Page',
227
+ path: '/docs/guide/hidden',
228
+ unlisted: true,
229
+ },
230
+ {
231
+ id: 'guide/visible.md',
232
+ title: 'Visible Page',
233
+ path: '/docs/guide/visible',
234
+ },
235
+ ]
236
+
237
+ const entries = toSidebarEntries(docs)
238
+ const guideSubKeys = Object.keys(entries.guide.subEntry || {})
239
+
240
+ // hidden should not be in the sidebar
241
+ expect(guideSubKeys).toContain('intro')
242
+ expect(guideSubKeys).toContain('visible')
243
+ expect(guideSubKeys).not.toContain('hidden')
244
+ })
245
+
246
+ it('should create non-clickable entries for render: false (no href)', () => {
247
+ const docs: DocsData[] = [
248
+ {
249
+ id: 'guide/index.md',
250
+ title: 'Guide',
251
+ path: '/docs/guide',
252
+ link: false, // render: false translates to link: false
253
+ },
254
+ {
255
+ id: 'guide/intro.md',
256
+ title: 'Introduction',
257
+ path: '/docs/guide/intro',
258
+ },
259
+ ]
260
+
261
+ const entries = toSidebarEntries(docs)
262
+
263
+ // Category should exist but not have an href
264
+ expect(entries.guide.label).toBe('Guide')
265
+ expect(entries.guide.href).toBeUndefined()
266
+ expect(entries.guide.subEntry?.intro.href).toBe('/docs/guide/intro')
267
+ })
268
+
269
+ it('should apply index.md metadata including collapsible state to parent category', () => {
270
+ const docs: DocsData[] = [
271
+ {
272
+ id: 'advanced/index.md',
273
+ title: 'Advanced Topics',
274
+ path: '/docs/advanced',
275
+ sidebarLabel: 'Advanced',
276
+ sidebarPosition: 5,
277
+ sidebarClassName: 'advanced-section',
278
+ collapsible: false,
279
+ collapsed: false,
280
+ },
281
+ {
282
+ id: 'advanced/topic1.md',
283
+ title: 'Topic 1',
284
+ path: '/docs/advanced/topic1',
285
+ },
286
+ ]
287
+
288
+ const entries = toSidebarEntries(docs)
289
+
290
+ expect(entries.advanced.label).toBe('Advanced')
291
+ expect(entries.advanced.className).toBe('advanced-section')
292
+ expect(entries.advanced.collapsible).toBe(false)
293
+ expect(entries.advanced.collapsed).toBe(false)
294
+ })
295
+
296
+ it('should not include collapsible/collapsed for leaf nodes', () => {
297
+ const docs: DocsData[] = [
298
+ {
299
+ id: 'page.md',
300
+ title: 'Page',
301
+ path: '/docs/page',
302
+ },
303
+ ]
304
+
305
+ const entries = toSidebarEntries(docs)
306
+
307
+ // Leaf nodes should not have collapsible/collapsed properties
308
+ expect(entries.page.collapsible).toBeUndefined()
309
+ expect(entries.page.collapsed).toBeUndefined()
310
+ })
173
311
  })
@@ -2,14 +2,21 @@ import type { Entry } from '@levino/shipyard-base'
2
2
 
3
3
  export interface DocsData {
4
4
  id: string
5
+ /** Custom document ID from frontmatter (for referencing in pagination) */
6
+ customId?: string
5
7
  title: string
6
8
  path: string
7
9
  link?: boolean
8
10
  sidebarPosition?: number
9
11
  sidebarLabel?: string
10
12
  sidebarClassName?: string
11
- pagination_next?: string | null
12
- pagination_prev?: string | null
13
+ sidebarCustomProps?: Record<string, unknown>
14
+ collapsible?: boolean
15
+ collapsed?: boolean
16
+ unlisted?: boolean
17
+ paginationLabel?: string
18
+ paginationNext?: string | null
19
+ paginationPrev?: string | null
13
20
  }
14
21
 
15
22
  interface TreeNode {
@@ -18,6 +25,9 @@ interface TreeNode {
18
25
  readonly href?: string
19
26
  readonly position: number
20
27
  readonly className?: string
28
+ readonly customProps?: Record<string, unknown>
29
+ readonly collapsible: boolean
30
+ readonly collapsed: boolean
21
31
  readonly children: Readonly<Record<string, TreeNode>>
22
32
  }
23
33
 
@@ -29,6 +39,9 @@ const createLeafNode = (key: string, doc: DocsData): TreeNode => ({
29
39
  href: doc.link !== false ? doc.path : undefined,
30
40
  position: doc.sidebarPosition ?? DEFAULT_POSITION,
31
41
  className: doc.sidebarClassName,
42
+ customProps: doc.sidebarCustomProps,
43
+ collapsible: doc.collapsible ?? true,
44
+ collapsed: doc.collapsed ?? true,
32
45
  children: {},
33
46
  })
34
47
 
@@ -36,6 +49,8 @@ const createBranchNode = (key: string): TreeNode => ({
36
49
  key,
37
50
  label: key,
38
51
  position: DEFAULT_POSITION,
52
+ collapsible: true,
53
+ collapsed: true,
39
54
  children: {},
40
55
  })
41
56
 
@@ -45,6 +60,9 @@ const mergeNodeWithDoc = (node: TreeNode, doc: DocsData): TreeNode => ({
45
60
  href: doc.link !== false ? doc.path : node.href,
46
61
  position: doc.sidebarPosition ?? node.position,
47
62
  className: doc.sidebarClassName ?? node.className,
63
+ customProps: doc.sidebarCustomProps ?? node.customProps,
64
+ collapsible: doc.collapsible ?? node.collapsible,
65
+ collapsed: doc.collapsed ?? node.collapsed,
48
66
  })
49
67
 
50
68
  const insertAtPath = (
@@ -93,11 +111,17 @@ const treeNodeToEntry = (node: TreeNode): Entry[string] => {
93
111
  )
94
112
  : undefined
95
113
 
114
+ // Only include collapsible/collapsed for nodes with children (category nodes)
115
+ const hasChildren = sortedChildren.length > 0
116
+
96
117
  return {
97
118
  label: node.label,
98
119
  ...(node.href && { href: node.href }),
99
120
  ...(node.className && { className: node.className }),
121
+ ...(node.customProps && { customProps: node.customProps }),
100
122
  ...(subEntry && { subEntry }),
123
+ ...(hasChildren && { collapsible: node.collapsible }),
124
+ ...(hasChildren && { collapsed: node.collapsed }),
101
125
  }
102
126
  }
103
127
 
@@ -109,7 +133,10 @@ const parseDocPath = (id: string): readonly string[] => {
109
133
  }
110
134
 
111
135
  export const toSidebarEntries = (docs: readonly DocsData[]): Entry => {
112
- const rootTree = docs.reduce<Readonly<Record<string, TreeNode>>>(
136
+ // Filter out unlisted pages from the sidebar
137
+ const visibleDocs = docs.filter((doc) => !doc.unlisted)
138
+
139
+ const rootTree = visibleDocs.reduce<Readonly<Record<string, TreeNode>>>(
113
140
  (acc, doc) => insertAtPath(acc, parseDocPath(doc.id), doc),
114
141
  {},
115
142
  )