@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/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 type { DocsRouteConfig } from './routeHelpers'
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 { getDocPath, getRouteParams } from './routeHelpers'
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
- export const docsSchema = z
50
- .object({
51
- // === Page Metadata ===
52
- /** Custom document ID (default: file path) */
53
- id: z.string().optional(),
54
- /** Reference title for SEO, pagination, previews (default: H1) */
55
- title: z.string().optional(),
56
- /** Override title for SEO/browser tab (default: title) - Docusaurus snake_case convention */
57
- title_meta: z.string().optional(),
58
- /** Meta description (default: first paragraph) */
59
- description: z.string().optional(),
60
- /** SEO keywords */
61
- keywords: z.array(z.string()).optional(),
62
- /** Social preview image (og:image) */
63
- image: z.string().optional(),
64
- /** Custom canonical URL */
65
- canonicalUrl: z.string().optional(),
66
- /** Custom canonical URL - snake_case alias for Docusaurus compatibility */
67
- canonical_url: z.string().optional(),
68
-
69
- // === Page Rendering ===
70
- /** Whether to render a page (default: true) */
71
- render: z.boolean().default(true),
72
- /** Exclude from production builds (default: false) */
73
- draft: z.boolean().default(false),
74
- /** Render page but hide from sidebar (default: false) */
75
- unlisted: z.boolean().default(false),
76
- /** Custom URL slug */
77
- slug: z.string().optional(),
78
-
79
- // === Layout Options ===
80
- /** Hide the H1 heading (default: false) */
81
- hideTitle: z.boolean().default(false),
82
- /** Hide the H1 heading (default: false) - snake_case alias for Docusaurus compatibility */
83
- hide_title: z.boolean().optional(),
84
- /** Hide the TOC (default: false) */
85
- hideTableOfContents: z.boolean().default(false),
86
- /** Hide the TOC (default: false) - snake_case alias for Docusaurus compatibility */
87
- hide_table_of_contents: z.boolean().optional(),
88
- /** Full-width page without sidebar (default: false) */
89
- hideSidebar: z.boolean().default(false),
90
- /** Min heading level in TOC (default: 2) */
91
- tocMinHeadingLevel: z.number().min(1).max(6).default(2),
92
- /** Max heading level in TOC (default: 3) */
93
- tocMaxHeadingLevel: z.number().min(1).max(6).default(3),
94
-
95
- // === Sidebar Configuration (grouped) ===
96
- sidebar: sidebarSchema.default({ collapsible: true, collapsed: true }),
97
-
98
- // === Pagination ===
99
- /** Label shown in prev/next buttons */
100
- paginationLabel: z.string().optional(),
101
- /** Label shown in prev/next buttons - snake_case alias for Docusaurus compatibility */
102
- pagination_label: z.string().optional(),
103
- /** Next page ID, or null to disable */
104
- paginationNext: z.string().nullable().optional(),
105
- /** Next page ID - snake_case alias for Docusaurus compatibility */
106
- pagination_next: z.string().nullable().optional(),
107
- /** Previous page ID, or null to disable */
108
- paginationPrev: z.string().nullable().optional(),
109
- /** Previous page ID - snake_case alias for Docusaurus compatibility */
110
- pagination_prev: z.string().nullable().optional(),
111
-
112
- // === Git Metadata Overrides ===
113
- /**
114
- * Override the last update author for this specific page.
115
- * Set to false to hide the author for this page.
116
- */
117
- lastUpdateAuthor: z.union([z.string(), z.literal(false)]).optional(),
118
- /**
119
- * Override the last update timestamp for this specific page.
120
- * Set to false to hide the timestamp for this page.
121
- */
122
- lastUpdateTime: z.union([z.literal(false), z.coerce.date()]).optional(),
123
- /**
124
- * Custom edit URL for this specific page.
125
- * Set to null to disable edit link for this page.
126
- */
127
- customEditUrl: z.string().nullable().optional(),
128
-
129
- // === Custom Meta Tags ===
130
- customMetaTags: z
131
- .array(
132
- z.object({
133
- name: z.string().optional(),
134
- property: z.string().optional(),
135
- content: z.string(),
136
- }),
137
- )
138
- .optional(),
139
- /** Custom meta tags - snake_case alias for Docusaurus compatibility */
140
- custom_meta_tags: z
141
- .array(
142
- z.object({
143
- name: z.string().optional(),
144
- property: z.string().optional(),
145
- content: z.string(),
146
- }),
147
- )
148
- .optional(),
149
- })
150
- .transform((data) => ({
151
- ...data,
152
- // Merge snake_case aliases into camelCase fields
153
- hideTitle: data.hide_title ?? data.hideTitle,
154
- hideTableOfContents:
155
- data.hide_table_of_contents ?? data.hideTableOfContents,
156
- canonicalUrl: data.canonical_url ?? data.canonicalUrl,
157
- customMetaTags: data.custom_meta_tags ?? data.customMetaTags,
158
- paginationLabel: data.pagination_label ?? data.paginationLabel,
159
- // For nullable fields, we need to check if the snake_case key exists (not just use ??)
160
- // because null ?? undefined = undefined, but we want null to be preserved
161
- paginationNext:
162
- 'pagination_next' in data ? data.pagination_next : data.paginationNext,
163
- paginationPrev:
164
- 'pagination_prev' in data ? data.pagination_prev : data.paginationPrev,
165
- }))
166
- .refine((data) => data.tocMinHeadingLevel <= data.tocMaxHeadingLevel, {
167
- message: 'tocMinHeadingLevel must be <= tocMaxHeadingLevel',
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
- const getParams = (slug) => {
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
- return {
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
- return {
851
+ const baseParams = {
431
852
  slug: slug || undefined,
432
853
  }
854
+ return version ? { ...baseParams, version } : baseParams
433
855
  }
434
856
  }
435
857
 
436
- return docs.map((entry) => ({
437
- params: getParams(entry.id),
438
- props: { entry, routeBasePath },
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.entry
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
- entryId = slug ? locale + '/' + slug : locale
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
- entryId = slug ?? ''
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
- return `export const docsConfigs = ${JSON.stringify(docsConfigs)};`
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
- injectRoute({
571
- pattern: `/[locale]/${normalizedBasePath}/[...slug]`,
572
- entrypoint: entryFilePath,
573
- prerender,
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
- injectRoute({
578
- pattern: `/${normalizedBasePath}/[...slug]`,
579
- entrypoint: entryFilePath,
580
- prerender,
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