@levino/shipyard-docs 0.8.4 → 0.8.5-rc-20260529092234
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 +105 -51
- package/astro/pages/DocsEntry.astro +71 -0
- package/astro/pages/DocsVersionRedirect.astro +28 -0
- package/package.json +2 -2
- package/src/docsStaticPaths.test.ts +154 -0
- package/src/docsStaticPaths.ts +226 -0
- package/src/index.ts +20 -299
package/astro/DocsEntry.astro
CHANGED
|
@@ -5,41 +5,17 @@ import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
|
5
5
|
import { getEditUrl, getGitMetadata } from '../src/gitMetadata'
|
|
6
6
|
import Layout from './Layout.astro'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const [locale, ...rest] = slug.split('/')
|
|
17
|
-
return {
|
|
18
|
-
slug: rest.length ? rest.join('/') : undefined,
|
|
19
|
-
locale,
|
|
20
|
-
}
|
|
21
|
-
} else {
|
|
22
|
-
// For non-i18n, treat the entire slug as the path
|
|
23
|
-
return {
|
|
24
|
-
slug: slug || undefined,
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return docs.map((doc) => ({
|
|
30
|
-
params: getParams(doc.id),
|
|
31
|
-
props: { entry: doc, routeBasePath: basePath },
|
|
32
|
-
}))
|
|
33
|
-
}),
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
return allPaths.flat()
|
|
8
|
+
interface Props {
|
|
9
|
+
entry?: Awaited<ReturnType<typeof getCollection>>[number]
|
|
10
|
+
routeBasePath?: string
|
|
11
|
+
collectionName?: string
|
|
12
|
+
version?: string
|
|
13
|
+
actualVersion?: string
|
|
14
|
+
isLatestAlias?: boolean
|
|
15
|
+
docLocale?: string
|
|
37
16
|
}
|
|
38
17
|
|
|
39
|
-
|
|
40
|
-
const { entry, routeBasePath } = Astro.props
|
|
41
|
-
|
|
42
|
-
// Get the config for this specific docs instance
|
|
18
|
+
const routeBasePath = Astro.props.routeBasePath ?? 'docs'
|
|
43
19
|
const docsConfig = docsConfigs[routeBasePath] ??
|
|
44
20
|
docsConfigs.docs ?? {
|
|
45
21
|
showLastUpdateTime: false,
|
|
@@ -47,20 +23,88 @@ const docsConfig = docsConfigs[routeBasePath] ??
|
|
|
47
23
|
routeBasePath: 'docs',
|
|
48
24
|
collectionName: 'docs',
|
|
49
25
|
}
|
|
26
|
+
const collectionName = Astro.props.collectionName ?? docsConfig.collectionName
|
|
27
|
+
|
|
28
|
+
// The /latest/ alias redirect is handled by the route wrapper
|
|
29
|
+
// (astro/pages/DocsEntry.astro) before this component renders, because a
|
|
30
|
+
// Response returned from a child component is not used as the page response.
|
|
31
|
+
|
|
32
|
+
// In SSR mode (prerender: false), getStaticPaths is not called so Astro.props
|
|
33
|
+
// will be empty. We need to fetch the entry from the collection based on URL
|
|
34
|
+
// params.
|
|
35
|
+
let { entry } = Astro.props
|
|
36
|
+
const { slug: pageSlug, locale, version: urlVersion } = Astro.params
|
|
37
|
+
|
|
38
|
+
if (!entry) {
|
|
39
|
+
const allDocs = await getCollection(collectionName as CollectionKey)
|
|
40
|
+
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
41
|
+
|
|
42
|
+
// Reconstruct the entry ID from URL params
|
|
43
|
+
let entryId: string
|
|
44
|
+
if (i18n && locale) {
|
|
45
|
+
// For versioned docs, include version in the entry ID
|
|
46
|
+
if (urlVersion) {
|
|
47
|
+
entryId = pageSlug
|
|
48
|
+
? `${urlVersion}/${locale}/${pageSlug}`
|
|
49
|
+
: `${urlVersion}/${locale}`
|
|
50
|
+
} else {
|
|
51
|
+
entryId = pageSlug ? `${locale}/${pageSlug}` : locale
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
if (urlVersion) {
|
|
55
|
+
entryId = pageSlug ? `${urlVersion}/${pageSlug}` : urlVersion
|
|
56
|
+
} else {
|
|
57
|
+
entryId = pageSlug ?? ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find the matching entry
|
|
62
|
+
entry = docs.find((doc) => doc.id === entryId)
|
|
63
|
+
|
|
64
|
+
// If no exact match, try matching with /index suffix (for index pages)
|
|
65
|
+
// For empty entryId (root path like /docs), look for 'index'
|
|
66
|
+
// For category paths (like /docs/details), look for 'details/index'
|
|
67
|
+
if (!entry) {
|
|
68
|
+
const indexEntryId = entryId ? `${entryId}/index` : 'index'
|
|
69
|
+
entry = docs.find((doc) => doc.id === indexEntryId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If still no match, return 404
|
|
73
|
+
if (!entry) {
|
|
74
|
+
return Astro.redirect('/404')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
50
77
|
|
|
51
78
|
const { Content, headings } = await render(entry)
|
|
52
79
|
|
|
53
|
-
|
|
54
|
-
|
|
80
|
+
const {
|
|
81
|
+
customEditUrl,
|
|
82
|
+
lastUpdateAuthor,
|
|
83
|
+
lastUpdateTime,
|
|
84
|
+
hideTableOfContents,
|
|
85
|
+
hideTitle,
|
|
86
|
+
keywords,
|
|
87
|
+
image,
|
|
88
|
+
canonicalUrl,
|
|
89
|
+
customMetaTags,
|
|
90
|
+
title,
|
|
91
|
+
title_meta,
|
|
92
|
+
description,
|
|
93
|
+
} = entry.data
|
|
94
|
+
|
|
95
|
+
// Use title_meta for the <title>/og:title if provided, otherwise fall back to
|
|
96
|
+
// the reference title. Without this fallback, docs pages that only define
|
|
97
|
+
// `title` render an empty <title>/og:title and broken social previews.
|
|
98
|
+
const titleMeta = title_meta ?? title
|
|
55
99
|
|
|
56
100
|
// Compute edit URL
|
|
57
101
|
let editUrl: string | undefined
|
|
58
|
-
if (
|
|
102
|
+
if (customEditUrl === null) {
|
|
59
103
|
// Explicitly disabled for this page
|
|
60
104
|
editUrl = undefined
|
|
61
|
-
} else if (
|
|
105
|
+
} else if (customEditUrl) {
|
|
62
106
|
// Custom URL provided
|
|
63
|
-
editUrl =
|
|
107
|
+
editUrl = customEditUrl
|
|
64
108
|
} else if (entry.filePath) {
|
|
65
109
|
// Use filePath instead of entry.id because Astro's glob loader
|
|
66
110
|
// strips "index" from entry.id for index pages (e.g., en/index -> en)
|
|
@@ -80,35 +124,45 @@ let lastUpdated: Date | undefined
|
|
|
80
124
|
let lastAuthor: string | undefined
|
|
81
125
|
|
|
82
126
|
if (
|
|
83
|
-
(docsConfig.showLastUpdateTime &&
|
|
84
|
-
(docsConfig.showLastUpdateAuthor &&
|
|
127
|
+
(docsConfig.showLastUpdateTime && lastUpdateTime !== false) ||
|
|
128
|
+
(docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false)
|
|
85
129
|
) {
|
|
86
|
-
// We need to get the file path for git metadata
|
|
87
|
-
// The entry.filePath gives us the absolute path to the markdown file
|
|
88
130
|
const filePath = entry.filePath
|
|
89
131
|
|
|
90
132
|
if (filePath) {
|
|
91
133
|
const gitMetadata = getGitMetadata(filePath)
|
|
92
134
|
|
|
93
|
-
|
|
94
|
-
if (docsConfig.showLastUpdateTime && last_update_time !== false) {
|
|
135
|
+
if (docsConfig.showLastUpdateTime && lastUpdateTime !== false) {
|
|
95
136
|
lastUpdated =
|
|
96
|
-
|
|
97
|
-
?
|
|
137
|
+
lastUpdateTime instanceof Date
|
|
138
|
+
? lastUpdateTime
|
|
98
139
|
: gitMetadata.lastUpdated
|
|
99
140
|
}
|
|
100
141
|
|
|
101
|
-
|
|
102
|
-
if (docsConfig.showLastUpdateAuthor && last_update_author !== false) {
|
|
142
|
+
if (docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false) {
|
|
103
143
|
lastAuthor =
|
|
104
|
-
typeof
|
|
105
|
-
?
|
|
144
|
+
typeof lastUpdateAuthor === 'string'
|
|
145
|
+
? lastUpdateAuthor
|
|
106
146
|
: gitMetadata.lastAuthor
|
|
107
147
|
}
|
|
108
148
|
}
|
|
109
149
|
}
|
|
110
150
|
---
|
|
111
151
|
|
|
112
|
-
<Layout
|
|
152
|
+
<Layout
|
|
153
|
+
headings={headings}
|
|
154
|
+
routeBasePath={routeBasePath}
|
|
155
|
+
editUrl={editUrl}
|
|
156
|
+
lastUpdated={lastUpdated}
|
|
157
|
+
lastAuthor={lastAuthor}
|
|
158
|
+
hideTableOfContents={hideTableOfContents}
|
|
159
|
+
hideTitle={hideTitle}
|
|
160
|
+
keywords={keywords}
|
|
161
|
+
image={image}
|
|
162
|
+
canonicalUrl={canonicalUrl}
|
|
163
|
+
customMetaTags={customMetaTags}
|
|
164
|
+
titleMeta={titleMeta}
|
|
165
|
+
description={description}
|
|
166
|
+
>
|
|
113
167
|
<Content />
|
|
114
168
|
</Layout>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { i18n } from 'astro:config/server'
|
|
3
|
+
import { type CollectionKey, getCollection } from 'astro:content'
|
|
4
|
+
import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
5
|
+
import type { GetStaticPaths } from 'astro'
|
|
6
|
+
import {
|
|
7
|
+
buildLatestAliasRedirect,
|
|
8
|
+
computeDocsEntryPaths,
|
|
9
|
+
getDocsInstanceConfig,
|
|
10
|
+
renderLatestAliasRedirectHtml,
|
|
11
|
+
} from '../../src/docsStaticPaths'
|
|
12
|
+
import DocsEntry from '../DocsEntry.astro'
|
|
13
|
+
|
|
14
|
+
export const getStaticPaths = (async ({ routePattern }) => {
|
|
15
|
+
const instance = getDocsInstanceConfig(routePattern, docsConfigs)
|
|
16
|
+
const allDocs = await getCollection(instance.collectionName as CollectionKey)
|
|
17
|
+
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
18
|
+
return computeDocsEntryPaths(docs, {
|
|
19
|
+
collectionName: instance.collectionName,
|
|
20
|
+
routeBasePath: instance.routeBasePath,
|
|
21
|
+
versions: instance.versions,
|
|
22
|
+
hasI18n: !!i18n,
|
|
23
|
+
})
|
|
24
|
+
}) satisfies GetStaticPaths
|
|
25
|
+
|
|
26
|
+
// getStaticPaths runs in a separate module scope, so derive the instance again
|
|
27
|
+
// here from the route pattern to resolve collectionName/routeBasePath.
|
|
28
|
+
const instance = getDocsInstanceConfig(Astro.routePattern, docsConfigs)
|
|
29
|
+
const resolvedCollectionName =
|
|
30
|
+
Astro.props.collectionName ?? instance.collectionName
|
|
31
|
+
const resolvedRouteBasePath =
|
|
32
|
+
Astro.props.routeBasePath ?? instance.routeBasePath
|
|
33
|
+
|
|
34
|
+
// SEO-friendly redirect for prerendered /latest/ alias URLs to canonical
|
|
35
|
+
// version URLs. This must happen in the page (route) component — a Response
|
|
36
|
+
// returned from a child component is not used as the page response.
|
|
37
|
+
const { slug: pageSlug } = Astro.params
|
|
38
|
+
const redirectInfo = buildLatestAliasRedirect({
|
|
39
|
+
isLatestAlias: Astro.props.isLatestAlias,
|
|
40
|
+
actualVersion: Astro.props.actualVersion,
|
|
41
|
+
routeBasePath: resolvedRouteBasePath,
|
|
42
|
+
docLocale: Astro.props.docLocale,
|
|
43
|
+
pageSlug: typeof pageSlug === 'string' ? pageSlug : undefined,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (redirectInfo) {
|
|
47
|
+
const canonicalHref = Astro.site
|
|
48
|
+
? new URL(redirectInfo.targetUrl, Astro.site).href
|
|
49
|
+
: redirectInfo.targetUrl
|
|
50
|
+
return new Response(
|
|
51
|
+
renderLatestAliasRedirectHtml(
|
|
52
|
+
redirectInfo.targetUrl,
|
|
53
|
+
redirectInfo.fromUrl,
|
|
54
|
+
canonicalHref,
|
|
55
|
+
),
|
|
56
|
+
{
|
|
57
|
+
status: 301,
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
60
|
+
Location: redirectInfo.targetUrl,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
<DocsEntry
|
|
68
|
+
{...Astro.props}
|
|
69
|
+
routeBasePath={resolvedRouteBasePath}
|
|
70
|
+
collectionName={resolvedCollectionName}
|
|
71
|
+
/>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { i18n } from 'astro:config/server'
|
|
3
|
+
import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
4
|
+
import type { GetStaticPaths } from 'astro'
|
|
5
|
+
import {
|
|
6
|
+
getDocsInstanceConfig,
|
|
7
|
+
getDocsRedirectPaths,
|
|
8
|
+
} from '../../src/docsStaticPaths'
|
|
9
|
+
|
|
10
|
+
export const getStaticPaths = (() => {
|
|
11
|
+
return getDocsRedirectPaths(i18n)
|
|
12
|
+
}) satisfies GetStaticPaths
|
|
13
|
+
|
|
14
|
+
const instance = getDocsInstanceConfig(Astro.routePattern, docsConfigs)
|
|
15
|
+
const routeBasePath = instance.routeBasePath
|
|
16
|
+
const versions = instance.versions
|
|
17
|
+
const currentVersionPath =
|
|
18
|
+
versions?.available.find((v) => v.version === versions.current)?.path ??
|
|
19
|
+
versions?.current
|
|
20
|
+
|
|
21
|
+
const { locale } = Astro.params
|
|
22
|
+
|
|
23
|
+
const targetUrl = locale
|
|
24
|
+
? `/${locale}/${routeBasePath}/${currentVersionPath}/`
|
|
25
|
+
: `/${routeBasePath}/${currentVersionPath}/`
|
|
26
|
+
|
|
27
|
+
return Astro.redirect(targetUrl, 302)
|
|
28
|
+
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-docs",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5-rc-20260529092234",
|
|
4
4
|
"description": "Documentation plugin for shipyard with automatic sidebar, pagination, and git metadata",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"effect": "^3.20.0",
|
|
28
28
|
"ramda": "^0.31",
|
|
29
29
|
"unist-util-visit": "^5.0.0",
|
|
30
|
-
"@levino/shipyard-base": "
|
|
30
|
+
"@levino/shipyard-base": "0.8.5-rc-20260529092234"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@tailwindcss/typography": "^0.5.16",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
computeDocsEntryPaths,
|
|
4
|
+
type DocsConfigsRegistry,
|
|
5
|
+
type DocsEntryLike,
|
|
6
|
+
getDocsInstanceConfig,
|
|
7
|
+
} from './docsStaticPaths'
|
|
8
|
+
import type { VersionConfig } from './index'
|
|
9
|
+
|
|
10
|
+
const makeConfig = (
|
|
11
|
+
routeBasePath: string,
|
|
12
|
+
versions?: VersionConfig,
|
|
13
|
+
): DocsConfigsRegistry[string] => ({
|
|
14
|
+
showLastUpdateTime: false,
|
|
15
|
+
showLastUpdateAuthor: false,
|
|
16
|
+
routeBasePath,
|
|
17
|
+
collectionName: routeBasePath,
|
|
18
|
+
llmsTxtEnabled: false,
|
|
19
|
+
versions,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('getDocsInstanceConfig', () => {
|
|
23
|
+
const registry: DocsConfigsRegistry = {
|
|
24
|
+
docs: makeConfig('docs'),
|
|
25
|
+
'api/reference': makeConfig('api/reference'),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('matches non-i18n route pattern', () => {
|
|
29
|
+
expect(
|
|
30
|
+
getDocsInstanceConfig('/docs/[...slug]', registry).routeBasePath,
|
|
31
|
+
).toBe('docs')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('matches i18n route pattern', () => {
|
|
35
|
+
expect(
|
|
36
|
+
getDocsInstanceConfig('/[locale]/docs/[...slug]', registry).routeBasePath,
|
|
37
|
+
).toBe('docs')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('matches multi-segment basePath', () => {
|
|
41
|
+
expect(
|
|
42
|
+
getDocsInstanceConfig('/api/reference/[...slug]', registry).routeBasePath,
|
|
43
|
+
).toBe('api/reference')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('matches multi-segment basePath with i18n', () => {
|
|
47
|
+
expect(
|
|
48
|
+
getDocsInstanceConfig('/[locale]/api/reference/[...slug]', registry)
|
|
49
|
+
.routeBasePath,
|
|
50
|
+
).toBe('api/reference')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('matches versioned route pattern', () => {
|
|
54
|
+
expect(
|
|
55
|
+
getDocsInstanceConfig('/docs/[version]/[...slug]', registry)
|
|
56
|
+
.routeBasePath,
|
|
57
|
+
).toBe('docs')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('matches the bare basePath pattern (redirect route)', () => {
|
|
61
|
+
expect(
|
|
62
|
+
getDocsInstanceConfig('/[locale]/docs', registry).routeBasePath,
|
|
63
|
+
).toBe('docs')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('throws when no instance matches', () => {
|
|
67
|
+
expect(() => getDocsInstanceConfig('/blog/[...slug]', registry)).toThrow(
|
|
68
|
+
/No docs instance found/,
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('computeDocsEntryPaths', () => {
|
|
74
|
+
const docs: DocsEntryLike[] = [
|
|
75
|
+
{ id: 'getting-started', data: {} },
|
|
76
|
+
{ id: 'guides/intro', data: {} },
|
|
77
|
+
{ id: 'hidden', data: { render: false } },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
it('computes non-i18n paths', () => {
|
|
81
|
+
const paths = computeDocsEntryPaths(docs, {
|
|
82
|
+
collectionName: 'docs',
|
|
83
|
+
routeBasePath: 'docs',
|
|
84
|
+
hasI18n: false,
|
|
85
|
+
})
|
|
86
|
+
expect(paths).toHaveLength(2)
|
|
87
|
+
expect(paths[0].params).toEqual({ slug: 'getting-started' })
|
|
88
|
+
expect(paths[0].props.routeBasePath).toBe('docs')
|
|
89
|
+
expect(paths[0].props.isLatestAlias).toBe(false)
|
|
90
|
+
expect(paths[1].params).toEqual({ slug: 'guides/intro' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('computes i18n paths with locale + slug split', () => {
|
|
94
|
+
const i18nDocs: DocsEntryLike[] = [
|
|
95
|
+
{ id: 'en/getting-started', data: {} },
|
|
96
|
+
// Astro's glob loader strips "index" so an index page has id "en"
|
|
97
|
+
{ id: 'en', data: {} },
|
|
98
|
+
]
|
|
99
|
+
const paths = computeDocsEntryPaths(i18nDocs, {
|
|
100
|
+
collectionName: 'docs',
|
|
101
|
+
routeBasePath: 'docs',
|
|
102
|
+
hasI18n: true,
|
|
103
|
+
})
|
|
104
|
+
expect(paths).toHaveLength(2)
|
|
105
|
+
expect(paths[0].params).toEqual({ locale: 'en', slug: 'getting-started' })
|
|
106
|
+
expect(paths[0].props.docLocale).toBe('en')
|
|
107
|
+
// "en" (locale-root index) -> slug undefined
|
|
108
|
+
expect(paths[1].params).toEqual({ locale: 'en', slug: undefined })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('generates a latest alias for current-version docs', () => {
|
|
112
|
+
const versions: VersionConfig = {
|
|
113
|
+
current: 'v2.0',
|
|
114
|
+
available: [
|
|
115
|
+
{ version: 'v2.0', path: 'v2' },
|
|
116
|
+
{ version: 'v1.0', path: 'v1' },
|
|
117
|
+
],
|
|
118
|
+
deprecated: [],
|
|
119
|
+
}
|
|
120
|
+
const versionedDocs: DocsEntryLike[] = [
|
|
121
|
+
{ id: 'v2.0/en/getting-started', data: {} },
|
|
122
|
+
{ id: 'v1.0/en/getting-started', data: {} },
|
|
123
|
+
]
|
|
124
|
+
const paths = computeDocsEntryPaths(versionedDocs, {
|
|
125
|
+
collectionName: 'docs',
|
|
126
|
+
routeBasePath: 'docs',
|
|
127
|
+
versions,
|
|
128
|
+
hasI18n: true,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// v2.0 doc -> main (version path 'v2') + latest alias
|
|
132
|
+
// v1.0 doc -> main only ('v1')
|
|
133
|
+
expect(paths).toHaveLength(3)
|
|
134
|
+
|
|
135
|
+
const v2Main = paths.find(
|
|
136
|
+
(p) => p.params.version === 'v2' && !p.props.isLatestAlias,
|
|
137
|
+
)
|
|
138
|
+
expect(v2Main).toBeDefined()
|
|
139
|
+
expect(v2Main?.params).toEqual({
|
|
140
|
+
locale: 'en',
|
|
141
|
+
slug: 'getting-started',
|
|
142
|
+
version: 'v2',
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const latestAlias = paths.find((p) => p.props.isLatestAlias)
|
|
146
|
+
expect(latestAlias).toBeDefined()
|
|
147
|
+
expect(latestAlias?.params.version).toBe('latest')
|
|
148
|
+
expect(latestAlias?.props.actualVersion).toBe('v2')
|
|
149
|
+
|
|
150
|
+
const v1Main = paths.find((p) => p.params.version === 'v1')
|
|
151
|
+
expect(v1Main).toBeDefined()
|
|
152
|
+
expect(v1Main?.props.isLatestAlias).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { VersionConfig } from './index'
|
|
2
|
+
import { createVersionPathMap } from './routeHelpers'
|
|
3
|
+
import { getVersionFromDocId, stripVersionFromDocId } from './versionHelpers'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal shape of a docs collection entry needed for path computation.
|
|
7
|
+
*/
|
|
8
|
+
export interface DocsEntryLike {
|
|
9
|
+
id: string
|
|
10
|
+
data: {
|
|
11
|
+
render?: boolean
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface I18nConfig {
|
|
16
|
+
locales: (string | { codes: string[] })[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shape of a single docs instance config stored in the
|
|
21
|
+
* `virtual:shipyard-docs-configs` registry.
|
|
22
|
+
*/
|
|
23
|
+
export interface DocsInstanceConfig {
|
|
24
|
+
editUrl?: string
|
|
25
|
+
showLastUpdateTime: boolean
|
|
26
|
+
showLastUpdateAuthor: boolean
|
|
27
|
+
routeBasePath: string
|
|
28
|
+
collectionName: string
|
|
29
|
+
llmsTxtEnabled: boolean
|
|
30
|
+
versions?: VersionConfig
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DocsConfigsRegistry = Record<string, DocsInstanceConfig>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve which docs instance a given route pattern belongs to.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the blog package's `getInstanceConfig`: strips an optional
|
|
39
|
+
* `/[locale]/` prefix and the leading slash, then matches against the known
|
|
40
|
+
* basePaths in the registry. basePaths may contain slashes (e.g.
|
|
41
|
+
* `api/reference`), so we match against registered keys rather than splitting
|
|
42
|
+
* on `/`.
|
|
43
|
+
*/
|
|
44
|
+
export const getDocsInstanceConfig = (
|
|
45
|
+
routePattern: string,
|
|
46
|
+
docsConfigs: DocsConfigsRegistry,
|
|
47
|
+
): DocsInstanceConfig => {
|
|
48
|
+
const stripped = routePattern.replace(/^\/?(\[locale\]\/)?/, '')
|
|
49
|
+
for (const [basePath, config] of Object.entries(docsConfigs)) {
|
|
50
|
+
if (stripped === basePath || stripped.startsWith(`${basePath}/`)) {
|
|
51
|
+
return config
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`No docs instance found for route pattern: ${routePattern}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ComputeDocsEntryPathsOptions {
|
|
58
|
+
collectionName: string
|
|
59
|
+
routeBasePath: string
|
|
60
|
+
versions?: VersionConfig | null
|
|
61
|
+
hasI18n: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute the static paths for the DocsEntry route.
|
|
66
|
+
*
|
|
67
|
+
* Behaviour is identical to the previously generated `getStaticPaths`:
|
|
68
|
+
* - one main path per rendered doc
|
|
69
|
+
* - for versioned docs that belong to the current version, an additional
|
|
70
|
+
* `latest` alias path that redirects to the canonical version URL
|
|
71
|
+
*/
|
|
72
|
+
export const computeDocsEntryPaths = (
|
|
73
|
+
allDocs: readonly DocsEntryLike[],
|
|
74
|
+
{
|
|
75
|
+
collectionName: _collectionName,
|
|
76
|
+
routeBasePath,
|
|
77
|
+
versions,
|
|
78
|
+
hasI18n,
|
|
79
|
+
}: ComputeDocsEntryPathsOptions,
|
|
80
|
+
) => {
|
|
81
|
+
const hasVersions = !!versions
|
|
82
|
+
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
83
|
+
|
|
84
|
+
const versionPathMap =
|
|
85
|
+
hasVersions && versions ? createVersionPathMap(versions) : null
|
|
86
|
+
|
|
87
|
+
const getParams = (slug: string, version?: string) => {
|
|
88
|
+
if (hasI18n) {
|
|
89
|
+
const [locale, ...rest] = slug.split('/')
|
|
90
|
+
const baseParams = {
|
|
91
|
+
slug: rest.length ? rest.join('/') : undefined,
|
|
92
|
+
locale,
|
|
93
|
+
}
|
|
94
|
+
return version ? { ...baseParams, version } : baseParams
|
|
95
|
+
}
|
|
96
|
+
const baseParams = {
|
|
97
|
+
slug: slug || undefined,
|
|
98
|
+
}
|
|
99
|
+
return version ? { ...baseParams, version } : baseParams
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const paths: {
|
|
103
|
+
params: Record<string, string | undefined>
|
|
104
|
+
props: {
|
|
105
|
+
entry: DocsEntryLike
|
|
106
|
+
routeBasePath: string
|
|
107
|
+
version?: string
|
|
108
|
+
actualVersion?: string
|
|
109
|
+
isLatestAlias: boolean
|
|
110
|
+
docLocale?: string
|
|
111
|
+
}
|
|
112
|
+
}[] = []
|
|
113
|
+
|
|
114
|
+
for (const entry of docs) {
|
|
115
|
+
let version: string | undefined
|
|
116
|
+
let docIdWithoutVersion = entry.id
|
|
117
|
+
|
|
118
|
+
if (hasVersions && versions && versionPathMap) {
|
|
119
|
+
const extractedVersion = getVersionFromDocId(entry.id)
|
|
120
|
+
if (extractedVersion) {
|
|
121
|
+
version = versionPathMap.get(extractedVersion) ?? extractedVersion
|
|
122
|
+
docIdWithoutVersion = stripVersionFromDocId(entry.id)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const docLocale = hasI18n ? docIdWithoutVersion.split('/')[0] : undefined
|
|
127
|
+
|
|
128
|
+
paths.push({
|
|
129
|
+
params: getParams(docIdWithoutVersion, version),
|
|
130
|
+
props: {
|
|
131
|
+
entry,
|
|
132
|
+
routeBasePath,
|
|
133
|
+
version,
|
|
134
|
+
isLatestAlias: false,
|
|
135
|
+
docLocale,
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if (hasVersions && versions && version) {
|
|
140
|
+
const extractedVersion = getVersionFromDocId(entry.id)
|
|
141
|
+
const currentVersion = versions.current
|
|
142
|
+
if (extractedVersion === currentVersion) {
|
|
143
|
+
paths.push({
|
|
144
|
+
params: getParams(docIdWithoutVersion, 'latest'),
|
|
145
|
+
props: {
|
|
146
|
+
entry,
|
|
147
|
+
routeBasePath,
|
|
148
|
+
version: 'latest',
|
|
149
|
+
actualVersion: version,
|
|
150
|
+
isLatestAlias: true,
|
|
151
|
+
docLocale,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return paths
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface LatestAliasRedirectInput {
|
|
162
|
+
isLatestAlias?: boolean
|
|
163
|
+
actualVersion?: string
|
|
164
|
+
routeBasePath: string
|
|
165
|
+
docLocale?: string
|
|
166
|
+
pageSlug?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build the SEO-friendly redirect descriptor for a `/latest/` alias URL.
|
|
171
|
+
* Returns `null` when the current request is not a latest alias.
|
|
172
|
+
*
|
|
173
|
+
* The `/latest/` alias paths are only generated as static paths (props come
|
|
174
|
+
* from getStaticPaths), so this is driven entirely by props — SSR requests
|
|
175
|
+
* never carry `isLatestAlias`.
|
|
176
|
+
*/
|
|
177
|
+
export const buildLatestAliasRedirect = ({
|
|
178
|
+
isLatestAlias,
|
|
179
|
+
actualVersion,
|
|
180
|
+
routeBasePath,
|
|
181
|
+
docLocale,
|
|
182
|
+
pageSlug,
|
|
183
|
+
}: LatestAliasRedirectInput): { targetUrl: string; fromUrl: string } | null => {
|
|
184
|
+
if (!isLatestAlias || !actualVersion) return null
|
|
185
|
+
const targetUrl = docLocale
|
|
186
|
+
? pageSlug
|
|
187
|
+
? `/${docLocale}/${routeBasePath}/${actualVersion}/${pageSlug}`
|
|
188
|
+
: `/${docLocale}/${routeBasePath}/${actualVersion}/`
|
|
189
|
+
: pageSlug
|
|
190
|
+
? `/${routeBasePath}/${actualVersion}/${pageSlug}`
|
|
191
|
+
: `/${routeBasePath}/${actualVersion}/`
|
|
192
|
+
const fromUrl = docLocale
|
|
193
|
+
? pageSlug
|
|
194
|
+
? `/${docLocale}/${routeBasePath}/latest/${pageSlug}`
|
|
195
|
+
: `/${docLocale}/${routeBasePath}/latest/`
|
|
196
|
+
: pageSlug
|
|
197
|
+
? `/${routeBasePath}/latest/${pageSlug}`
|
|
198
|
+
: `/${routeBasePath}/latest/`
|
|
199
|
+
return { targetUrl, fromUrl }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render the minimal redirect HTML page body for a latest-alias redirect.
|
|
204
|
+
*/
|
|
205
|
+
export const renderLatestAliasRedirectHtml = (
|
|
206
|
+
targetUrl: string,
|
|
207
|
+
fromUrl: string,
|
|
208
|
+
canonicalHref: string,
|
|
209
|
+
): string =>
|
|
210
|
+
`<!doctype html><title>Redirecting to: ${targetUrl}</title><meta http-equiv="refresh" content="0;url=${targetUrl}"><meta name="robots" content="noindex"><link rel="canonical" href="${canonicalHref}"><body>\t<a href="${targetUrl}">Redirecting from <code>${fromUrl}</code> to <code>${targetUrl}</code></a></body>`
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Compute static paths for the versioned-root redirect route.
|
|
214
|
+
* Returns one path per locale when i18n is enabled, else a single empty path.
|
|
215
|
+
*/
|
|
216
|
+
export const getDocsRedirectPaths = (
|
|
217
|
+
i18n: I18nConfig | null | undefined | false,
|
|
218
|
+
) => {
|
|
219
|
+
if (i18n) {
|
|
220
|
+
return i18n.locales.map((locale) => {
|
|
221
|
+
const localeCode = typeof locale === 'string' ? locale : locale.codes[0]
|
|
222
|
+
return { params: { locale: localeCode } }
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
return [{ params: {} }]
|
|
226
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -824,260 +824,19 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
824
824
|
? prerenderConfig
|
|
825
825
|
: astroConfig.output !== 'server'
|
|
826
826
|
|
|
827
|
-
//
|
|
828
|
-
//
|
|
827
|
+
// Directory for generated JS/TS route files (llms.txt endpoints).
|
|
828
|
+
// No .astro files are generated as strings anymore — the docs entry and
|
|
829
|
+
// version-redirect routes ship as real .astro files under astro/pages.
|
|
829
830
|
const generatedDir = join(
|
|
830
831
|
astroConfig.root?.pathname || process.cwd(),
|
|
831
832
|
'node_modules',
|
|
832
833
|
'.shipyard-docs',
|
|
833
834
|
)
|
|
834
835
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const entryFileName = `DocsEntry-${normalizedBasePath}.astro`
|
|
840
|
-
const entryFilePath = join(generatedDir, entryFileName)
|
|
841
|
-
|
|
842
|
-
// Generate the entry file with the correct routeBasePath and collectionName
|
|
843
|
-
// Note: We inline the values directly in getStaticPaths because Astro's compiler
|
|
844
|
-
// hoists getStaticPaths to a separate module context where top-level constants aren't available
|
|
845
|
-
const hasVersions = !!versions
|
|
846
|
-
const entryFileContent = `---
|
|
847
|
-
import { i18n } from 'astro:config/server'
|
|
848
|
-
import { getCollection, render } from 'astro:content'
|
|
849
|
-
import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
850
|
-
import { createVersionPathMap, getEditUrl, getGitMetadata, getVersionFromDocId, stripVersionFromDocId } from '@levino/shipyard-docs'
|
|
851
|
-
import Layout from '@levino/shipyard-docs/astro/Layout.astro'
|
|
852
|
-
|
|
853
|
-
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
854
|
-
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
855
|
-
|
|
856
|
-
export async function getStaticPaths() {
|
|
857
|
-
// Note: collectionName and routeBasePath must be inlined here because Astro compiles
|
|
858
|
-
// getStaticPaths separately and module-level constants are not available
|
|
859
|
-
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
860
|
-
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
861
|
-
const hasVersions = ${JSON.stringify(hasVersions)}
|
|
862
|
-
const versionsConfig = ${JSON.stringify(versions || null)}
|
|
863
|
-
const allDocs = await getCollection(collectionName)
|
|
864
|
-
|
|
865
|
-
// Filter out pages with render: false - they should not generate pages
|
|
866
|
-
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
867
|
-
|
|
868
|
-
// Pre-compute version path map for O(1) lookups instead of O(V) per document
|
|
869
|
-
const versionPathMap = hasVersions && versionsConfig
|
|
870
|
-
? createVersionPathMap(versionsConfig)
|
|
871
|
-
: null
|
|
872
|
-
|
|
873
|
-
const getParams = (slug, version) => {
|
|
874
|
-
if (i18n) {
|
|
875
|
-
const [locale, ...rest] = slug.split('/')
|
|
876
|
-
const baseParams = {
|
|
877
|
-
slug: rest.length ? rest.join('/') : undefined,
|
|
878
|
-
locale,
|
|
879
|
-
}
|
|
880
|
-
return version ? { ...baseParams, version } : baseParams
|
|
881
|
-
} else {
|
|
882
|
-
const baseParams = {
|
|
883
|
-
slug: slug || undefined,
|
|
884
|
-
}
|
|
885
|
-
return version ? { ...baseParams, version } : baseParams
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
const paths = []
|
|
890
|
-
|
|
891
|
-
for (const entry of docs) {
|
|
892
|
-
// For versioned docs, extract version from the doc ID (e.g., "v1.0/en/getting-started")
|
|
893
|
-
let version = null
|
|
894
|
-
let docIdWithoutVersion = entry.id
|
|
895
|
-
|
|
896
|
-
if (hasVersions && versionsConfig && versionPathMap) {
|
|
897
|
-
const extractedVersion = getVersionFromDocId(entry.id)
|
|
898
|
-
if (extractedVersion) {
|
|
899
|
-
// Use pre-computed map for O(1) lookup instead of array.find()
|
|
900
|
-
version = versionPathMap.get(extractedVersion) ?? extractedVersion
|
|
901
|
-
docIdWithoutVersion = stripVersionFromDocId(entry.id)
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// Extract locale from docIdWithoutVersion for i18n builds
|
|
906
|
-
const docLocale = i18n ? docIdWithoutVersion.split('/')[0] : undefined
|
|
907
|
-
|
|
908
|
-
// Add the main path for this doc
|
|
909
|
-
paths.push({
|
|
910
|
-
params: getParams(docIdWithoutVersion, version),
|
|
911
|
-
props: { entry, routeBasePath, version, isLatestAlias: false, docLocale },
|
|
912
|
-
})
|
|
913
|
-
|
|
914
|
-
// If this doc is in the current version, also generate a 'latest' alias path that redirects
|
|
915
|
-
if (hasVersions && versionsConfig && version) {
|
|
916
|
-
const extractedVersion = getVersionFromDocId(entry.id)
|
|
917
|
-
const currentVersion = versionsConfig.current
|
|
918
|
-
if (extractedVersion === currentVersion) {
|
|
919
|
-
paths.push({
|
|
920
|
-
params: getParams(docIdWithoutVersion, 'latest'),
|
|
921
|
-
props: { entry, routeBasePath, version: 'latest', actualVersion: version, isLatestAlias: true, docLocale },
|
|
922
|
-
})
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
return paths
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// In SSR mode (prerender: false), getStaticPaths is not called so Astro.props.entry will be undefined.
|
|
931
|
-
// We need to fetch the entry from the collection based on URL params.
|
|
932
|
-
let { entry, routeBasePath: propsRouteBasePath, version, actualVersion, isLatestAlias, docLocale } = Astro.props
|
|
933
|
-
const { slug: pageSlug, locale, version: urlVersion } = Astro.params
|
|
934
|
-
|
|
935
|
-
// SSR mode: fetch entry dynamically when props are not available from getStaticPaths
|
|
936
|
-
if (!entry) {
|
|
937
|
-
const allDocs = await getCollection(collectionName)
|
|
938
|
-
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
939
|
-
|
|
940
|
-
// Reconstruct the entry ID from URL params
|
|
941
|
-
let entryId
|
|
942
|
-
if (i18n && locale) {
|
|
943
|
-
// For versioned docs, include version in the entry ID
|
|
944
|
-
if (urlVersion) {
|
|
945
|
-
entryId = pageSlug ? urlVersion + '/' + locale + '/' + pageSlug : urlVersion + '/' + locale
|
|
946
|
-
} else {
|
|
947
|
-
entryId = pageSlug ? locale + '/' + pageSlug : locale
|
|
948
|
-
}
|
|
949
|
-
} else {
|
|
950
|
-
if (urlVersion) {
|
|
951
|
-
entryId = pageSlug ? urlVersion + '/' + pageSlug : urlVersion
|
|
952
|
-
} else {
|
|
953
|
-
entryId = pageSlug ?? ''
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Find the matching entry
|
|
958
|
-
entry = docs.find((doc) => doc.id === entryId)
|
|
959
|
-
|
|
960
|
-
// If no exact match, try matching with /index suffix (for index pages)
|
|
961
|
-
// For empty entryId (root path like /docs), look for 'index'
|
|
962
|
-
// For category paths (like /docs/details), look for 'details/index'
|
|
963
|
-
if (!entry) {
|
|
964
|
-
const indexEntryId = entryId ? entryId + '/index' : 'index'
|
|
965
|
-
entry = docs.find((doc) => doc.id === indexEntryId)
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// If still no match, return 404
|
|
969
|
-
if (!entry) {
|
|
970
|
-
return Astro.redirect('/404')
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// Set version from URL params for SSR mode
|
|
974
|
-
version = urlVersion
|
|
975
|
-
isLatestAlias = false
|
|
976
|
-
docLocale = locale
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
// SEO-friendly redirect for /latest/ URLs to canonical version URLs
|
|
980
|
-
// We handle the redirect inline below since Astro.redirect() doesn't work reliably
|
|
981
|
-
// with i18n fallback pages
|
|
982
|
-
const redirectInfo = isLatestAlias && actualVersion ? {
|
|
983
|
-
locale: docLocale,
|
|
984
|
-
targetUrl: docLocale
|
|
985
|
-
? (pageSlug
|
|
986
|
-
? \`/\${docLocale}/\${routeBasePath}/\${actualVersion}/\${pageSlug}\`
|
|
987
|
-
: \`/\${docLocale}/\${routeBasePath}/\${actualVersion}/\`)
|
|
988
|
-
: (pageSlug
|
|
989
|
-
? \`/\${routeBasePath}/\${actualVersion}/\${pageSlug}\`
|
|
990
|
-
: \`/\${routeBasePath}/\${actualVersion}/\`),
|
|
991
|
-
fromUrl: docLocale
|
|
992
|
-
? (pageSlug
|
|
993
|
-
? \`/\${docLocale}/\${routeBasePath}/latest/\${pageSlug}\`
|
|
994
|
-
: \`/\${docLocale}/\${routeBasePath}/latest/\`)
|
|
995
|
-
: (pageSlug
|
|
996
|
-
? \`/\${routeBasePath}/latest/\${pageSlug}\`
|
|
997
|
-
: \`/\${routeBasePath}/latest/\`),
|
|
998
|
-
} : null
|
|
999
|
-
|
|
1000
|
-
// If this is a redirect, return early with a minimal redirect page
|
|
1001
|
-
if (redirectInfo) {
|
|
1002
|
-
return new Response(\`<!doctype html><title>Redirecting to: \${redirectInfo.targetUrl}</title><meta http-equiv="refresh" content="0;url=\${redirectInfo.targetUrl}"><meta name="robots" content="noindex"><link rel="canonical" href="\${Astro.site ? new URL(redirectInfo.targetUrl, Astro.site).href : redirectInfo.targetUrl}"><body>\\t<a href="\${redirectInfo.targetUrl}">Redirecting from <code>\${redirectInfo.fromUrl}</code> to <code>\${redirectInfo.targetUrl}</code></a></body>\`, {
|
|
1003
|
-
status: 301,
|
|
1004
|
-
headers: {
|
|
1005
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
1006
|
-
'Location': redirectInfo.targetUrl,
|
|
1007
|
-
},
|
|
1008
|
-
})
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
const docsConfig = docsConfigs[routeBasePath] ?? {
|
|
1012
|
-
showLastUpdateTime: false,
|
|
1013
|
-
showLastUpdateAuthor: false,
|
|
1014
|
-
routeBasePath: 'docs',
|
|
1015
|
-
collectionName: 'docs',
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// Version is available for use in Layout/components if needed
|
|
1019
|
-
// For 'latest' alias URLs, actualVersion contains the real version
|
|
1020
|
-
const currentVersion = isLatestAlias ? actualVersion : version
|
|
1021
|
-
const displayVersion = version // The version shown in the URL
|
|
1022
|
-
|
|
1023
|
-
const { Content, headings } = await render(entry)
|
|
1024
|
-
|
|
1025
|
-
const { customEditUrl, lastUpdateAuthor, lastUpdateTime, hideTableOfContents, hideTitle, keywords, image, canonicalUrl, customMetaTags, title_meta: titleMeta, description } = entry.data
|
|
1026
|
-
|
|
1027
|
-
let editUrl
|
|
1028
|
-
if (customEditUrl === null) {
|
|
1029
|
-
editUrl = undefined
|
|
1030
|
-
} else if (customEditUrl) {
|
|
1031
|
-
editUrl = customEditUrl
|
|
1032
|
-
} else if (entry.filePath) {
|
|
1033
|
-
// Use filePath instead of entry.id because Astro's glob loader
|
|
1034
|
-
// strips "index" from entry.id for index pages (e.g., en/index -> en)
|
|
1035
|
-
// Strip the collection base directory to get the relative path
|
|
1036
|
-
const collectionBase = \`\${docsConfig.collectionName}/\`
|
|
1037
|
-
const relativePath = entry.filePath.startsWith(collectionBase)
|
|
1038
|
-
? entry.filePath.slice(collectionBase.length)
|
|
1039
|
-
: entry.filePath
|
|
1040
|
-
editUrl = getEditUrl(docsConfig.editUrl, relativePath)
|
|
1041
|
-
} else {
|
|
1042
|
-
// Fallback to entry.id if filePath is not available
|
|
1043
|
-
editUrl = getEditUrl(docsConfig.editUrl, entry.id)
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
let lastUpdated
|
|
1047
|
-
let lastAuthor
|
|
1048
|
-
|
|
1049
|
-
if (
|
|
1050
|
-
(docsConfig.showLastUpdateTime && lastUpdateTime !== false) ||
|
|
1051
|
-
(docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false)
|
|
1052
|
-
) {
|
|
1053
|
-
const filePath = entry.filePath
|
|
1054
|
-
|
|
1055
|
-
if (filePath) {
|
|
1056
|
-
const gitMetadata = getGitMetadata(filePath)
|
|
1057
|
-
|
|
1058
|
-
if (docsConfig.showLastUpdateTime && lastUpdateTime !== false) {
|
|
1059
|
-
lastUpdated =
|
|
1060
|
-
lastUpdateTime instanceof Date
|
|
1061
|
-
? lastUpdateTime
|
|
1062
|
-
: gitMetadata.lastUpdated
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
if (docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false) {
|
|
1066
|
-
lastAuthor =
|
|
1067
|
-
typeof lastUpdateAuthor === 'string'
|
|
1068
|
-
? lastUpdateAuthor
|
|
1069
|
-
: gitMetadata.lastAuthor
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
---
|
|
1074
|
-
|
|
1075
|
-
<Layout headings={headings} routeBasePath={routeBasePath} editUrl={editUrl} lastUpdated={lastUpdated} lastAuthor={lastAuthor} hideTableOfContents={hideTableOfContents} hideTitle={hideTitle} keywords={keywords} image={image} canonicalUrl={canonicalUrl} customMetaTags={customMetaTags} titleMeta={titleMeta} description={description}>
|
|
1076
|
-
<Content />
|
|
1077
|
-
</Layout>
|
|
1078
|
-
`
|
|
1079
|
-
|
|
1080
|
-
writeFileSync(entryFilePath, entryFileContent)
|
|
836
|
+
const ENTRYPOINT_DOCS_ENTRY =
|
|
837
|
+
'@levino/shipyard-docs/astro/pages/DocsEntry.astro'
|
|
838
|
+
const ENTRYPOINT_DOCS_VERSION_REDIRECT =
|
|
839
|
+
'@levino/shipyard-docs/astro/pages/DocsVersionRedirect.astro'
|
|
1081
840
|
|
|
1082
841
|
// Create virtual modules to expose docs configurations
|
|
1083
842
|
updateConfig({
|
|
@@ -1172,51 +931,24 @@ export function hasVersioning(routeBasePath = 'docs') {
|
|
|
1172
931
|
// With i18n: use locale prefix
|
|
1173
932
|
if (versions) {
|
|
1174
933
|
// Versioned routes: /[locale]/[routeBasePath]/[version]/[...slug]
|
|
1175
|
-
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the
|
|
934
|
+
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the component
|
|
1176
935
|
injectRoute({
|
|
1177
936
|
pattern: `/[locale]/${normalizedBasePath}/[version]/[...slug]`,
|
|
1178
|
-
entrypoint:
|
|
937
|
+
entrypoint: ENTRYPOINT_DOCS_ENTRY,
|
|
1179
938
|
prerender,
|
|
1180
939
|
})
|
|
1181
940
|
|
|
1182
|
-
// Generate redirect from docs root to current version
|
|
1183
|
-
const redirectFileName = `docs-redirect-${normalizedBasePath}.astro`
|
|
1184
|
-
const redirectFilePath = join(generatedDir, redirectFileName)
|
|
1185
|
-
const currentVersionPath =
|
|
1186
|
-
versions.available.find((v) => v.version === versions.current)
|
|
1187
|
-
?.path ?? versions.current
|
|
1188
|
-
const redirectFileContent = `---
|
|
1189
|
-
import { i18n } from 'astro:config/server'
|
|
1190
|
-
|
|
1191
|
-
export function getStaticPaths() {
|
|
1192
|
-
const locales = i18n?.locales ?? ['en']
|
|
1193
|
-
return locales.map((locale) => {
|
|
1194
|
-
const localeCode = typeof locale === 'string' ? locale : locale.path
|
|
1195
|
-
return { params: { locale: localeCode } }
|
|
1196
|
-
})
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const { locale } = Astro.params
|
|
1200
|
-
const currentVersion = ${JSON.stringify(currentVersionPath)}
|
|
1201
|
-
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
1202
|
-
|
|
1203
|
-
// Redirect to the current version's index
|
|
1204
|
-
return Astro.redirect(\`/\${locale}/\${routeBasePath}/\${currentVersion}/\`, 302)
|
|
1205
|
-
---
|
|
1206
|
-
`
|
|
1207
|
-
writeFileSync(redirectFilePath, redirectFileContent)
|
|
1208
|
-
|
|
1209
941
|
// Inject redirect route for docs root (without trailing slash)
|
|
1210
942
|
injectRoute({
|
|
1211
943
|
pattern: `/[locale]/${normalizedBasePath}`,
|
|
1212
|
-
entrypoint:
|
|
944
|
+
entrypoint: ENTRYPOINT_DOCS_VERSION_REDIRECT,
|
|
1213
945
|
prerender,
|
|
1214
946
|
})
|
|
1215
947
|
} else {
|
|
1216
948
|
// Non-versioned routes: /[locale]/[routeBasePath]/[...slug]
|
|
1217
949
|
injectRoute({
|
|
1218
950
|
pattern: `/[locale]/${normalizedBasePath}/[...slug]`,
|
|
1219
|
-
entrypoint:
|
|
951
|
+
entrypoint: ENTRYPOINT_DOCS_ENTRY,
|
|
1220
952
|
prerender,
|
|
1221
953
|
})
|
|
1222
954
|
}
|
|
@@ -1224,40 +956,24 @@ return Astro.redirect(\`/\${locale}/\${routeBasePath}/\${currentVersion}/\`, 302
|
|
|
1224
956
|
// Without i18n: direct path
|
|
1225
957
|
if (versions) {
|
|
1226
958
|
// Versioned routes: /[routeBasePath]/[version]/[...slug]
|
|
1227
|
-
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the
|
|
959
|
+
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the component
|
|
1228
960
|
injectRoute({
|
|
1229
961
|
pattern: `/${normalizedBasePath}/[version]/[...slug]`,
|
|
1230
|
-
entrypoint:
|
|
962
|
+
entrypoint: ENTRYPOINT_DOCS_ENTRY,
|
|
1231
963
|
prerender,
|
|
1232
964
|
})
|
|
1233
965
|
|
|
1234
|
-
// Generate redirect from docs root to current version
|
|
1235
|
-
const redirectFileName = `docs-redirect-${normalizedBasePath}.astro`
|
|
1236
|
-
const redirectFilePath = join(generatedDir, redirectFileName)
|
|
1237
|
-
const currentVersionPath =
|
|
1238
|
-
versions.available.find((v) => v.version === versions.current)
|
|
1239
|
-
?.path ?? versions.current
|
|
1240
|
-
const redirectFileContent = `---
|
|
1241
|
-
const currentVersion = ${JSON.stringify(currentVersionPath)}
|
|
1242
|
-
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
1243
|
-
|
|
1244
|
-
// Redirect to the current version's index
|
|
1245
|
-
return Astro.redirect(\`/\${routeBasePath}/\${currentVersion}/\`, 302)
|
|
1246
|
-
---
|
|
1247
|
-
`
|
|
1248
|
-
writeFileSync(redirectFilePath, redirectFileContent)
|
|
1249
|
-
|
|
1250
966
|
// Inject redirect route for docs root (without trailing slash)
|
|
1251
967
|
injectRoute({
|
|
1252
968
|
pattern: `/${normalizedBasePath}`,
|
|
1253
|
-
entrypoint:
|
|
969
|
+
entrypoint: ENTRYPOINT_DOCS_VERSION_REDIRECT,
|
|
1254
970
|
prerender,
|
|
1255
971
|
})
|
|
1256
972
|
} else {
|
|
1257
973
|
// Non-versioned routes: /[routeBasePath]/[...slug]
|
|
1258
974
|
injectRoute({
|
|
1259
975
|
pattern: `/${normalizedBasePath}/[...slug]`,
|
|
1260
|
-
entrypoint:
|
|
976
|
+
entrypoint: ENTRYPOINT_DOCS_ENTRY,
|
|
1261
977
|
prerender,
|
|
1262
978
|
})
|
|
1263
979
|
}
|
|
@@ -1272,6 +988,11 @@ return Astro.redirect(\`/\${routeBasePath}/\${currentVersion}/\`, 302)
|
|
|
1272
988
|
sectionTitle: llmsTxt.sectionTitle ?? 'Documentation',
|
|
1273
989
|
}
|
|
1274
990
|
|
|
991
|
+
// Ensure the directory for generated JS/TS endpoints exists
|
|
992
|
+
if (!existsSync(generatedDir)) {
|
|
993
|
+
mkdirSync(generatedDir, { recursive: true })
|
|
994
|
+
}
|
|
995
|
+
|
|
1275
996
|
// Generate individual plain text endpoints for each doc page
|
|
1276
997
|
// These are mounted at /_llms-txt/[slug].txt
|
|
1277
998
|
const llmsTxtPagesFileName = `llms-txt-pages-${normalizedBasePath}.ts`
|