@levino/shipyard-docs 0.6.2 → 0.6.3
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/README.md +40 -1
- package/astro/Layout.astro +159 -5
- package/package.json +4 -2
- package/src/index.ts +797 -146
- package/src/rehypeVersionLinks.test.ts +319 -0
- package/src/rehypeVersionLinks.ts +156 -0
- package/src/routeHelpers.test.ts +657 -1
- package/src/routeHelpers.ts +221 -0
- package/src/sidebarEntries.test.ts +154 -1
- package/src/sidebarEntries.ts +33 -0
- package/src/versionHelpers.ts +64 -0
- package/src/versionSchema.test.ts +404 -0
- package/src/virtual-module.d.ts +68 -0
package/src/index.ts
CHANGED
|
@@ -15,12 +15,35 @@ export { generateLlmsFullTxt, generateLlmsTxt } from './llmsTxt'
|
|
|
15
15
|
// Re-export pagination types and utilities
|
|
16
16
|
export type { PaginationInfo, PaginationLink } from './pagination'
|
|
17
17
|
export { getPaginationInfo } from './pagination'
|
|
18
|
-
export
|
|
18
|
+
// Re-export rehype plugin for version-aware links
|
|
19
|
+
export type { RehypeVersionLinksOptions } from './rehypeVersionLinks'
|
|
20
|
+
export { rehypeVersionLinks } from './rehypeVersionLinks'
|
|
21
|
+
export type { DocsEntry, DocsRouteConfig } from './routeHelpers'
|
|
19
22
|
// Re-export route helpers
|
|
20
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
createDeprecatedVersionSet,
|
|
25
|
+
createVersionPathMap,
|
|
26
|
+
findVersionConfig,
|
|
27
|
+
getAvailableVersions,
|
|
28
|
+
getCurrentVersion,
|
|
29
|
+
getDocPath,
|
|
30
|
+
getRouteParams,
|
|
31
|
+
getStableVersion,
|
|
32
|
+
getVersionedDocPath,
|
|
33
|
+
getVersionedRouteParams,
|
|
34
|
+
getVersionPath,
|
|
35
|
+
isVersionDeprecated,
|
|
36
|
+
switchVersionInPath,
|
|
37
|
+
} from './routeHelpers'
|
|
21
38
|
// Re-export types and utilities from sidebarEntries
|
|
22
39
|
export type { DocsData } from './sidebarEntries'
|
|
23
|
-
export { toSidebarEntries } from './sidebarEntries'
|
|
40
|
+
export { filterDocsForVersion, toSidebarEntries } from './sidebarEntries'
|
|
41
|
+
// Re-export version helpers
|
|
42
|
+
export {
|
|
43
|
+
getVersionFromDocId,
|
|
44
|
+
isVersionLikeString,
|
|
45
|
+
stripVersionFromDocId,
|
|
46
|
+
} from './versionHelpers'
|
|
24
47
|
|
|
25
48
|
/**
|
|
26
49
|
* Schema for sidebar configuration grouped under a single object.
|
|
@@ -46,126 +69,227 @@ const sidebarSchema = z
|
|
|
46
69
|
'sidebar.collapsed cannot be true when sidebar.collapsible is false',
|
|
47
70
|
})
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Base docs schema object (before transforms/refinements).
|
|
74
|
+
* Used internally for extending with additional fields.
|
|
75
|
+
*/
|
|
76
|
+
const docsSchemaBase = z.object({
|
|
77
|
+
// === Page Metadata ===
|
|
78
|
+
/** Custom document ID (default: file path) */
|
|
79
|
+
id: z.string().optional(),
|
|
80
|
+
/** Reference title for SEO, pagination, previews (default: H1) */
|
|
81
|
+
title: z.string().optional(),
|
|
82
|
+
/** Override title for SEO/browser tab (default: title) - Docusaurus snake_case convention */
|
|
83
|
+
title_meta: z.string().optional(),
|
|
84
|
+
/** Meta description (default: first paragraph) */
|
|
85
|
+
description: z.string().optional(),
|
|
86
|
+
/** SEO keywords */
|
|
87
|
+
keywords: z.array(z.string()).optional(),
|
|
88
|
+
/** Social preview image (og:image) */
|
|
89
|
+
image: z.string().optional(),
|
|
90
|
+
/** Custom canonical URL */
|
|
91
|
+
canonicalUrl: z.string().optional(),
|
|
92
|
+
/** Custom canonical URL - snake_case alias for Docusaurus compatibility */
|
|
93
|
+
canonical_url: z.string().optional(),
|
|
94
|
+
|
|
95
|
+
// === Page Rendering ===
|
|
96
|
+
/** Whether to render a page (default: true) */
|
|
97
|
+
render: z.boolean().default(true),
|
|
98
|
+
/** Exclude from production builds (default: false) */
|
|
99
|
+
draft: z.boolean().default(false),
|
|
100
|
+
/** Render page but hide from sidebar (default: false) */
|
|
101
|
+
unlisted: z.boolean().default(false),
|
|
102
|
+
/** Custom URL slug */
|
|
103
|
+
slug: z.string().optional(),
|
|
104
|
+
|
|
105
|
+
// === Layout Options ===
|
|
106
|
+
/** Hide the H1 heading (default: false) */
|
|
107
|
+
hideTitle: z.boolean().default(false),
|
|
108
|
+
/** Hide the H1 heading (default: false) - snake_case alias for Docusaurus compatibility */
|
|
109
|
+
hide_title: z.boolean().optional(),
|
|
110
|
+
/** Hide the TOC (default: false) */
|
|
111
|
+
hideTableOfContents: z.boolean().default(false),
|
|
112
|
+
/** Hide the TOC (default: false) - snake_case alias for Docusaurus compatibility */
|
|
113
|
+
hide_table_of_contents: z.boolean().optional(),
|
|
114
|
+
/** Full-width page without sidebar (default: false) */
|
|
115
|
+
hideSidebar: z.boolean().default(false),
|
|
116
|
+
/** Min heading level in TOC (default: 2) */
|
|
117
|
+
tocMinHeadingLevel: z.number().min(1).max(6).default(2),
|
|
118
|
+
/** Max heading level in TOC (default: 3) */
|
|
119
|
+
tocMaxHeadingLevel: z.number().min(1).max(6).default(3),
|
|
120
|
+
|
|
121
|
+
// === Sidebar Configuration (grouped) ===
|
|
122
|
+
sidebar: sidebarSchema.default({ collapsible: true, collapsed: true }),
|
|
123
|
+
|
|
124
|
+
// === Pagination ===
|
|
125
|
+
/** Label shown in prev/next buttons */
|
|
126
|
+
paginationLabel: z.string().optional(),
|
|
127
|
+
/** Label shown in prev/next buttons - snake_case alias for Docusaurus compatibility */
|
|
128
|
+
pagination_label: z.string().optional(),
|
|
129
|
+
/** Next page ID, or null to disable */
|
|
130
|
+
paginationNext: z.string().nullable().optional(),
|
|
131
|
+
/** Next page ID - snake_case alias for Docusaurus compatibility */
|
|
132
|
+
pagination_next: z.string().nullable().optional(),
|
|
133
|
+
/** Previous page ID, or null to disable */
|
|
134
|
+
paginationPrev: z.string().nullable().optional(),
|
|
135
|
+
/** Previous page ID - snake_case alias for Docusaurus compatibility */
|
|
136
|
+
pagination_prev: z.string().nullable().optional(),
|
|
137
|
+
|
|
138
|
+
// === Git Metadata Overrides ===
|
|
139
|
+
/**
|
|
140
|
+
* Override the last update author for this specific page.
|
|
141
|
+
* Set to false to hide the author for this page.
|
|
142
|
+
*/
|
|
143
|
+
lastUpdateAuthor: z.union([z.string(), z.literal(false)]).optional(),
|
|
144
|
+
/**
|
|
145
|
+
* Override the last update timestamp for this specific page.
|
|
146
|
+
* Set to false to hide the timestamp for this page.
|
|
147
|
+
*/
|
|
148
|
+
lastUpdateTime: z.union([z.literal(false), z.coerce.date()]).optional(),
|
|
149
|
+
/**
|
|
150
|
+
* Custom edit URL for this specific page.
|
|
151
|
+
* Set to null to disable edit link for this page.
|
|
152
|
+
*/
|
|
153
|
+
customEditUrl: z.string().nullable().optional(),
|
|
154
|
+
|
|
155
|
+
// === Custom Meta Tags ===
|
|
156
|
+
customMetaTags: z
|
|
157
|
+
.array(
|
|
158
|
+
z.object({
|
|
159
|
+
name: z.string().optional(),
|
|
160
|
+
property: z.string().optional(),
|
|
161
|
+
content: z.string(),
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
.optional(),
|
|
165
|
+
/** Custom meta tags - snake_case alias for Docusaurus compatibility */
|
|
166
|
+
custom_meta_tags: z
|
|
167
|
+
.array(
|
|
168
|
+
z.object({
|
|
169
|
+
name: z.string().optional(),
|
|
170
|
+
property: z.string().optional(),
|
|
171
|
+
content: z.string(),
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
.optional(),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Transform function for docs schema to merge snake_case aliases into camelCase fields.
|
|
179
|
+
*/
|
|
180
|
+
const docsSchemaTransform = <T extends z.infer<typeof docsSchemaBase>>(
|
|
181
|
+
data: T,
|
|
182
|
+
) => ({
|
|
183
|
+
...data,
|
|
184
|
+
// Merge snake_case aliases into camelCase fields
|
|
185
|
+
hideTitle: data.hide_title ?? data.hideTitle,
|
|
186
|
+
hideTableOfContents: data.hide_table_of_contents ?? data.hideTableOfContents,
|
|
187
|
+
canonicalUrl: data.canonical_url ?? data.canonicalUrl,
|
|
188
|
+
customMetaTags: data.custom_meta_tags ?? data.customMetaTags,
|
|
189
|
+
paginationLabel: data.pagination_label ?? data.paginationLabel,
|
|
190
|
+
// For nullable fields, we need to check if the snake_case key exists (not just use ??)
|
|
191
|
+
// because null ?? undefined = undefined, but we want null to be preserved
|
|
192
|
+
paginationNext:
|
|
193
|
+
'pagination_next' in data ? data.pagination_next : data.paginationNext,
|
|
194
|
+
paginationPrev:
|
|
195
|
+
'pagination_prev' in data ? data.pagination_prev : data.paginationPrev,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Refinement for docs schema to validate TOC heading levels.
|
|
200
|
+
*/
|
|
201
|
+
const docsSchemaRefinement = {
|
|
202
|
+
check: (data: { tocMinHeadingLevel: number; tocMaxHeadingLevel: number }) =>
|
|
203
|
+
data.tocMinHeadingLevel <= data.tocMaxHeadingLevel,
|
|
204
|
+
message: 'tocMinHeadingLevel must be <= tocMaxHeadingLevel',
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const docsSchema = docsSchemaBase
|
|
208
|
+
.transform(docsSchemaTransform)
|
|
209
|
+
.refine(docsSchemaRefinement.check, { message: docsSchemaRefinement.message })
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Schema for a single version entry in the versions configuration.
|
|
213
|
+
*/
|
|
214
|
+
export const singleVersionSchema = z.object({
|
|
215
|
+
/**
|
|
216
|
+
* The version identifier (e.g., "v1.0", "2.0.0", "latest").
|
|
217
|
+
*/
|
|
218
|
+
version: z.string(),
|
|
219
|
+
/**
|
|
220
|
+
* Optional human-readable label for display in the UI.
|
|
221
|
+
* If not provided, the version string will be used.
|
|
222
|
+
* @example "Version 2.0" or "Latest"
|
|
223
|
+
*/
|
|
224
|
+
label: z.string().optional(),
|
|
225
|
+
/**
|
|
226
|
+
* Path segment used in URLs for this version.
|
|
227
|
+
* If not provided, defaults to the version string.
|
|
228
|
+
* @example "v2" for a URL like /docs/v2/getting-started
|
|
229
|
+
*/
|
|
230
|
+
path: z.string().optional(),
|
|
231
|
+
/**
|
|
232
|
+
* Optional banner to display when viewing this version.
|
|
233
|
+
* Use "unreleased" for preview/beta versions, "unmaintained" for deprecated versions.
|
|
234
|
+
*/
|
|
235
|
+
banner: z.enum(['unreleased', 'unmaintained']).optional(),
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Schema for version configuration in docs.
|
|
240
|
+
* Allows configuring multiple documentation versions with routing and UI options.
|
|
241
|
+
*/
|
|
242
|
+
export const versionConfigSchema = z.object({
|
|
243
|
+
/**
|
|
244
|
+
* The current/default version of the documentation.
|
|
245
|
+
* This version will be shown when users visit the docs without specifying a version.
|
|
246
|
+
* @example "v2.0" or "latest"
|
|
247
|
+
*/
|
|
248
|
+
current: z.string(),
|
|
249
|
+
/**
|
|
250
|
+
* List of all available versions with their configuration.
|
|
251
|
+
* Order matters: versions are typically displayed in reverse chronological order.
|
|
252
|
+
*/
|
|
253
|
+
available: z.array(singleVersionSchema).min(1),
|
|
254
|
+
/**
|
|
255
|
+
* List of version identifiers that are deprecated.
|
|
256
|
+
* These versions will show a deprecation banner directing users to the current version.
|
|
257
|
+
* @example ["v1.0", "v0.9"]
|
|
258
|
+
*/
|
|
259
|
+
deprecated: z.array(z.string()).optional().default([]),
|
|
260
|
+
/**
|
|
261
|
+
* The stable version identifier.
|
|
262
|
+
* This may differ from "current" - for example, current could be "latest"
|
|
263
|
+
* while stable is "v2.0" (the last released version).
|
|
264
|
+
* @example "v2.0"
|
|
265
|
+
*/
|
|
266
|
+
stable: z.string().optional(),
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Configuration for a single documentation version.
|
|
271
|
+
* Inferred from singleVersionSchema.
|
|
272
|
+
*/
|
|
273
|
+
export type SingleVersionConfig = z.infer<typeof singleVersionSchema>
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Configuration for documentation versioning.
|
|
277
|
+
* Inferred from versionConfigSchema.
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```ts
|
|
281
|
+
* const versions: VersionConfig = {
|
|
282
|
+
* current: 'v2.0',
|
|
283
|
+
* available: [
|
|
284
|
+
* { version: 'v2.0', label: 'Version 2.0 (Latest)' },
|
|
285
|
+
* { version: 'v1.0', label: 'Version 1.0', banner: 'unmaintained' },
|
|
286
|
+
* ],
|
|
287
|
+
* deprecated: ['v1.0'],
|
|
288
|
+
* stable: 'v2.0',
|
|
289
|
+
* }
|
|
290
|
+
* ```
|
|
291
|
+
*/
|
|
292
|
+
export type VersionConfig = z.infer<typeof versionConfigSchema>
|
|
169
293
|
|
|
170
294
|
/**
|
|
171
295
|
* Configuration for llms.txt generation.
|
|
@@ -251,6 +375,27 @@ export interface DocsConfig {
|
|
|
251
375
|
* ```
|
|
252
376
|
*/
|
|
253
377
|
llmsTxt?: LlmsTxtDocsConfig
|
|
378
|
+
/**
|
|
379
|
+
* Configuration for documentation versioning.
|
|
380
|
+
* When provided, enables multi-version documentation support with version selectors
|
|
381
|
+
* and version-specific content.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```ts
|
|
385
|
+
* shipyardDocs({
|
|
386
|
+
* versions: {
|
|
387
|
+
* current: 'v2.0',
|
|
388
|
+
* available: [
|
|
389
|
+
* { version: 'v2.0', label: 'Version 2.0 (Latest)' },
|
|
390
|
+
* { version: 'v1.0', label: 'Version 1.0', banner: 'unmaintained' },
|
|
391
|
+
* ],
|
|
392
|
+
* deprecated: ['v1.0'],
|
|
393
|
+
* stable: 'v2.0',
|
|
394
|
+
* }
|
|
395
|
+
* })
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
versions?: VersionConfig
|
|
254
399
|
/**
|
|
255
400
|
* Whether to prerender docs pages at build time.
|
|
256
401
|
* When not specified, this is automatically determined from Astro's output mode:
|
|
@@ -289,6 +434,260 @@ export const createDocsCollection = (
|
|
|
289
434
|
loader: glob({ pattern, base: basePath }),
|
|
290
435
|
})
|
|
291
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Schema for versioned docs that includes version metadata.
|
|
439
|
+
* Extends docsSchema with a version field extracted from the file path.
|
|
440
|
+
*/
|
|
441
|
+
export const versionedDocsSchema = docsSchemaBase
|
|
442
|
+
.extend({
|
|
443
|
+
/**
|
|
444
|
+
* The version this document belongs to.
|
|
445
|
+
* Automatically extracted from the directory structure when using createVersionedDocsCollection.
|
|
446
|
+
* @example "v1.0", "v2.0", "latest"
|
|
447
|
+
*/
|
|
448
|
+
version: z.string().optional(),
|
|
449
|
+
})
|
|
450
|
+
.transform(docsSchemaTransform)
|
|
451
|
+
.refine(docsSchemaRefinement.check, { message: docsSchemaRefinement.message })
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Options for configuring a versioned docs collection.
|
|
455
|
+
*/
|
|
456
|
+
export interface VersionedDocsCollectionOptions {
|
|
457
|
+
/**
|
|
458
|
+
* List of version identifiers to include.
|
|
459
|
+
* Each version should have a corresponding directory in the basePath.
|
|
460
|
+
* @example ["v1.0", "v2.0", "latest"]
|
|
461
|
+
*/
|
|
462
|
+
versions: string[]
|
|
463
|
+
/**
|
|
464
|
+
* The fallback version to use when a page doesn't exist in the requested version.
|
|
465
|
+
* Documents from this version will be used as defaults.
|
|
466
|
+
* @example "latest" or "v2.0"
|
|
467
|
+
*/
|
|
468
|
+
fallbackVersion?: string
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Helper function to create a versioned docs content collection configuration.
|
|
473
|
+
* Use this when you need multiple versions of documentation.
|
|
474
|
+
*
|
|
475
|
+
* The expected directory structure is:
|
|
476
|
+
* ```
|
|
477
|
+
* basePath/
|
|
478
|
+
* [version]/
|
|
479
|
+
* [locale]/
|
|
480
|
+
* [...slug].md
|
|
481
|
+
* ```
|
|
482
|
+
*
|
|
483
|
+
* For example:
|
|
484
|
+
* ```
|
|
485
|
+
* docs/
|
|
486
|
+
* v1.0/
|
|
487
|
+
* en/
|
|
488
|
+
* getting-started.md
|
|
489
|
+
* de/
|
|
490
|
+
* getting-started.md
|
|
491
|
+
* v2.0/
|
|
492
|
+
* en/
|
|
493
|
+
* getting-started.md
|
|
494
|
+
* new-feature.md
|
|
495
|
+
* ```
|
|
496
|
+
*
|
|
497
|
+
* @param basePath - The base directory path where versioned docs are located
|
|
498
|
+
* @param options - Configuration for versions
|
|
499
|
+
* @returns A loader and schema configuration for use with defineCollection
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* ```ts
|
|
503
|
+
* import { defineCollection } from 'astro:content'
|
|
504
|
+
* import { createVersionedDocsCollection } from '@levino/shipyard-docs'
|
|
505
|
+
*
|
|
506
|
+
* const docs = defineCollection(createVersionedDocsCollection('./docs', {
|
|
507
|
+
* versions: ['v1.0', 'v2.0', 'latest'],
|
|
508
|
+
* fallbackVersion: 'latest',
|
|
509
|
+
* }))
|
|
510
|
+
*
|
|
511
|
+
* export const collections = { docs }
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
export const createVersionedDocsCollection = (
|
|
515
|
+
basePath: string,
|
|
516
|
+
options: VersionedDocsCollectionOptions,
|
|
517
|
+
) => {
|
|
518
|
+
const { versions } = options
|
|
519
|
+
|
|
520
|
+
// Create a glob pattern that matches all version directories
|
|
521
|
+
// Pattern: {v1.0,v2.0,latest}/**/*.md
|
|
522
|
+
const versionPattern =
|
|
523
|
+
versions.length === 1 ? versions[0] : `{${versions.join(',')}}`
|
|
524
|
+
const pattern = `${versionPattern}/**/*.md`
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
schema: versionedDocsSchema,
|
|
528
|
+
loader: glob({ pattern, base: basePath }),
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Import version helpers for use in document filter functions
|
|
533
|
+
import {
|
|
534
|
+
getVersionFromDocId as getVersionFromDocIdHelper,
|
|
535
|
+
stripVersionFromDocId as stripVersionFromDocIdHelper,
|
|
536
|
+
} from './versionHelpers'
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Filters versioned docs to return only documents for a specific version.
|
|
540
|
+
* Useful for building version-specific sidebars and navigation.
|
|
541
|
+
*
|
|
542
|
+
* @param docs - Array of document entries
|
|
543
|
+
* @param version - The version to filter by
|
|
544
|
+
* @param idAccessor - Function to extract the document ID (defaults to doc.id)
|
|
545
|
+
* @returns Documents matching the specified version
|
|
546
|
+
*
|
|
547
|
+
* @example
|
|
548
|
+
* ```ts
|
|
549
|
+
* const allDocs = await getCollection('docs')
|
|
550
|
+
* const v2Docs = filterDocsByVersion(allDocs, 'v2.0')
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
export const filterDocsByVersion = <T extends { id: string }>(
|
|
554
|
+
docs: readonly T[],
|
|
555
|
+
version: string,
|
|
556
|
+
): T[] => {
|
|
557
|
+
return docs.filter((doc) => {
|
|
558
|
+
const docVersion = getVersionFromDocIdHelper(doc.id)
|
|
559
|
+
return docVersion === version
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Groups versioned documents by their version.
|
|
565
|
+
* Useful for building version overviews or managing content across versions.
|
|
566
|
+
*
|
|
567
|
+
* @param docs - Array of document entries
|
|
568
|
+
* @returns A Map with version strings as keys and arrays of documents as values
|
|
569
|
+
*
|
|
570
|
+
* @example
|
|
571
|
+
* ```ts
|
|
572
|
+
* const allDocs = await getCollection('docs')
|
|
573
|
+
* const byVersion = groupDocsByVersion(allDocs)
|
|
574
|
+
* // Map { "v1.0" => [...], "v2.0" => [...] }
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
export const groupDocsByVersion = <T extends { id: string }>(
|
|
578
|
+
docs: readonly T[],
|
|
579
|
+
): Map<string | undefined, T[]> => {
|
|
580
|
+
const groups = new Map<string | undefined, T[]>()
|
|
581
|
+
for (const doc of docs) {
|
|
582
|
+
const version = getVersionFromDocIdHelper(doc.id)
|
|
583
|
+
const existing = groups.get(version) ?? []
|
|
584
|
+
existing.push(doc)
|
|
585
|
+
groups.set(version, existing)
|
|
586
|
+
}
|
|
587
|
+
return groups
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Finds a document in a fallback version when it doesn't exist in the requested version.
|
|
592
|
+
* Useful for gracefully handling missing version-specific content.
|
|
593
|
+
*
|
|
594
|
+
* @param docs - Array of all document entries
|
|
595
|
+
* @param slug - The slug (path without version) to look for
|
|
596
|
+
* @param requestedVersion - The version that was originally requested
|
|
597
|
+
* @param fallbackVersions - Array of versions to check in order (first match wins)
|
|
598
|
+
* @returns The document from the fallback version, or undefined if not found in any version
|
|
599
|
+
*
|
|
600
|
+
* @example
|
|
601
|
+
* ```ts
|
|
602
|
+
* const allDocs = await getCollection('docs')
|
|
603
|
+
* // User requested v1.0/en/new-feature but it doesn't exist
|
|
604
|
+
* // Fall back to v2.0 or latest
|
|
605
|
+
* const fallbackDoc = findFallbackDoc(
|
|
606
|
+
* allDocs,
|
|
607
|
+
* 'en/new-feature',
|
|
608
|
+
* 'v1.0',
|
|
609
|
+
* ['v2.0', 'latest']
|
|
610
|
+
* )
|
|
611
|
+
* ```
|
|
612
|
+
*/
|
|
613
|
+
export const findFallbackDoc = <T extends { id: string }>(
|
|
614
|
+
docs: readonly T[],
|
|
615
|
+
slug: string,
|
|
616
|
+
requestedVersion: string,
|
|
617
|
+
fallbackVersions: string[],
|
|
618
|
+
): { doc: T; version: string } | undefined => {
|
|
619
|
+
// Check each fallback version in order
|
|
620
|
+
for (const version of fallbackVersions) {
|
|
621
|
+
// Skip the requested version since we already know it doesn't exist there
|
|
622
|
+
if (version === requestedVersion) continue
|
|
623
|
+
|
|
624
|
+
const targetId = `${version}/${slug}`
|
|
625
|
+
const doc = docs.find((d) => d.id === targetId)
|
|
626
|
+
if (doc) {
|
|
627
|
+
return { doc, version }
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return undefined
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Checks if a document exists for a specific version.
|
|
635
|
+
* Useful for determining whether to use fallback logic.
|
|
636
|
+
*
|
|
637
|
+
* @param docs - Array of document entries
|
|
638
|
+
* @param slug - The slug (path without version) to look for
|
|
639
|
+
* @param version - The version to check
|
|
640
|
+
* @returns True if the document exists in the specified version
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* const allDocs = await getCollection('docs')
|
|
645
|
+
* if (!docExistsInVersion(allDocs, 'en/new-feature', 'v1.0')) {
|
|
646
|
+
* // Handle missing document - show 404 or redirect to another version
|
|
647
|
+
* }
|
|
648
|
+
* ```
|
|
649
|
+
*/
|
|
650
|
+
export const docExistsInVersion = <T extends { id: string }>(
|
|
651
|
+
docs: readonly T[],
|
|
652
|
+
slug: string,
|
|
653
|
+
version: string,
|
|
654
|
+
): boolean => {
|
|
655
|
+
const targetId = `${version}/${slug}`
|
|
656
|
+
return docs.some((d) => d.id === targetId)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Gets all available versions for a specific slug.
|
|
661
|
+
* Useful for showing a version switcher with only versions that have this document.
|
|
662
|
+
*
|
|
663
|
+
* @param docs - Array of document entries
|
|
664
|
+
* @param slug - The slug (path without version) to look for
|
|
665
|
+
* @returns Array of version strings where this document exists
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* ```ts
|
|
669
|
+
* const allDocs = await getCollection('docs')
|
|
670
|
+
* const versions = getDocVersions(allDocs, 'en/getting-started')
|
|
671
|
+
* // ['v1.0', 'v2.0', 'latest'] - shows in which versions this doc exists
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
export const getDocVersions = <T extends { id: string }>(
|
|
675
|
+
docs: readonly T[],
|
|
676
|
+
slug: string,
|
|
677
|
+
): string[] => {
|
|
678
|
+
const versions: string[] = []
|
|
679
|
+
for (const doc of docs) {
|
|
680
|
+
const docVersion = getVersionFromDocIdHelper(doc.id)
|
|
681
|
+
if (docVersion) {
|
|
682
|
+
const docSlug = stripVersionFromDocIdHelper(doc.id)
|
|
683
|
+
if (docSlug === slug && !versions.includes(docVersion)) {
|
|
684
|
+
versions.push(docVersion)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return versions
|
|
689
|
+
}
|
|
690
|
+
|
|
292
691
|
/**
|
|
293
692
|
* shipyard Docs integration for Astro.
|
|
294
693
|
*
|
|
@@ -325,6 +724,7 @@ const docsConfigs: Record<
|
|
|
325
724
|
routeBasePath: string
|
|
326
725
|
collectionName: string
|
|
327
726
|
llmsTxtEnabled: boolean
|
|
727
|
+
versions?: VersionConfig
|
|
328
728
|
}
|
|
329
729
|
> = {}
|
|
330
730
|
|
|
@@ -336,9 +736,20 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
336
736
|
showLastUpdateTime = false,
|
|
337
737
|
showLastUpdateAuthor = false,
|
|
338
738
|
llmsTxt,
|
|
739
|
+
versions,
|
|
339
740
|
prerender: prerenderConfig,
|
|
340
741
|
} = config
|
|
341
742
|
|
|
743
|
+
// Validate versions config if provided
|
|
744
|
+
if (versions) {
|
|
745
|
+
const parseResult = versionConfigSchema.safeParse(versions)
|
|
746
|
+
if (!parseResult.success) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
`Invalid versions configuration: ${parseResult.error.message}`,
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
342
753
|
// Normalize the route base path (remove leading/trailing slashes safely)
|
|
343
754
|
let normalizedBasePath = routeBasePath
|
|
344
755
|
while (normalizedBasePath.startsWith('/')) {
|
|
@@ -359,6 +770,7 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
359
770
|
routeBasePath: normalizedBasePath,
|
|
360
771
|
collectionName: resolvedCollectionName,
|
|
361
772
|
llmsTxtEnabled: !!llmsTxt?.enabled,
|
|
773
|
+
versions,
|
|
362
774
|
}
|
|
363
775
|
|
|
364
776
|
// Virtual module for this specific route's config
|
|
@@ -399,11 +811,12 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
399
811
|
// Generate the entry file with the correct routeBasePath and collectionName
|
|
400
812
|
// Note: We inline the values directly in getStaticPaths because Astro's compiler
|
|
401
813
|
// hoists getStaticPaths to a separate module context where top-level constants aren't available
|
|
814
|
+
const hasVersions = !!versions
|
|
402
815
|
const entryFileContent = `---
|
|
403
816
|
import { i18n } from 'astro:config/server'
|
|
404
817
|
import { getCollection, render } from 'astro:content'
|
|
405
818
|
import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
406
|
-
import { getEditUrl, getGitMetadata } from '@levino/shipyard-docs'
|
|
819
|
+
import { createVersionPathMap, getEditUrl, getGitMetadata, getVersionFromDocId, stripVersionFromDocId } from '@levino/shipyard-docs'
|
|
407
820
|
import Layout from '@levino/shipyard-docs/astro/Layout.astro'
|
|
408
821
|
|
|
409
822
|
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
@@ -414,45 +827,100 @@ export async function getStaticPaths() {
|
|
|
414
827
|
// getStaticPaths separately and module-level constants are not available
|
|
415
828
|
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
416
829
|
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
830
|
+
const hasVersions = ${JSON.stringify(hasVersions)}
|
|
831
|
+
const versionsConfig = ${JSON.stringify(versions || null)}
|
|
417
832
|
const allDocs = await getCollection(collectionName)
|
|
418
833
|
|
|
419
834
|
// Filter out pages with render: false - they should not generate pages
|
|
420
835
|
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
421
836
|
|
|
422
|
-
|
|
837
|
+
// Pre-compute version path map for O(1) lookups instead of O(V) per document
|
|
838
|
+
const versionPathMap = hasVersions && versionsConfig
|
|
839
|
+
? createVersionPathMap(versionsConfig)
|
|
840
|
+
: null
|
|
841
|
+
|
|
842
|
+
const getParams = (slug, version) => {
|
|
423
843
|
if (i18n) {
|
|
424
844
|
const [locale, ...rest] = slug.split('/')
|
|
425
|
-
|
|
845
|
+
const baseParams = {
|
|
426
846
|
slug: rest.length ? rest.join('/') : undefined,
|
|
427
847
|
locale,
|
|
428
848
|
}
|
|
849
|
+
return version ? { ...baseParams, version } : baseParams
|
|
429
850
|
} else {
|
|
430
|
-
|
|
851
|
+
const baseParams = {
|
|
431
852
|
slug: slug || undefined,
|
|
432
853
|
}
|
|
854
|
+
return version ? { ...baseParams, version } : baseParams
|
|
433
855
|
}
|
|
434
856
|
}
|
|
435
857
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
858
|
+
const paths = []
|
|
859
|
+
|
|
860
|
+
for (const entry of docs) {
|
|
861
|
+
// For versioned docs, extract version from the doc ID (e.g., "v1.0/en/getting-started")
|
|
862
|
+
let version = null
|
|
863
|
+
let docIdWithoutVersion = entry.id
|
|
864
|
+
|
|
865
|
+
if (hasVersions && versionsConfig && versionPathMap) {
|
|
866
|
+
const extractedVersion = getVersionFromDocId(entry.id)
|
|
867
|
+
if (extractedVersion) {
|
|
868
|
+
// Use pre-computed map for O(1) lookup instead of array.find()
|
|
869
|
+
version = versionPathMap.get(extractedVersion) ?? extractedVersion
|
|
870
|
+
docIdWithoutVersion = stripVersionFromDocId(entry.id)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Extract locale from docIdWithoutVersion for i18n builds
|
|
875
|
+
const docLocale = i18n ? docIdWithoutVersion.split('/')[0] : undefined
|
|
876
|
+
|
|
877
|
+
// Add the main path for this doc
|
|
878
|
+
paths.push({
|
|
879
|
+
params: getParams(docIdWithoutVersion, version),
|
|
880
|
+
props: { entry, routeBasePath, version, isLatestAlias: false, docLocale },
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
// If this doc is in the current version, also generate a 'latest' alias path that redirects
|
|
884
|
+
if (hasVersions && versionsConfig && version) {
|
|
885
|
+
const extractedVersion = getVersionFromDocId(entry.id)
|
|
886
|
+
const currentVersion = versionsConfig.current
|
|
887
|
+
if (extractedVersion === currentVersion) {
|
|
888
|
+
paths.push({
|
|
889
|
+
params: getParams(docIdWithoutVersion, 'latest'),
|
|
890
|
+
props: { entry, routeBasePath, version: 'latest', actualVersion: version, isLatestAlias: true, docLocale },
|
|
891
|
+
})
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return paths
|
|
440
897
|
}
|
|
441
898
|
|
|
442
899
|
// In SSR mode (prerender: false), getStaticPaths is not called so Astro.props.entry will be undefined.
|
|
443
900
|
// We need to fetch the entry from the collection based on URL params.
|
|
444
|
-
let entry = Astro.props
|
|
901
|
+
let { entry, routeBasePath: propsRouteBasePath, version, actualVersion, isLatestAlias, docLocale } = Astro.props
|
|
902
|
+
const { slug: pageSlug, locale, version: urlVersion } = Astro.params
|
|
903
|
+
|
|
904
|
+
// SSR mode: fetch entry dynamically when props are not available from getStaticPaths
|
|
445
905
|
if (!entry) {
|
|
446
906
|
const allDocs = await getCollection(collectionName)
|
|
447
907
|
const docs = allDocs.filter((doc) => doc.data.render !== false)
|
|
448
908
|
|
|
449
909
|
// Reconstruct the entry ID from URL params
|
|
450
|
-
const { locale, slug } = Astro.params
|
|
451
910
|
let entryId
|
|
452
911
|
if (i18n && locale) {
|
|
453
|
-
|
|
912
|
+
// For versioned docs, include version in the entry ID
|
|
913
|
+
if (urlVersion) {
|
|
914
|
+
entryId = pageSlug ? urlVersion + '/' + locale + '/' + pageSlug : urlVersion + '/' + locale
|
|
915
|
+
} else {
|
|
916
|
+
entryId = pageSlug ? locale + '/' + pageSlug : locale
|
|
917
|
+
}
|
|
454
918
|
} else {
|
|
455
|
-
|
|
919
|
+
if (urlVersion) {
|
|
920
|
+
entryId = pageSlug ? urlVersion + '/' + pageSlug : urlVersion
|
|
921
|
+
} else {
|
|
922
|
+
entryId = pageSlug ?? ''
|
|
923
|
+
}
|
|
456
924
|
}
|
|
457
925
|
|
|
458
926
|
// Find the matching entry
|
|
@@ -470,6 +938,43 @@ if (!entry) {
|
|
|
470
938
|
if (!entry) {
|
|
471
939
|
return Astro.redirect('/404')
|
|
472
940
|
}
|
|
941
|
+
|
|
942
|
+
// Set version from URL params for SSR mode
|
|
943
|
+
version = urlVersion
|
|
944
|
+
isLatestAlias = false
|
|
945
|
+
docLocale = locale
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// SEO-friendly redirect for /latest/ URLs to canonical version URLs
|
|
949
|
+
// We handle the redirect inline below since Astro.redirect() doesn't work reliably
|
|
950
|
+
// with i18n fallback pages
|
|
951
|
+
const redirectInfo = isLatestAlias && actualVersion ? {
|
|
952
|
+
locale: docLocale,
|
|
953
|
+
targetUrl: docLocale
|
|
954
|
+
? (pageSlug
|
|
955
|
+
? \`/\${docLocale}/\${routeBasePath}/\${actualVersion}/\${pageSlug}\`
|
|
956
|
+
: \`/\${docLocale}/\${routeBasePath}/\${actualVersion}/\`)
|
|
957
|
+
: (pageSlug
|
|
958
|
+
? \`/\${routeBasePath}/\${actualVersion}/\${pageSlug}\`
|
|
959
|
+
: \`/\${routeBasePath}/\${actualVersion}/\`),
|
|
960
|
+
fromUrl: docLocale
|
|
961
|
+
? (pageSlug
|
|
962
|
+
? \`/\${docLocale}/\${routeBasePath}/latest/\${pageSlug}\`
|
|
963
|
+
: \`/\${docLocale}/\${routeBasePath}/latest/\`)
|
|
964
|
+
: (pageSlug
|
|
965
|
+
? \`/\${routeBasePath}/latest/\${pageSlug}\`
|
|
966
|
+
: \`/\${routeBasePath}/latest/\`),
|
|
967
|
+
} : null
|
|
968
|
+
|
|
969
|
+
// If this is a redirect, return early with a minimal redirect page
|
|
970
|
+
if (redirectInfo) {
|
|
971
|
+
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>\`, {
|
|
972
|
+
status: 301,
|
|
973
|
+
headers: {
|
|
974
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
975
|
+
'Location': redirectInfo.targetUrl,
|
|
976
|
+
},
|
|
977
|
+
})
|
|
473
978
|
}
|
|
474
979
|
|
|
475
980
|
const docsConfig = docsConfigs[routeBasePath] ?? {
|
|
@@ -479,6 +984,11 @@ const docsConfig = docsConfigs[routeBasePath] ?? {
|
|
|
479
984
|
collectionName: 'docs',
|
|
480
985
|
}
|
|
481
986
|
|
|
987
|
+
// Version is available for use in Layout/components if needed
|
|
988
|
+
// For 'latest' alias URLs, actualVersion contains the real version
|
|
989
|
+
const currentVersion = isLatestAlias ? actualVersion : version
|
|
990
|
+
const displayVersion = version // The version shown in the URL
|
|
991
|
+
|
|
482
992
|
const { Content, headings } = await render(entry)
|
|
483
993
|
|
|
484
994
|
const { customEditUrl, lastUpdateAuthor, lastUpdateTime, hideTableOfContents, hideTitle, keywords, image, canonicalUrl, customMetaTags, title_meta: titleMeta } = entry.data
|
|
@@ -554,7 +1064,69 @@ if (
|
|
|
554
1064
|
},
|
|
555
1065
|
load(id) {
|
|
556
1066
|
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
557
|
-
|
|
1067
|
+
// Generate the virtual module with docsConfigs and version helper functions
|
|
1068
|
+
const virtualModuleCode = `export const docsConfigs = ${JSON.stringify(docsConfigs)};
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Get the version configuration for a specific docs instance.
|
|
1072
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1073
|
+
* @returns {import('./index').VersionConfig | undefined}
|
|
1074
|
+
*/
|
|
1075
|
+
export function getVersionConfig(routeBasePath = 'docs') {
|
|
1076
|
+
return docsConfigs[routeBasePath]?.versions;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Get the current/default version for a docs instance.
|
|
1081
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1082
|
+
* @returns {string | undefined}
|
|
1083
|
+
*/
|
|
1084
|
+
export function getCurrentVersion(routeBasePath = 'docs') {
|
|
1085
|
+
return docsConfigs[routeBasePath]?.versions?.current;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Get all available versions for a docs instance.
|
|
1090
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1091
|
+
* @returns {import('./index').SingleVersionConfig[]}
|
|
1092
|
+
*/
|
|
1093
|
+
export function getAvailableVersions(routeBasePath = 'docs') {
|
|
1094
|
+
return docsConfigs[routeBasePath]?.versions?.available ?? [];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Check if a version is deprecated.
|
|
1099
|
+
* @param {string} version - The version string to check
|
|
1100
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1101
|
+
* @returns {boolean}
|
|
1102
|
+
*/
|
|
1103
|
+
export function isVersionDeprecated(version, routeBasePath = 'docs') {
|
|
1104
|
+
const config = docsConfigs[routeBasePath]?.versions;
|
|
1105
|
+
if (!config) return false;
|
|
1106
|
+
return config.deprecated?.includes(version) ?? false;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Get the stable version for a docs instance.
|
|
1111
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1112
|
+
* @returns {string | undefined}
|
|
1113
|
+
*/
|
|
1114
|
+
export function getStableVersion(routeBasePath = 'docs') {
|
|
1115
|
+
const config = docsConfigs[routeBasePath]?.versions;
|
|
1116
|
+
if (!config) return undefined;
|
|
1117
|
+
return config.stable ?? config.current;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Check if versioning is enabled for a docs instance.
|
|
1122
|
+
* @param {string} [routeBasePath='docs'] - The route base path
|
|
1123
|
+
* @returns {boolean}
|
|
1124
|
+
*/
|
|
1125
|
+
export function hasVersioning(routeBasePath = 'docs') {
|
|
1126
|
+
return !!docsConfigs[routeBasePath]?.versions;
|
|
1127
|
+
}
|
|
1128
|
+
`
|
|
1129
|
+
return virtualModuleCode
|
|
558
1130
|
}
|
|
559
1131
|
if (id === resolvedRouteConfigVirtualId) {
|
|
560
1132
|
return `export const routeBasePath = ${JSON.stringify(normalizedBasePath)};\nexport const collectionName = ${JSON.stringify(resolvedCollectionName)};`
|
|
@@ -567,18 +1139,97 @@ if (
|
|
|
567
1139
|
|
|
568
1140
|
if (astroConfig.i18n) {
|
|
569
1141
|
// With i18n: use locale prefix
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1142
|
+
if (versions) {
|
|
1143
|
+
// Versioned routes: /[locale]/[routeBasePath]/[version]/[...slug]
|
|
1144
|
+
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the frontmatter
|
|
1145
|
+
injectRoute({
|
|
1146
|
+
pattern: `/[locale]/${normalizedBasePath}/[version]/[...slug]`,
|
|
1147
|
+
entrypoint: entryFilePath,
|
|
1148
|
+
prerender,
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
// Generate redirect from docs root to current version
|
|
1152
|
+
const redirectFileName = `docs-redirect-${normalizedBasePath}.astro`
|
|
1153
|
+
const redirectFilePath = join(generatedDir, redirectFileName)
|
|
1154
|
+
const currentVersionPath =
|
|
1155
|
+
versions.available.find((v) => v.version === versions.current)
|
|
1156
|
+
?.path ?? versions.current
|
|
1157
|
+
const redirectFileContent = `---
|
|
1158
|
+
import { i18n } from 'astro:config/server'
|
|
1159
|
+
|
|
1160
|
+
export function getStaticPaths() {
|
|
1161
|
+
const locales = i18n?.locales ?? ['en']
|
|
1162
|
+
return locales.map((locale) => {
|
|
1163
|
+
const localeCode = typeof locale === 'string' ? locale : locale.path
|
|
1164
|
+
return { params: { locale: localeCode } }
|
|
1165
|
+
})
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const { locale } = Astro.params
|
|
1169
|
+
const currentVersion = ${JSON.stringify(currentVersionPath)}
|
|
1170
|
+
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
1171
|
+
|
|
1172
|
+
// Redirect to the current version's index
|
|
1173
|
+
return Astro.redirect(\`/\${locale}/\${routeBasePath}/\${currentVersion}/\`, 302)
|
|
1174
|
+
---
|
|
1175
|
+
`
|
|
1176
|
+
writeFileSync(redirectFilePath, redirectFileContent)
|
|
1177
|
+
|
|
1178
|
+
// Inject redirect route for docs root (without trailing slash)
|
|
1179
|
+
injectRoute({
|
|
1180
|
+
pattern: `/[locale]/${normalizedBasePath}`,
|
|
1181
|
+
entrypoint: redirectFilePath,
|
|
1182
|
+
prerender,
|
|
1183
|
+
})
|
|
1184
|
+
} else {
|
|
1185
|
+
// Non-versioned routes: /[locale]/[routeBasePath]/[...slug]
|
|
1186
|
+
injectRoute({
|
|
1187
|
+
pattern: `/[locale]/${normalizedBasePath}/[...slug]`,
|
|
1188
|
+
entrypoint: entryFilePath,
|
|
1189
|
+
prerender,
|
|
1190
|
+
})
|
|
1191
|
+
}
|
|
575
1192
|
} else {
|
|
576
1193
|
// Without i18n: direct path
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1194
|
+
if (versions) {
|
|
1195
|
+
// Versioned routes: /[routeBasePath]/[version]/[...slug]
|
|
1196
|
+
// Note: 'latest' alias paths are generated in getStaticPaths and redirect in the frontmatter
|
|
1197
|
+
injectRoute({
|
|
1198
|
+
pattern: `/${normalizedBasePath}/[version]/[...slug]`,
|
|
1199
|
+
entrypoint: entryFilePath,
|
|
1200
|
+
prerender,
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
// Generate redirect from docs root to current version
|
|
1204
|
+
const redirectFileName = `docs-redirect-${normalizedBasePath}.astro`
|
|
1205
|
+
const redirectFilePath = join(generatedDir, redirectFileName)
|
|
1206
|
+
const currentVersionPath =
|
|
1207
|
+
versions.available.find((v) => v.version === versions.current)
|
|
1208
|
+
?.path ?? versions.current
|
|
1209
|
+
const redirectFileContent = `---
|
|
1210
|
+
const currentVersion = ${JSON.stringify(currentVersionPath)}
|
|
1211
|
+
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
1212
|
+
|
|
1213
|
+
// Redirect to the current version's index
|
|
1214
|
+
return Astro.redirect(\`/\${routeBasePath}/\${currentVersion}/\`, 302)
|
|
1215
|
+
---
|
|
1216
|
+
`
|
|
1217
|
+
writeFileSync(redirectFilePath, redirectFileContent)
|
|
1218
|
+
|
|
1219
|
+
// Inject redirect route for docs root (without trailing slash)
|
|
1220
|
+
injectRoute({
|
|
1221
|
+
pattern: `/${normalizedBasePath}`,
|
|
1222
|
+
entrypoint: redirectFilePath,
|
|
1223
|
+
prerender,
|
|
1224
|
+
})
|
|
1225
|
+
} else {
|
|
1226
|
+
// Non-versioned routes: /[routeBasePath]/[...slug]
|
|
1227
|
+
injectRoute({
|
|
1228
|
+
pattern: `/${normalizedBasePath}/[...slug]`,
|
|
1229
|
+
entrypoint: entryFilePath,
|
|
1230
|
+
prerender,
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
582
1233
|
}
|
|
583
1234
|
|
|
584
1235
|
// Generate llms.txt routes if enabled
|