@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.
- package/astro/DocsEntry.astro +2 -2
- package/astro/Layout.astro +35 -8
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/sidebarEntries.test.ts +156 -188
- package/src/sidebarEntries.ts +113 -25
package/astro/DocsEntry.astro
CHANGED
|
@@ -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>
|
package/astro/Layout.astro
CHANGED
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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="
|
|
50
|
-
<
|
|
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.
|
|
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.
|
|
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,
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { DocsData } from './sidebarEntries'
|
|
2
3
|
import { toSidebarEntries } from './sidebarEntries'
|
|
3
4
|
|
|
4
|
-
describe('
|
|
5
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
})
|
package/src/sidebarEntries.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|