@levino/shipyard-docs 0.0.0-rc-20251122114952

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.
@@ -0,0 +1,37 @@
1
+ ---
2
+ import { i18n } from 'astro:config/server'
3
+ import { getCollection, render } from 'astro:content'
4
+ import Layout from './Layout.astro'
5
+
6
+ export async function getStaticPaths() {
7
+ const docs = await getCollection('docs')
8
+
9
+ const getParams = (slug: string) => {
10
+ if (i18n) {
11
+ const [locale, ...rest] = slug.split('/')
12
+ return {
13
+ slug: rest.length ? rest.join('/') : undefined,
14
+ locale,
15
+ }
16
+ } else {
17
+ // For non-i18n, treat the entire slug as the path
18
+ return {
19
+ slug: slug || undefined,
20
+ }
21
+ }
22
+ }
23
+
24
+ return docs.map((entry) => ({
25
+ params: getParams(entry.id),
26
+ props: { entry },
27
+ }))
28
+ }
29
+
30
+ // 2. For your template, you can get the entry directly from the prop
31
+ const { entry } = Astro.props
32
+ const { Content, headings } = await render(entry)
33
+ ---
34
+
35
+ <Layout headings={headings}>
36
+ <Content />
37
+ </Layout>
@@ -0,0 +1,79 @@
1
+ ---
2
+ import { i18n } from 'astro:config/server'
3
+ import { getCollection, render } from 'astro:content'
4
+ import type { NavigationTree } from '@levino/shipyard-base'
5
+ import { Breadcrumbs, TableOfContents } from '@levino/shipyard-base/components'
6
+ import BaseLayout from '@levino/shipyard-base/layouts/Page.astro'
7
+ import { Array as EffectArray, Option } from 'effect'
8
+ import { toSidebarEntries } from '../src/sidebarEntries'
9
+
10
+ interface Props {
11
+ headings?: { depth: number; text: string; slug: string }[]
12
+ }
13
+
14
+ const { headings = [] } = Astro.props
15
+
16
+ const getPath = (id: string) =>
17
+ i18n ? `/${Astro.currentLocale}/docs/${id.slice(3)}` : `/docs/${id}`
18
+
19
+ const docs = await getCollection('docs')
20
+ .then(
21
+ EffectArray.map(async (doc) => {
22
+ const {
23
+ id,
24
+ data: {
25
+ title,
26
+ sidebar: { render: shouldBeRendered, label },
27
+ sidebar_position,
28
+ sidebar_label,
29
+ sidebar_class_name,
30
+ sidebar_custom_props,
31
+ },
32
+ } = doc
33
+ return {
34
+ id,
35
+ path: getPath(id),
36
+ title:
37
+ label ??
38
+ title ??
39
+ Option.getOrUndefined(
40
+ EffectArray.findFirst(
41
+ (await render(doc)).headings,
42
+ ({ depth }) => depth === 1,
43
+ ),
44
+ )?.text ??
45
+ id,
46
+ link: shouldBeRendered,
47
+ sidebarPosition: sidebar_position,
48
+ sidebarLabel: sidebar_label,
49
+ sidebarClassName: sidebar_class_name,
50
+ sidebarCustomProps: sidebar_custom_props,
51
+ }
52
+ }),
53
+ )
54
+ .then((promises) => Promise.all(promises))
55
+
56
+ const fullTree = toSidebarEntries(docs)
57
+
58
+ const entries: NavigationTree =
59
+ i18n && Astro.currentLocale
60
+ ? (fullTree[Astro.currentLocale]?.subEntry ?? {})
61
+ : fullTree
62
+ ---
63
+
64
+ <BaseLayout sidebarNavigation={entries}>
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>
78
+ </div>
79
+ </BaseLayout>
package/astro/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default as DocsEntry } from './DocsEntry.astro'
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@levino/shipyard-docs",
3
+ "version": "0.0.0-rc-20251122114952",
4
+ "description": "",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "keywords": [],
8
+ "author": "",
9
+ "license": "ISC",
10
+ "peerDependencies": {
11
+ "astro": "^5.7"
12
+ },
13
+ "dependencies": {
14
+ "effect": "^3.12.5",
15
+ "ramda": "^0.31",
16
+ "@levino/shipyard-base": "0.0.0-rc-20251122114952"
17
+ },
18
+ "devDependencies": {
19
+ "@tailwindcss/typography": "^0.5.16",
20
+ "@types/ramda": "^0.31",
21
+ "astro": "^5.7",
22
+ "vitest": "^2.1.8"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/levino/shipyard"
27
+ },
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./layout": "./astro/Layout.astro",
31
+ "./astro": "./astro/index.ts",
32
+ "./astro/*": "./astro/*"
33
+ },
34
+ "files": [
35
+ "astro",
36
+ "src"
37
+ ]
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { AstroIntegration } from 'astro'
2
+
3
+ import { z } from 'astro/zod'
4
+
5
+ export const docsSchema = z.object({
6
+ sidebar: z
7
+ .object({
8
+ render: z.boolean().default(true),
9
+ label: z.string().optional(),
10
+ })
11
+ .default({ render: true }),
12
+ title: z.string().optional(),
13
+ description: z.string().optional(),
14
+ sidebar_position: z.number().optional(),
15
+ sidebar_label: z.string().optional(),
16
+ sidebar_class_name: z.string().optional(),
17
+ })
18
+
19
+ export default (): AstroIntegration => ({
20
+ name: 'shipyard-docs',
21
+ hooks: {
22
+ 'astro:config:setup': ({ injectRoute, config }) => {
23
+ if (config.i18n) {
24
+ // With i18n: use locale prefix
25
+ injectRoute({
26
+ pattern: `/[locale]/docs/[...slug]`,
27
+ entrypoint: `@levino/shipyard-docs/astro/DocsEntry.astro`,
28
+ })
29
+ } else {
30
+ // Without i18n: direct path
31
+ injectRoute({
32
+ pattern: `/docs/[...slug]`,
33
+ entrypoint: `@levino/shipyard-docs/astro/DocsEntry.astro`,
34
+ })
35
+ }
36
+ },
37
+ },
38
+ })
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { DocsData } from './sidebarEntries'
3
+ import { toSidebarEntries } from './sidebarEntries'
4
+
5
+ describe('toSidebarEntries', () => {
6
+ it('should create a basic sidebar structure from flat docs', () => {
7
+ const docs: DocsData[] = [
8
+ {
9
+ id: 'guide/intro.md',
10
+ title: 'Introduction',
11
+ path: '/docs/guide/intro',
12
+ },
13
+ {
14
+ id: 'guide/advanced/topic.md',
15
+ title: 'Topic',
16
+ path: '/docs/guide/advanced/topic',
17
+ },
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' },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ api: { label: 'API', href: '/docs/api' },
37
+ })
38
+ })
39
+
40
+ it('should respect sidebar_label', () => {
41
+ const docs: DocsData[] = [
42
+ {
43
+ id: 'guide/intro.md',
44
+ title: 'Introduction',
45
+ path: '/docs/guide/intro',
46
+ sidebarLabel: 'Intro',
47
+ },
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[] = [
56
+ {
57
+ id: 'guide/b.md',
58
+ title: 'B',
59
+ path: '/docs/guide/b',
60
+ sidebarPosition: 2,
61
+ },
62
+ {
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',
72
+ },
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
+ })
173
+ })
@@ -0,0 +1,120 @@
1
+ import type { Entry } from '@levino/shipyard-base'
2
+
3
+ export interface DocsData {
4
+ id: string
5
+ title: string
6
+ path: string
7
+ link?: boolean
8
+ sidebarPosition?: number
9
+ sidebarLabel?: string
10
+ sidebarClassName?: string
11
+ }
12
+
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
+ }