@levino/shipyard-docs 0.4.6 → 0.4.7-rc-20251123141510

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.
@@ -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 ? `/${Astro.currentLocale}/docs/${id.slice(3)}` : `/docs/${id}`
32
+ i18n
33
+ ? `/${Astro.currentLocale}/${normalizedBasePath}/${id.slice(3)}`
34
+ : `/${normalizedBasePath}/${id}`
18
35
 
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 ??
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
- 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
+ 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
@@ -1 +1,2 @@
1
1
  export { default as DocsEntry } from './DocsEntry.astro'
2
+ export { default as DocsLayout } from './Layout.astro'
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@levino/shipyard-docs",
3
- "version": "0.4.6",
3
+ "version": "0.4.7-rc-20251123141510",
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
- 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
- },
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'>