@levino/shipyard-docs 0.4.5 → 0.4.6

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.
@@ -29,9 +29,9 @@ export async function getStaticPaths() {
29
29
 
30
30
  // 2. For your template, you can get the entry directly from the prop
31
31
  const { entry } = Astro.props
32
- const { Content } = await render(entry)
32
+ const { Content, headings } = await render(entry)
33
33
  ---
34
34
 
35
- <Layout>
35
+ <Layout headings={headings}>
36
36
  <Content />
37
37
  </Layout>
@@ -2,11 +2,17 @@
2
2
  import { i18n } from 'astro:config/server'
3
3
  import { getCollection, render } from 'astro:content'
4
4
  import type { NavigationTree } from '@levino/shipyard-base'
5
+ import { Breadcrumbs, TableOfContents } from '@levino/shipyard-base/components'
5
6
  import BaseLayout from '@levino/shipyard-base/layouts/Page.astro'
6
7
  import { Array as EffectArray, Option } from 'effect'
7
- import { path } from 'ramda'
8
8
  import { toSidebarEntries } from '../src/sidebarEntries'
9
9
 
10
+ interface Props {
11
+ headings?: { depth: number; text: string; slug: string }[]
12
+ }
13
+
14
+ const { headings = [] } = Astro.props
15
+
10
16
  const getPath = (id: string) =>
11
17
  i18n ? `/${Astro.currentLocale}/docs/${id.slice(3)}` : `/docs/${id}`
12
18
 
@@ -18,9 +24,14 @@ const docs = await getCollection('docs')
18
24
  data: {
19
25
  title,
20
26
  sidebar: { render: shouldBeRendered, label },
27
+ sidebar_position,
28
+ sidebar_label,
29
+ sidebar_class_name,
30
+ sidebar_custom_props,
21
31
  },
22
32
  } = doc
23
33
  return {
34
+ id,
24
35
  path: getPath(id),
25
36
  title:
26
37
  label ??
@@ -33,20 +44,36 @@ const docs = await getCollection('docs')
33
44
  )?.text ??
34
45
  id,
35
46
  link: shouldBeRendered,
47
+ sidebarPosition: sidebar_position,
48
+ sidebarLabel: sidebar_label,
49
+ sidebarClassName: sidebar_class_name,
50
+ sidebarCustomProps: sidebar_custom_props,
36
51
  }
37
52
  }),
38
53
  )
39
54
  .then((promises) => Promise.all(promises))
40
55
 
41
- const entries = path(
42
- i18n
43
- ? [Astro.currentLocale, 'subEntry', 'docs', 'subEntry']
44
- : ['docs', 'subEntry'],
45
- )(toSidebarEntries(docs)) as NavigationTree
56
+ const fullTree = toSidebarEntries(docs)
57
+
58
+ const entries: NavigationTree =
59
+ i18n && Astro.currentLocale
60
+ ? (fullTree[Astro.currentLocale]?.subEntry ?? {})
61
+ : fullTree
46
62
  ---
47
63
 
48
64
  <BaseLayout sidebarNavigation={entries}>
49
- <div class="prose mx-auto">
50
- <slot />
65
+ <div class="grid grid-cols-12 gap-6 max-w-7xl mx-auto">
66
+ <div class="col-span-12 xl:col-span-9">
67
+ <div class="prose max-w-none">
68
+ <Breadcrumbs navigation={entries} />
69
+ <TableOfContents links={headings} class="xl:hidden" />
70
+ <slot />
71
+ </div>
72
+ </div>
73
+ <div class="hidden xl:block col-span-3">
74
+ <div class="sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto">
75
+ <TableOfContents links={headings} desktopOnly />
76
+ </div>
77
+ </div>
51
78
  </div>
52
79
  </BaseLayout>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levino/shipyard-docs",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "effect": "^3.12.5",
15
15
  "ramda": "^0.31",
16
- "@levino/shipyard-base": "^0.5.6"
16
+ "@levino/shipyard-base": "^0.5.8"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@tailwindcss/typography": "^0.5.16",
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ export const docsSchema = z.object({
11
11
  .default({ render: true }),
12
12
  title: z.string().optional(),
13
13
  description: z.string().optional(),
14
+ sidebar_position: z.number().optional(),
15
+ sidebar_label: z.string().optional(),
16
+ sidebar_class_name: z.string().optional(),
14
17
  })
