@levino/shipyard-docs 0.4.6 → 0.4.7-rc-20251123144832
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/Layout.astro +57 -37
- package/astro/index.ts +1 -0
- package/package.json +4 -1
- package/src/index.ts +102 -20
- package/src/routeHelpers.test.ts +121 -0
- package/src/routeHelpers.ts +78 -0
package/astro/Layout.astro
CHANGED
|
@@ -5,53 +5,73 @@ import type { NavigationTree } from '@levino/shipyard-base'
|
|
|
5
5
|
import { Breadcrumbs, TableOfContents } from '@levino/shipyard-base/components'
|
|
6
6
|
import BaseLayout from '@levino/shipyard-base/layouts/Page.astro'
|
|
7
7
|
import { Array as EffectArray, Option } from 'effect'
|
|
8
|
+
import type { DocsData } from '../src/sidebarEntries'
|
|
8
9
|
import { toSidebarEntries } from '../src/sidebarEntries'
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
headings?: { depth: number; text: string; slug: string }[]
|
|
13
|
+
/**
|
|
14
|
+
* The base path for generating doc URLs.
|
|
15
|
+
* @default 'docs'
|
|
16
|
+
*/
|
|
17
|
+
routeBasePath?: string
|
|
18
|
+
/**
|
|
19
|
+
* Pre-computed docs data for the sidebar. If provided, this will be used
|
|
20
|
+
* instead of fetching from the default 'docs' collection.
|
|
21
|
+
* Use this when you have multiple docs instances with different collections.
|
|
22
|
+
*/
|
|
23
|
+
docsData?: DocsData[]
|
|
12
24
|
}
|
|
13
25
|
|
|
14
|
-
const { headings = [] } = Astro.props
|
|
26
|
+
const { headings = [], routeBasePath = 'docs', docsData } = Astro.props
|
|
27
|
+
|
|
28
|
+
// Normalize the route base path
|
|
29
|
+
const normalizedBasePath = routeBasePath.replace(/^\/+|\/+$/g, '')
|
|
15
30
|
|
|
16
31
|
const getPath = (id: string) =>
|
|
17
|
-
i18n
|
|
32
|
+
i18n
|
|
33
|
+
? `/${Astro.currentLocale}/${normalizedBasePath}/${id.slice(3)}`
|
|
34
|
+
: `/${normalizedBasePath}/${id}`
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
label ??
|
|
38
|
-
title ??
|
|
39
|
-
Option.getOrUndefined(
|
|
40
|
-
EffectArray.findFirst(
|
|
41
|
-
(await render(doc)).headings,
|
|
42
|
-
({ depth }) => depth === 1,
|
|
43
|
-
),
|
|
44
|
-
)?.text ??
|
|
36
|
+
// Use provided docsData or fetch from the default 'docs' collection
|
|
37
|
+
const docs =
|
|
38
|
+
docsData ??
|
|
39
|
+
(await getCollection('docs')
|
|
40
|
+
.then(
|
|
41
|
+
EffectArray.map(async (doc) => {
|
|
42
|
+
const {
|
|
43
|
+
id,
|
|
44
|
+
data: {
|
|
45
|
+
title,
|
|
46
|
+
sidebar: { render: shouldBeRendered, label },
|
|
47
|
+
sidebar_position,
|
|
48
|
+
sidebar_label,
|
|
49
|
+
sidebar_class_name,
|
|
50
|
+
sidebar_custom_props,
|
|
51
|
+
},
|
|
52
|
+
} = doc
|
|
53
|
+
return {
|
|
45
54
|
id,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
path: getPath(id),
|
|
56
|
+
title:
|
|
57
|
+
label ??
|
|
58
|
+
title ??
|
|
59
|
+
Option.getOrUndefined(
|
|
60
|
+
EffectArray.findFirst(
|
|
61
|
+
(await render(doc)).headings,
|
|
62
|
+
({ depth }) => depth === 1,
|
|
63
|
+
),
|
|
64
|
+
)?.text ??
|
|
65
|
+
id,
|
|
66
|
+
link: shouldBeRendered,
|
|
67
|
+
sidebarPosition: sidebar_position,
|
|
68
|
+
sidebarLabel: sidebar_label,
|
|
69
|
+
sidebarClassName: sidebar_class_name,
|
|
70
|
+
sidebarCustomProps: sidebar_custom_props,
|
|
71
|
+
}
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
.then((promises) => Promise.all(promises)))
|
|
55
75
|
|
|
56
76
|
const fullTree = toSidebarEntries(docs)
|
|
57
77
|
|
package/astro/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-docs",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.7-rc-20251123144832",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test:unit": "vitest run"
|
|
9
|
+
},
|
|
7
10
|
"keywords": [],
|
|
8
11
|
"author": "",
|
|
9
12
|
"license": "ISC",
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { AstroIntegration } from 'astro'
|
|
2
|
-
|
|
2
|
+
import { glob } from 'astro/loaders'
|
|
3
3
|
import { z } from 'astro/zod'
|
|
4
4
|
|
|
5
|
+
export type { DocsRouteConfig } from './routeHelpers'
|
|
6
|
+
// Re-export route helpers
|
|
7
|
+
export { getDocPath, getRouteParams } from './routeHelpers'
|
|
8
|
+
// Re-export types and utilities from sidebarEntries
|
|
9
|
+
export type { DocsData } from './sidebarEntries'
|
|
10
|
+
export { toSidebarEntries } from './sidebarEntries'
|
|
11
|
+
|
|
5
12
|
export const docsSchema = z.object({
|
|
6
13
|
sidebar: z
|
|
7
14
|
.object({
|
|
@@ -16,23 +23,98 @@ export const docsSchema = z.object({
|
|
|
16
23
|
sidebar_class_name: z.string().optional(),
|
|
17
24
|
})
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Configuration for a docs instance.
|
|
28
|
+
*/
|
|
29
|
+
export interface DocsConfig {
|
|
30
|
+
/**
|
|
31
|
+
* The base path where docs routes will be mounted.
|
|
32
|
+
* @default 'docs'
|
|
33
|
+
* @example 'guides' will mount docs at /guides/[...slug]
|
|
34
|
+
*/
|
|
35
|
+
routeBasePath?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Helper function to create a docs content collection configuration.
|
|
40
|
+
* Use this in your content.config.ts to define docs collections.
|
|
41
|
+
*
|
|
42
|
+
* @param basePath - The base directory path where markdown files are located (relative to project root)
|
|
43
|
+
* @param pattern - Optional glob pattern to match files (defaults to '**\/*.md')
|
|
44
|
+
* @returns A loader and schema configuration for use with defineCollection
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { defineCollection } from 'astro:content'
|
|
49
|
+
* import { createDocsCollection } from '@levino/shipyard-docs'
|
|
50
|
+
*
|
|
51
|
+
* const docs = defineCollection(createDocsCollection('./docs'))
|
|
52
|
+
* const guides = defineCollection(createDocsCollection('./guides'))
|
|
53
|
+
*
|
|
54
|
+
* export const collections = { docs, guides }
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export const createDocsCollection = (
|
|
58
|
+
basePath: string,
|
|
59
|
+
pattern: string = '**/*.md',
|
|
60
|
+
) => ({
|
|
61
|
+
schema: docsSchema,
|
|
62
|
+
loader: glob({ pattern, base: basePath }),
|
|
38
63
|
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Shipyard Docs integration for Astro.
|
|
67
|
+
*
|
|
68
|
+
* Supports multiple documentation instances with configurable route mounting.
|
|
69
|
+
*
|
|
70
|
+
* @param config - Optional configuration for the docs instance
|
|
71
|
+
* @returns An Astro integration
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* // Single docs instance (default)
|
|
76
|
+
* shipyardDocs()
|
|
77
|
+
*
|
|
78
|
+
* // Custom route path
|
|
79
|
+
* shipyardDocs({ routeBasePath: 'guides' })
|
|
80
|
+
*
|
|
81
|
+
* // Multiple docs instances (requires custom route files - see documentation)
|
|
82
|
+
* shipyardDocs({ routeBasePath: 'docs' })
|
|
83
|
+
* shipyardDocs({ routeBasePath: 'guides' })
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export default (config: DocsConfig = {}): AstroIntegration => {
|
|
87
|
+
const { routeBasePath = 'docs' } = config
|
|
88
|
+
|
|
89
|
+
// Normalize the route base path (remove leading/trailing slashes safely)
|
|
90
|
+
let normalizedBasePath = routeBasePath
|
|
91
|
+
while (normalizedBasePath.startsWith('/')) {
|
|
92
|
+
normalizedBasePath = normalizedBasePath.slice(1)
|
|
93
|
+
}
|
|
94
|
+
while (normalizedBasePath.endsWith('/')) {
|
|
95
|
+
normalizedBasePath = normalizedBasePath.slice(0, -1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
name: `shipyard-docs${normalizedBasePath !== 'docs' ? `-${normalizedBasePath}` : ''}`,
|
|
100
|
+
hooks: {
|
|
101
|
+
'astro:config:setup': ({ injectRoute, config: astroConfig }) => {
|
|
102
|
+
if (astroConfig.i18n) {
|
|
103
|
+
// With i18n: use locale prefix
|
|
104
|
+
injectRoute({
|
|
105
|
+
pattern: `/[locale]/${normalizedBasePath}/[...slug]`,
|
|
106
|
+
entrypoint: `@levino/shipyard-docs/astro/DocsEntry.astro`,
|
|
107
|
+
prerender: true,
|
|
108
|
+
})
|
|
109
|
+
} else {
|
|
110
|
+
// Without i18n: direct path
|
|
111
|
+
injectRoute({
|
|
112
|
+
pattern: `/${normalizedBasePath}/[...slug]`,
|
|
113
|
+
entrypoint: `@levino/shipyard-docs/astro/DocsEntry.astro`,
|
|
114
|
+
prerender: true,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { getDocPath, getRouteParams } from './routeHelpers'
|
|
3
|
+
|
|
4
|
+
describe('getRouteParams', () => {
|
|
5
|
+
describe('without i18n', () => {
|
|
6
|
+
it('should return slug from simple path', () => {
|
|
7
|
+
const params = getRouteParams('getting-started', false)
|
|
8
|
+
expect(params).toEqual({ slug: 'getting-started' })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('should return slug from nested path', () => {
|
|
12
|
+
const params = getRouteParams('guides/advanced/topic', false)
|
|
13
|
+
expect(params).toEqual({ slug: 'guides/advanced/topic' })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should return undefined slug for empty path', () => {
|
|
17
|
+
const params = getRouteParams('', false)
|
|
18
|
+
expect(params).toEqual({ slug: undefined })
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('with i18n', () => {
|
|
23
|
+
it('should extract locale and slug from path', () => {
|
|
24
|
+
const params = getRouteParams('en/getting-started', true)
|
|
25
|
+
expect(params).toEqual({ locale: 'en', slug: 'getting-started' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should extract locale and nested slug', () => {
|
|
29
|
+
const params = getRouteParams('de/guides/advanced/topic', true)
|
|
30
|
+
expect(params).toEqual({ locale: 'de', slug: 'guides/advanced/topic' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should handle locale-only path (index page)', () => {
|
|
34
|
+
const params = getRouteParams('en', true)
|
|
35
|
+
expect(params).toEqual({ locale: 'en', slug: undefined })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should handle path with locale and direct page', () => {
|
|
39
|
+
const params = getRouteParams('fr/intro', true)
|
|
40
|
+
expect(params).toEqual({ locale: 'fr', slug: 'intro' })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('getDocPath', () => {
|
|
46
|
+
describe('without i18n', () => {
|
|
47
|
+
it('should generate path with default base path', () => {
|
|
48
|
+
const path = getDocPath('getting-started', 'docs', false)
|
|
49
|
+
expect(path).toBe('/docs/getting-started')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should generate path with custom base path', () => {
|
|
53
|
+
const path = getDocPath('getting-started', 'guides', false)
|
|
54
|
+
expect(path).toBe('/guides/getting-started')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should handle nested paths', () => {
|
|
58
|
+
const path = getDocPath('api/endpoints/users', 'docs', false)
|
|
59
|
+
expect(path).toBe('/docs/api/endpoints/users')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should normalize base path with leading slash', () => {
|
|
63
|
+
const path = getDocPath('intro', '/guides', false)
|
|
64
|
+
expect(path).toBe('/guides/intro')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should normalize base path with trailing slash', () => {
|
|
68
|
+
const path = getDocPath('intro', 'guides/', false)
|
|
69
|
+
expect(path).toBe('/guides/intro')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should normalize base path with both slashes', () => {
|
|
73
|
+
const path = getDocPath('intro', '/guides/', false)
|
|
74
|
+
expect(path).toBe('/guides/intro')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle multiple leading/trailing slashes', () => {
|
|
78
|
+
const path = getDocPath('intro', '///guides///', false)
|
|
79
|
+
expect(path).toBe('/guides/intro')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('with i18n', () => {
|
|
84
|
+
it('should generate localized path', () => {
|
|
85
|
+
const path = getDocPath('en/getting-started', 'docs', true, 'en')
|
|
86
|
+
expect(path).toBe('/en/docs/getting-started')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should generate localized path for different locale', () => {
|
|
90
|
+
const path = getDocPath('de/einfuehrung', 'docs', true, 'de')
|
|
91
|
+
expect(path).toBe('/de/docs/einfuehrung')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should generate localized path with custom base path', () => {
|
|
95
|
+
const path = getDocPath('en/tutorial', 'guides', true, 'en')
|
|
96
|
+
expect(path).toBe('/en/guides/tutorial')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should handle nested paths with locale', () => {
|
|
100
|
+
const path = getDocPath('en/api/endpoints/users', 'docs', true, 'en')
|
|
101
|
+
expect(path).toBe('/en/docs/api/endpoints/users')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should handle index page (id without nested path)', () => {
|
|
105
|
+
const path = getDocPath('en', 'docs', true, 'en')
|
|
106
|
+
expect(path).toBe('/en/docs/en')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('edge cases', () => {
|
|
111
|
+
it('should handle empty id without i18n', () => {
|
|
112
|
+
const path = getDocPath('', 'docs', false)
|
|
113
|
+
expect(path).toBe('/docs/')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should handle deeply nested base path', () => {
|
|
117
|
+
const path = getDocPath('intro', 'api/reference/v2', false)
|
|
118
|
+
expect(path).toBe('/api/reference/v2/intro')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CollectionEntry } from 'astro:content'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Configuration for generating static paths for a docs collection.
|
|
5
|
+
*/
|
|
6
|
+
export interface DocsRouteConfig {
|
|
7
|
+
/**
|
|
8
|
+
* The base path for the docs routes (without leading/trailing slashes).
|
|
9
|
+
* @example 'docs', 'guides', 'api/reference'
|
|
10
|
+
*/
|
|
11
|
+
routeBasePath: string
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether i18n is enabled for this docs instance.
|
|
15
|
+
* When true, expects docs to have locale prefixes in their IDs.
|
|
16
|
+
*/
|
|
17
|
+
hasI18n: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate route parameters from a doc entry's slug.
|
|
22
|
+
*
|
|
23
|
+
* @param slug - The slug from the doc entry (e.g., 'en/getting-started' or 'getting-started')
|
|
24
|
+
* @param hasI18n - Whether i18n is enabled
|
|
25
|
+
* @returns Route parameters object
|
|
26
|
+
*/
|
|
27
|
+
export const getRouteParams = (slug: string, hasI18n: boolean) => {
|
|
28
|
+
if (hasI18n) {
|
|
29
|
+
const [locale, ...rest] = slug.split('/')
|
|
30
|
+
return {
|
|
31
|
+
slug: rest.length ? rest.join('/') : undefined,
|
|
32
|
+
locale,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
slug: slug || undefined,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate the full URL path for a doc entry.
|
|
42
|
+
*
|
|
43
|
+
* @param id - The doc entry ID
|
|
44
|
+
* @param routeBasePath - The base path for routes
|
|
45
|
+
* @param hasI18n - Whether i18n is enabled
|
|
46
|
+
* @param currentLocale - The current locale (for i18n)
|
|
47
|
+
* @returns The full URL path
|
|
48
|
+
*/
|
|
49
|
+
export const getDocPath = (
|
|
50
|
+
id: string,
|
|
51
|
+
routeBasePath: string,
|
|
52
|
+
hasI18n: boolean,
|
|
53
|
+
currentLocale?: string,
|
|
54
|
+
) => {
|
|
55
|
+
// Remove leading and trailing slashes safely (avoid polynomial regex)
|
|
56
|
+
let normalizedBasePath = routeBasePath
|
|
57
|
+
while (normalizedBasePath.startsWith('/')) {
|
|
58
|
+
normalizedBasePath = normalizedBasePath.slice(1)
|
|
59
|
+
}
|
|
60
|
+
while (normalizedBasePath.endsWith('/')) {
|
|
61
|
+
normalizedBasePath = normalizedBasePath.slice(0, -1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (hasI18n && currentLocale) {
|
|
65
|
+
// Remove locale prefix from id (e.g., 'en/guide/intro' -> 'guide/intro')
|
|
66
|
+
const pathWithoutLocale = id.includes('/')
|
|
67
|
+
? id.slice(id.indexOf('/') + 1)
|
|
68
|
+
: id
|
|
69
|
+
return `/${currentLocale}/${normalizedBasePath}/${pathWithoutLocale}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return `/${normalizedBasePath}/${id}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Type for docs collection entry with inferred data structure.
|
|
77
|
+
*/
|
|
78
|
+
export type DocsEntry = CollectionEntry<'docs'>
|