@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.
@@ -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
- export async function getStaticPaths() {
9
- // Get all configured docs collections
10
- const allPaths = await Promise.all(
11
- Object.entries(docsConfigs).map(async ([basePath, config]) => {
12
- const docs = await getCollection(config.collectionName as CollectionKey)
13
-
14
- const getParams = (slug: string) => {
15
- if (i18n) {
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
- // For your template, you can get the entry directly from the prop
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
- // Get frontmatter data for per-page overrides
54
- const { custom_edit_url, last_update_author, last_update_time } = entry.data
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 (custom_edit_url === null) {
102
+ if (customEditUrl === null) {
59
103
  // Explicitly disabled for this page
60
104
  editUrl = undefined
61
- } else if (custom_edit_url) {
105
+ } else if (customEditUrl) {
62
106
  // Custom URL provided
63
- editUrl = custom_edit_url
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 && last_update_time !== false) ||
84
- (docsConfig.showLastUpdateAuthor && last_update_author !== false)
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
- // Use frontmatter override or git metadata for timestamp
94
- if (docsConfig.showLastUpdateTime && last_update_time !== false) {
135
+ if (docsConfig.showLastUpdateTime && lastUpdateTime !== false) {
95
136
  lastUpdated =
96
- last_update_time instanceof Date
97
- ? last_update_time
137
+ lastUpdateTime instanceof Date
138
+ ? lastUpdateTime
98
139
  : gitMetadata.lastUpdated
99
140
  }
100
141
 
101
- // Use frontmatter override or git metadata for author
102
- if (docsConfig.showLastUpdateAuthor && last_update_author !== false) {
142
+ if (docsConfig.showLastUpdateAuthor && lastUpdateAuthor !== false) {
103
143
  lastAuthor =
104
- typeof last_update_author === 'string'
105
- ? last_update_author
144
+ typeof lastUpdateAuthor === 'string'
145
+ ? lastUpdateAuthor
106
146
  : gitMetadata.lastAuthor
107
147
  }
108
148
  }
109
149
  }
110
150
  ---
111
151
 
112
- <Layout headings={headings} routeBasePath={routeBasePath} editUrl={editUrl} lastUpdated={lastUpdated} lastAuthor={lastAuthor}>
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.4",
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": "^0.8.4"
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
- // Create a generated entry file for this specific docs instance
828
- // This ensures each route has its own getStaticPaths that only returns its own paths
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
- if (!existsSync(generatedDir)) {
836
- mkdirSync(generatedDir, { recursive: true })
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 frontmatter
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: entryFilePath,
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: redirectFilePath,
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: entryFilePath,
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 frontmatter
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: entryFilePath,
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: redirectFilePath,
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: entryFilePath,
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`