15
18
 
16
19
  export default (): AstroIntegration => ({
@@ -1,205 +1,173 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { DocsData } from './sidebarEntries'
2
3
  import { toSidebarEntries } from './sidebarEntries'
3
4
 
4
- describe('Sidebar subEntry helpers', () => {
5
- test.each([
6
- [
7
- [
8
- {
9
- title: 'foo',
10
- path: '/foo',
11
- },
12
- ],
5
+ describe('toSidebarEntries', () => {
6
+ it('should create a basic sidebar structure from flat docs', () => {
7
+ const docs: DocsData[] = [
13
8
  {
14
- foo: {
15
- label: 'foo',
16
- href: '/foo',
17
- },
9
+ id: 'guide/intro.md',
10
+ title: 'Introduction',
11
+ path: '/docs/guide/intro',
18
12
  },
19
- ],
20
- [
21
- [
22
- {
23
- title: 'foo',
24
- path: '/foo',
25
- },
26
- {
27
- title: 'bar',
28
- path: '/bar',
29
- },
30
- ],
31
13
  {
32
- foo: {
33
- label: 'foo',
34
- href: '/foo',
35
- },
36
- bar: {
37
- label: 'bar',
38
- href: '/bar',
39
- },
14
+ id: 'guide/advanced/topic.md',
15
+ title: 'Topic',
16
+ path: '/docs/guide/advanced/topic',
40
17
  },
41
- ],
42
- [
43
- [
44
- {
45
- title: 'foo baz',
46
- path: '/baz/foo',
47
- },
48
- {
49
- title: 'bar baz',
50
- path: '/baz/bar',
51
- },
52
- ],
53
- {
54
- baz: {
55
- subEntry: {
56
- foo: {
57
- label: 'foo baz',
58
- href: '/baz/foo',
59
- },
60
- bar: {
61
- label: 'bar baz',
62
- href: '/baz/bar',
18
+ { id: 'api.md', title: 'API', path: '/docs/api' },
19
+ ]
20
+
21
+ const entries = toSidebarEntries(docs)
22
+
23
+ expect(entries).toMatchObject({
24
+ guide: {
25
+ label: 'guide',
26
+ subEntry: {
27
+ intro: { label: 'Introduction', href: '/docs/guide/intro' },
28
+ advanced: {
29
+ label: 'advanced',
30
+ subEntry: {
31
+ topic: { label: 'Topic', href: '/docs/guide/advanced/topic' },
63
32
  },
64
33
  },
65
34
  },
66
35
  },
67
- ],
68
- [
69
- [
70
- {
71
- title: 'foo',
72
- path: '/foo',
73
- },
74
- {
75
- title: 'bar',
76
- path: '/bar',
77
- },
78
- {
79
- title: 'foo baz',
80
- path: '/baz/foo',
81
- },
82
- {
83
- title: 'bar baz',
84
- path: '/baz/bar',
85
- },
86
- ],
36
+ api: { label: 'API', href: '/docs/api' },
37
+ })
38
+ })
39
+
40
+ it('should respect sidebar_label', () => {
41
+ const docs: DocsData[] = [
87
42
  {
88
- foo: {
89
- label: 'foo',
90
- href: '/foo',
91
- },
92
- bar: {
93
- label: 'bar',
94
- href: '/bar',
95
- },
96
- baz: {
97
- subEntry: {
98
- foo: {
99
- label: 'foo baz',
100
- href: '/baz/foo',
101
- },
102
- bar: {
103
- label: 'bar baz',
104
- href: '/baz/bar',
105
- },
106
- },
107
- },
43
+ id: 'guide/intro.md',
44
+ title: 'Introduction',
45
+ path: '/docs/guide/intro',
46
+ sidebarLabel: 'Intro',
108
47
  },
109
- ],
110
- [
111
- [
112
- {
113
- title: 'foo',
114
- path: '/foo',
115
- },
116
- {
117
- title: 'bar',
118
- path: '/bar',
119
- },
120
- { title: 'baz', path: '/baz' },
121
- {
122
- title: 'foo baz',
123
- path: '/baz/foo',
124
- },
125
- {
126
- title: 'bar baz',
127
- path: '/baz/bar',
128
- },
129
- ],
48
+ ]
49
+
50
+ const entries = toSidebarEntries(docs)
51
+ expect(entries.guide.subEntry?.intro.label).toBe('Intro')
52
+ })
53
+
54
+ it('should respect sidebar_position', () => {
55
+ const docs: DocsData[] = [
130
56
  {
131
- foo: {
132
- label: 'foo',
133
- href: '/foo',
134
- },
135
- bar: {
136
- label: 'bar',
137
- href: '/bar',
138
- },
139
- baz: {
140
- label: 'baz',
141
- href: '/baz',
142
- subEntry: {
143
- foo: {
144
- label: 'foo baz',
145
- href: '/baz/foo',
146
- },
147
- bar: {
148
- label: 'bar baz',
149
- href: '/baz/bar',
150
- },
151
- },
152
- },
57
+ id: 'guide/b.md',
58
+ title: 'B',
59
+ path: '/docs/guide/b',
60
+ sidebarPosition: 2,
153
61
  },
154
- ],
155
- [
156
- [
157
- {
158
- title: 'foo',
159
- path: '/foo',
160
- },
161
- {
162
- title: 'bar',
163
- path: '/bar',
164
- },
165
- { title: 'baz', path: '/baz', link: false },
166
- {
167
- title: 'foo baz',
168
- path: '/baz/foo',
169
- },
170
- {
171
- title: 'bar baz',
172
- path: '/baz/bar',
173
- },
174
- ],
175
62
  {
176
- foo: {
177
- label: 'foo',
178
- href: '/foo',
179
- },
180
- bar: {
181
- label: 'bar',
182
- href: '/bar',
183
- },
184
- baz: {
185
- label: 'baz',
186
- subEntry: {
187
- foo: {
188
- label: 'foo baz',
189
- href: '/baz/foo',
190
- },
191
- bar: {
192
- label: 'bar baz',
193
- href: '/baz/bar',
194
- },
195
- },
196
- },
63
+ id: 'guide/a.md',
64
+ title: 'A',
65
+ path: '/docs/guide/a',
66
+ sidebarPosition: 1,
67
+ },
68
+ {
69
+ id: 'guide/c.md',
70
+ title: 'C',
71
+ path: '/docs/guide/c',
197
72
  },
198
- ],
199
- ])(
200
- 'transforms a list of doc sites into a list of sidebar subEntry',
201
- (input, expected) => {
202
- expect(toSidebarEntries(input)).toEqual(expected)
203
- },
204
- )
73
+ ]
74
+
75
+ const entries = toSidebarEntries(docs)
76
+ const guideSub = entries.guide.subEntry
77
+ const keys = Object.keys(guideSub || {})
78
+
79
+ // Items with explicit positions come first (1, 2), then items without position (Infinity) are sorted alphabetically
80
+ expect(keys).toEqual(['a', 'b', 'c'])
81
+ })
82
+
83
+ it('should apply sidebar_class_name', () => {
84
+ const docs: DocsData[] = [
85
+ {
86
+ id: 'page.md',
87
+ title: 'Page',
88
+ path: '/docs/page',
89
+ sidebarClassName: 'special-page',
90
+ },
91
+ ]
92
+
93
+ const entries = toSidebarEntries(docs)
94
+ expect(entries.page.className).toBe('special-page')
95
+ })
96
+
97
+ it('should handle index files correctly', () => {
98
+ const docs: DocsData[] = [
99
+ { id: 'guide/index.md', title: 'Guide Index', path: '/docs/guide' },
100
+ { id: 'guide/other.md', title: 'Other', path: '/docs/guide/other' },
101
+ ]
102
+
103
+ const entries = toSidebarEntries(docs)
104
+ expect(entries.guide.href).toBe('/docs/guide')
105
+ expect(entries.guide.label).toBe('Guide Index')
106
+ expect(entries.guide.subEntry).toBeDefined()
107
+ expect(entries.guide.subEntry?.other).toBeDefined()
108
+ })
109
+
110
+ it('should sort alphabetically when positions match', () => {
111
+ const docs: DocsData[] = [
112
+ { id: 'z.md', title: 'Zebra', path: '/z' },
113
+ { id: 'a.md', title: 'Apple', path: '/a' },
114
+ ]
115
+ const entries = toSidebarEntries(docs)
116
+ const keys = Object.keys(entries)
117
+ expect(keys).toEqual(['a', 'z'])
118
+ })
119
+
120
+ it('should apply index.md sidebar_position to parent category for top-level sorting', () => {
121
+ const docs: DocsData[] = [
122
+ // "zebra" folder with index.md that has sidebar_position: 1
123
+ {
124
+ id: 'zebra/index.md',
125
+ title: 'Zebra Section',
126
+ path: '/docs/zebra',
127
+ sidebarPosition: 1,
128
+ },
129
+ { id: 'zebra/page.md', title: 'Zebra Page', path: '/docs/zebra/page' },
130
+ // "apple" folder with no explicit position (defaults to Infinity)
131
+ {
132
+ id: 'apple/index.md',
133
+ title: 'Apple Section',
134
+ path: '/docs/apple',
135
+ },
136
+ { id: 'apple/page.md', title: 'Apple Page', path: '/docs/apple/page' },
137
+ ]
138
+
139
+ const entries = toSidebarEntries(docs)
140
+ const keys = Object.keys(entries)
141
+
142
+ // zebra has explicit position 1, apple defaults to Infinity
143
+ // So zebra (position 1) comes before apple (Infinity)
144
+ expect(keys).toEqual(['zebra', 'apple'])
145
+ })
146
+
147
+ it('should position category first when index.md has explicit sidebar_position', () => {
148
+ const docs: DocsData[] = [
149
+ // A category with index.md having sidebar_position: 0
150
+ {
151
+ id: 'sidebar-demo/index.md',
152
+ title: 'Sidebar Demo',
153
+ path: '/docs/sidebar-demo',
154
+ sidebarPosition: 0,
155
+ },
156
+ {
157
+ id: 'sidebar-demo/custom-class.md',
158
+ title: 'Custom Class',
159
+ path: '/docs/sidebar-demo/custom-class',
160
+ },
161
+ // Other top-level items with no position (default Infinity)
162
+ { id: 'garden-beds.md', title: 'Garden Beds', path: '/docs/garden-beds' },
163
+ { id: 'harvesting.md', title: 'Harvesting', path: '/docs/harvesting' },
164
+ ]
165
+
166
+ const entries = toSidebarEntries(docs)
167
+ const keys = Object.keys(entries)
168
+
169
+ // sidebar-demo has explicit position 0, others default to Infinity
170
+ // So sidebar-demo comes first, then others alphabetically
171
+ expect(keys).toEqual(['sidebar-demo', 'garden-beds', 'harvesting'])
172
+ })
205
173
  })
@@ -1,32 +1,120 @@
1
1
  import type { Entry } from '@levino/shipyard-base'
2
- import { mergeDeepLeft } from 'ramda'
3
2
 
4
- interface DocsData {
3
+ export interface DocsData {
4
+ id: string
5
5
  title: string
6
6
  path: string
7
7
  link?: boolean
8
+ sidebarPosition?: number
9
+ sidebarLabel?: string
10
+ sidebarClassName?: string
8
11
  }
9
12
 
10
- export const toSidebarEntries = (docs: DocsData[]): Entry =>
11
- docs.reduce((acc, { path, title, link = true }) => {
12
- const newObject = path
13
- .split('/')
14
- .slice(1)
15
- .reverse()
16
- .reduce((acc, node, index): Entry => {
17
- if (index === 0) {
18
- return {
19
- [node]: {
20
- label: title,
21
- ...(link ? { href: `${path}` } : {}),
22
- },
23
- }
24
- }
25
- return {
26
- [node]: {
27
- subEntry: acc,
28
- },
29
- }
30
- }, {} as Entry)
31
- return mergeDeepLeft(acc, newObject)
32
- }, {} as Entry)
13
+ interface TreeNode {
14
+ readonly key: string
15
+ readonly label: string
16
+ readonly href?: string
17
+ readonly position: number
18
+ readonly className?: string
19
+ readonly children: Readonly<Record<string, TreeNode>>
20
+ }
21
+
22
+ const DEFAULT_POSITION = Number.POSITIVE_INFINITY
23
+
24
+ const createLeafNode = (key: string, doc: DocsData): TreeNode => ({
25
+ key,
26
+ label: doc.sidebarLabel ?? doc.title ?? key,
27
+ href: doc.link !== false ? doc.path : undefined,
28
+ position: doc.sidebarPosition ?? DEFAULT_POSITION,
29
+ className: doc.sidebarClassName,
30
+ children: {},
31
+ })
32
+
33
+ const createBranchNode = (key: string): TreeNode => ({
34
+ key,
35
+ label: key,
36
+ position: DEFAULT_POSITION,
37
+ children: {},
38
+ })
39
+
40
+ const mergeNodeWithDoc = (node: TreeNode, doc: DocsData): TreeNode => ({
41
+ ...node,
42
+ label: doc.sidebarLabel ?? doc.title ?? node.label,
43
+ href: doc.link !== false ? doc.path : node.href,
44
+ position: doc.sidebarPosition ?? node.position,
45
+ className: doc.sidebarClassName ?? node.className,
46
+ })
47
+
48
+ const insertAtPath = (
49
+ root: Readonly<Record<string, TreeNode>>,
50
+ pathParts: readonly string[],
51
+ doc: DocsData,
52
+ ): Readonly<Record<string, TreeNode>> => {
53
+ if (pathParts.length === 0) return root
54
+
55
+ const [head, ...tail] = pathParts
56
+ const existingNode = root[head]
57
+
58
+ if (tail.length === 0) {
59
+ const newNode = existingNode
60
+ ? mergeNodeWithDoc(existingNode, doc)
61
+ : createLeafNode(head, doc)
62
+ return { ...root, [head]: newNode }
63
+ }
64
+
65
+ const currentNode = existingNode ?? createBranchNode(head)
66
+ const updatedChildren = insertAtPath(currentNode.children, tail, doc)
67
+
68
+ return {
69
+ ...root,
70
+ [head]: { ...currentNode, children: updatedChildren },
71
+ }
72
+ }
73
+
74
+ const sortByPositionThenLabel = (
75
+ nodes: readonly TreeNode[],
76
+ ): readonly TreeNode[] =>
77
+ [...nodes].sort((a, b) =>
78
+ a.position !== b.position
79
+ ? a.position - b.position
80
+ : a.label.localeCompare(b.label),
81
+ )
82
+
83
+ const treeNodeToEntry = (node: TreeNode): Entry[string] => {
84
+ const childNodes = Object.values(node.children)
85
+ const sortedChildren = sortByPositionThenLabel(childNodes)
86
+
87
+ const subEntry =
88
+ sortedChildren.length > 0
89
+ ? Object.fromEntries(
90
+ sortedChildren.map((child) => [child.key, treeNodeToEntry(child)]),
91
+ )
92
+ : undefined
93
+
94
+ return {
95
+ label: node.label,
96
+ ...(node.href && { href: node.href }),
97
+ ...(node.className && { className: node.className }),
98
+ ...(subEntry && { subEntry }),
99
+ }
100
+ }
101
+
102
+ const parseDocPath = (id: string): readonly string[] => {
103
+ const cleanId = id.replace(/\.[^/.]+$/, '')
104
+ const parts = cleanId.split('/')
105
+ const filename = parts[parts.length - 1]
106
+ return filename === 'index' ? parts.slice(0, -1) : parts
107
+ }
108
+
109
+ export const toSidebarEntries = (docs: readonly DocsData[]): Entry => {
110
+ const rootTree = docs.reduce<Readonly<Record<string, TreeNode>>>(
111
+ (acc, doc) => insertAtPath(acc, parseDocPath(doc.id), doc),
112
+ {},
113
+ )
114
+
115
+ const sortedNodes = sortByPositionThenLabel(Object.values(rootTree))
116
+
117
+ return Object.fromEntries(
118
+ sortedNodes.map((node) => [node.key, treeNodeToEntry(node)]),
119
+ )
120
+ }