@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.
@@ -1,4 +1,5 @@
1
1
  import type { CollectionEntry } from 'astro:content'
2
+ import type { SingleVersionConfig, VersionConfig } from './index'
2
3
 
3
4
  /**
4
5
  * Configuration for generating static paths for a docs collection.
@@ -76,3 +77,223 @@ export const getDocPath = (
76
77
  * Type for docs collection entry with inferred data structure.
77
78
  */
78
79
  export type DocsEntry = CollectionEntry<'docs'>
80
+
81
+ /**
82
+ * Get the URL path segment for a version.
83
+ * Uses the version's `path` property if defined, otherwise uses the version string.
84
+ *
85
+ * @param version - The version string to look up
86
+ * @param versions - The version configuration
87
+ * @returns The path segment to use in URLs, or undefined if version not found
88
+ */
89
+ export const getVersionPath = (
90
+ version: string,
91
+ versions: VersionConfig,
92
+ ): string | undefined => {
93
+ const versionConfig = versions.available.find((v) => v.version === version)
94
+ if (!versionConfig) return undefined
95
+ return versionConfig.path ?? versionConfig.version
96
+ }
97
+
98
+ /**
99
+ * Get the current/default version from version configuration.
100
+ *
101
+ * @param versions - The version configuration
102
+ * @returns The current version string
103
+ */
104
+ export const getCurrentVersion = (versions: VersionConfig): string => {
105
+ return versions.current
106
+ }
107
+
108
+ /**
109
+ * Get all available versions from version configuration.
110
+ *
111
+ * @param versions - The version configuration
112
+ * @returns Array of available version configurations
113
+ */
114
+ export const getAvailableVersions = (
115
+ versions: VersionConfig,
116
+ ): SingleVersionConfig[] => {
117
+ return versions.available
118
+ }
119
+
120
+ /**
121
+ * Check if a version is deprecated.
122
+ *
123
+ * @param version - The version string to check
124
+ * @param versions - The version configuration
125
+ * @returns True if the version is in the deprecated list
126
+ */
127
+ export const isVersionDeprecated = (
128
+ version: string,
129
+ versions: VersionConfig,
130
+ ): boolean => {
131
+ return versions.deprecated?.includes(version) ?? false
132
+ }
133
+
134
+ /**
135
+ * Get the stable version from configuration.
136
+ * Falls back to current if stable is not explicitly set.
137
+ *
138
+ * @param versions - The version configuration
139
+ * @returns The stable version string
140
+ */
141
+ export const getStableVersion = (versions: VersionConfig): string => {
142
+ return versions.stable ?? versions.current
143
+ }
144
+
145
+ /**
146
+ * Find a version configuration by its version string or path.
147
+ *
148
+ * @param versionOrPath - The version string or path segment
149
+ * @param versions - The version configuration
150
+ * @returns The matching version config, or undefined if not found
151
+ */
152
+ export const findVersionConfig = (
153
+ versionOrPath: string,
154
+ versions: VersionConfig,
155
+ ): SingleVersionConfig | undefined => {
156
+ return versions.available.find(
157
+ (v) => v.version === versionOrPath || v.path === versionOrPath,
158
+ )
159
+ }
160
+
161
+ /**
162
+ * Generate the full URL path for a versioned doc entry.
163
+ *
164
+ * URL structure with versions:
165
+ * - Without i18n: /[routeBasePath]/[version]/[...slug]
166
+ * - With i18n: /[locale]/[routeBasePath]/[version]/[...slug]
167
+ *
168
+ * @param id - The doc entry ID
169
+ * @param routeBasePath - The base path for routes
170
+ * @param hasI18n - Whether i18n is enabled
171
+ * @param version - The version path segment (not the version string, use getVersionPath first)
172
+ * @param currentLocale - The current locale (for i18n)
173
+ * @returns The full URL path including version
174
+ */
175
+ export const getVersionedDocPath = (
176
+ id: string,
177
+ routeBasePath: string,
178
+ hasI18n: boolean,
179
+ version: string,
180
+ currentLocale?: string,
181
+ ): string => {
182
+ // Normalize base path - remove leading and trailing slashes
183
+ let normalizedBasePath = routeBasePath
184
+ while (normalizedBasePath.startsWith('/')) {
185
+ normalizedBasePath = normalizedBasePath.slice(1)
186
+ }
187
+ while (normalizedBasePath.endsWith('/')) {
188
+ normalizedBasePath = normalizedBasePath.slice(0, -1)
189
+ }
190
+
191
+ // Normalize version - remove leading and trailing slashes
192
+ let normalizedVersion = version
193
+ while (normalizedVersion.startsWith('/')) {
194
+ normalizedVersion = normalizedVersion.slice(1)
195
+ }
196
+ while (normalizedVersion.endsWith('/')) {
197
+ normalizedVersion = normalizedVersion.slice(0, -1)
198
+ }
199
+
200
+ if (hasI18n && currentLocale) {
201
+ // Remove locale prefix from id (e.g., 'en/guide/intro' -> 'guide/intro')
202
+ const pathWithoutLocale = id.includes('/')
203
+ ? id.slice(id.indexOf('/') + 1)
204
+ : id
205
+ return `/${currentLocale}/${normalizedBasePath}/${normalizedVersion}/${pathWithoutLocale}`
206
+ }
207
+
208
+ return `/${normalizedBasePath}/${normalizedVersion}/${id}`
209
+ }
210
+
211
+ /**
212
+ * Generate route parameters from a versioned doc entry's slug.
213
+ *
214
+ * @param slug - The slug from the doc entry (may include version prefix)
215
+ * @param hasI18n - Whether i18n is enabled
216
+ * @param version - The version path segment
217
+ * @returns Route parameters object including version
218
+ */
219
+ export const getVersionedRouteParams = (
220
+ slug: string,
221
+ hasI18n: boolean,
222
+ version: string,
223
+ ) => {
224
+ const baseParams = getRouteParams(slug, hasI18n)
225
+ return {
226
+ ...baseParams,
227
+ version,
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Convert a path from one version to another.
233
+ * Useful for "view this page in another version" links.
234
+ *
235
+ * @param currentPath - The current full URL path
236
+ * @param targetVersion - The target version path segment
237
+ * @param currentVersion - The current version path segment
238
+ * @returns The path with version replaced
239
+ */
240
+ export const switchVersionInPath = (
241
+ currentPath: string,
242
+ targetVersion: string,
243
+ currentVersion: string,
244
+ ): string => {
245
+ // Replace the version segment in the path
246
+ // Handle both /docs/v1/page and /en/docs/v1/page patterns
247
+ return currentPath.replace(
248
+ new RegExp(`/${escapeRegex(currentVersion)}/`),
249
+ `/${targetVersion}/`,
250
+ )
251
+ }
252
+
253
+ /**
254
+ * Escape special regex characters in a string.
255
+ * @internal
256
+ */
257
+ const escapeRegex = (str: string): string => {
258
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
259
+ }
260
+
261
+ /**
262
+ * Creates a Map for efficient version path lookups.
263
+ * Use this when processing many documents to avoid repeated array.find() calls.
264
+ *
265
+ * @param versions - The version configuration
266
+ * @returns A Map from version string to URL path segment
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const versionPathMap = createVersionPathMap(versionsConfig)
271
+ * // Now use versionPathMap.get(version) instead of getVersionPath(version, versionsConfig)
272
+ * for (const doc of docs) {
273
+ * const version = getVersionFromDocId(doc.id)
274
+ * const versionPath = versionPathMap.get(version) ?? version
275
+ * }
276
+ * ```
277
+ */
278
+ export const createVersionPathMap = (
279
+ versions: VersionConfig,
280
+ ): Map<string, string> => {
281
+ const map = new Map<string, string>()
282
+ for (const v of versions.available) {
283
+ map.set(v.version, v.path ?? v.version)
284
+ }
285
+ return map
286
+ }
287
+
288
+ /**
289
+ * Creates a Set of deprecated versions for efficient lookup.
290
+ * Use this when checking many documents for deprecation status.
291
+ *
292
+ * @param versions - The version configuration
293
+ * @returns A Set of deprecated version strings
294
+ */
295
+ export const createDeprecatedVersionSet = (
296
+ versions: VersionConfig,
297
+ ): Set<string> => {
298
+ return new Set(versions.deprecated ?? [])
299
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { DocsData } from './sidebarEntries'
3
- import { toSidebarEntries } from './sidebarEntries'
3
+ import { filterDocsForVersion, toSidebarEntries } from './sidebarEntries'
4
4
 
5
5
  describe('toSidebarEntries', () => {
6
6
  it('should create a basic sidebar structure from flat docs', () => {
@@ -309,3 +309,156 @@ describe('toSidebarEntries', () => {
309
309
  expect(entries.page.collapsed).toBeUndefined()
310
310
  })
311
311
  })
312
+
313
+ describe('filterDocsForVersion', () => {
314
+ it('should filter docs to only include entries for the specified version', () => {
315
+ const docs: DocsData[] = [
316
+ { id: 'v1/intro.md', title: 'Intro v1', path: '/docs/v1/intro' },
317
+ { id: 'v1/guide.md', title: 'Guide v1', path: '/docs/v1/guide' },
318
+ { id: 'v2/intro.md', title: 'Intro v2', path: '/docs/v2/intro' },
319
+ { id: 'v2/guide.md', title: 'Guide v2', path: '/docs/v2/guide' },
320
+ {
321
+ id: 'v2/new-feature.md',
322
+ title: 'New Feature',
323
+ path: '/docs/v2/new-feature',
324
+ },
325
+ ]
326
+
327
+ const v1Docs = filterDocsForVersion(docs, 'v1')
328
+ expect(v1Docs).toHaveLength(2)
329
+ expect(v1Docs.map((d) => d.title)).toEqual(['Intro v1', 'Guide v1'])
330
+
331
+ const v2Docs = filterDocsForVersion(docs, 'v2')
332
+ expect(v2Docs).toHaveLength(3)
333
+ expect(v2Docs.map((d) => d.title)).toEqual([
334
+ 'Intro v2',
335
+ 'Guide v2',
336
+ 'New Feature',
337
+ ])
338
+ })
339
+
340
+ it('should strip version prefix from doc IDs', () => {
341
+ const docs: DocsData[] = [
342
+ {
343
+ id: 'v2/getting-started.md',
344
+ title: 'Getting Started',
345
+ path: '/docs/v2/getting-started',
346
+ },
347
+ {
348
+ id: 'v2/en/intro.md',
349
+ title: 'Introduction',
350
+ path: '/docs/v2/en/intro',
351
+ },
352
+ ]
353
+
354
+ const filtered = filterDocsForVersion(docs, 'v2')
355
+
356
+ expect(filtered[0].id).toBe('getting-started.md')
357
+ expect(filtered[1].id).toBe('en/intro.md')
358
+ })
359
+
360
+ it('should preserve other doc properties unchanged', () => {
361
+ const docs: DocsData[] = [
362
+ {
363
+ id: 'v1/page.md',
364
+ title: 'Page Title',
365
+ path: '/docs/v1/page',
366
+ sidebarPosition: 5,
367
+ sidebarLabel: 'Custom Label',
368
+ sidebarClassName: 'special-class',
369
+ pagination_next: 'next-page',
370
+ pagination_prev: 'prev-page',
371
+ link: true,
372
+ },
373
+ ]
374
+
375
+ const filtered = filterDocsForVersion(docs, 'v1')
376
+ expect(filtered[0]).toEqual({
377
+ id: 'page.md',
378
+ title: 'Page Title',
379
+ path: '/docs/v1/page',
380
+ sidebarPosition: 5,
381
+ sidebarLabel: 'Custom Label',
382
+ sidebarClassName: 'special-class',
383
+ pagination_next: 'next-page',
384
+ pagination_prev: 'prev-page',
385
+ link: true,
386
+ })
387
+ })
388
+
389
+ it('should return empty array when no docs match the version', () => {
390
+ const docs: DocsData[] = [
391
+ { id: 'v1/intro.md', title: 'Intro', path: '/docs/v1/intro' },
392
+ ]
393
+
394
+ const filtered = filterDocsForVersion(docs, 'v3')
395
+ expect(filtered).toEqual([])
396
+ })
397
+
398
+ it('should handle versioned docs with i18n locale prefixes', () => {
399
+ const docs: DocsData[] = [
400
+ {
401
+ id: 'v2/en/getting-started.md',
402
+ title: 'Getting Started (EN)',
403
+ path: '/en/docs/v2/getting-started',
404
+ },
405
+ {
406
+ id: 'v2/de/getting-started.md',
407
+ title: 'Erste Schritte (DE)',
408
+ path: '/de/docs/v2/getting-started',
409
+ },
410
+ {
411
+ id: 'v1/en/getting-started.md',
412
+ title: 'Getting Started v1',
413
+ path: '/en/docs/v1/getting-started',
414
+ },
415
+ ]
416
+
417
+ const filtered = filterDocsForVersion(docs, 'v2')
418
+ expect(filtered).toHaveLength(2)
419
+ // IDs should have version stripped but locale preserved
420
+ expect(filtered.map((d) => d.id)).toEqual([
421
+ 'en/getting-started.md',
422
+ 'de/getting-started.md',
423
+ ])
424
+ })
425
+
426
+ it('should work correctly with semantic version strings', () => {
427
+ const docs: DocsData[] = [
428
+ { id: 'v1.0/intro.md', title: 'Intro v1.0', path: '/docs/v1.0/intro' },
429
+ {
430
+ id: 'v2.0.0/intro.md',
431
+ title: 'Intro v2.0.0',
432
+ path: '/docs/v2.0.0/intro',
433
+ },
434
+ ]
435
+
436
+ const v1Docs = filterDocsForVersion(docs, 'v1.0')
437
+ expect(v1Docs).toHaveLength(1)
438
+ expect(v1Docs[0].title).toBe('Intro v1.0')
439
+
440
+ const v2Docs = filterDocsForVersion(docs, 'v2.0.0')
441
+ expect(v2Docs).toHaveLength(1)
442
+ expect(v2Docs[0].title).toBe('Intro v2.0.0')
443
+ })
444
+
445
+ it('should work with special version names like latest and next', () => {
446
+ const docs: DocsData[] = [
447
+ {
448
+ id: 'latest/intro.md',
449
+ title: 'Latest Intro',
450
+ path: '/docs/latest/intro',
451
+ },
452
+ { id: 'next/intro.md', title: 'Next Intro', path: '/docs/next/intro' },
453
+ { id: 'v1/intro.md', title: 'V1 Intro', path: '/docs/v1/intro' },
454
+ ]
455
+
456
+ const latestDocs = filterDocsForVersion(docs, 'latest')
457
+ expect(latestDocs).toHaveLength(1)
458
+ expect(latestDocs[0].title).toBe('Latest Intro')
459
+
460
+ const nextDocs = filterDocsForVersion(docs, 'next')
461
+ expect(nextDocs).toHaveLength(1)
462
+ expect(nextDocs[0].title).toBe('Next Intro')
463
+ })
464
+ })
@@ -1,4 +1,5 @@
1
1
  import type { Entry } from '@levino/shipyard-base'
2
+ import { getVersionFromDocId, stripVersionFromDocId } from './versionHelpers'
2
3
 
3
4
  export interface DocsData {
4
5
  id: string
@@ -19,6 +20,38 @@ export interface DocsData {
19
20
  paginationPrev?: string | null
20
21
  }
21
22
 
23
+ /**
24
+ * Filters docs data to only include entries for a specific version
25
+ * and strips the version prefix from doc IDs for proper sidebar tree building.
26
+ *
27
+ * @param docs - Array of DocsData entries
28
+ * @param version - The version to filter by
29
+ * @returns Filtered docs with version prefix stripped from IDs
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * // Input: [{ id: 'v2/getting-started', path: '/docs/v2/getting-started', ... }]
34
+ * // Output: [{ id: 'getting-started', path: '/docs/v2/getting-started', ... }]
35
+ * const sidebarDocs = filterDocsForVersion(allDocs, 'v2')
36
+ * ```
37
+ */
38
+ export const filterDocsForVersion = (
39
+ docs: readonly DocsData[],
40
+ version: string,
41
+ ): DocsData[] => {
42
+ return docs
43
+ .filter((doc) => {
44
+ const docVersion = getVersionFromDocId(doc.id)
45
+ return docVersion === version
46
+ })
47
+ .map((doc) => ({
48
+ ...doc,
49
+ // Strip version from ID so tree builds correctly
50
+ // e.g., "v2/en/getting-started" -> "en/getting-started"
51
+ id: stripVersionFromDocId(doc.id),
52
+ }))
53
+ }
54
+
22
55
  interface TreeNode {
23
56
  readonly key: string
24
57
  readonly label: string
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Helper functions for parsing and manipulating version strings in doc IDs.
3
+ * These are low-level utilities used by other modules.
4
+ */
5
+
6
+ /**
7
+ * Checks if a string looks like a version identifier.
8
+ * Matches: v1.0, v2.0.0, latest, next, main, etc.
9
+ */
10
+ export const isVersionLikeString = (str: string): boolean => {
11
+ // Match common version patterns
12
+ return /^(v?\d+(\.\d+)*|latest|next|main|master|canary|beta|alpha|rc\d*|stable)$/i.test(
13
+ str,
14
+ )
15
+ }
16
+
17
+ /**
18
+ * Extracts the version from a versioned document ID.
19
+ * The version is expected to be the first path segment.
20
+ *
21
+ * @param docId - The document ID (e.g., "v1.0/en/getting-started")
22
+ * @returns The version string or undefined if not found
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * getVersionFromDocId("v1.0/en/getting-started") // "v1.0"
27
+ * getVersionFromDocId("latest/en/index") // "latest"
28
+ * getVersionFromDocId("en/getting-started") // undefined (non-versioned)
29
+ * ```
30
+ */
31
+ export const getVersionFromDocId = (docId: string): string | undefined => {
32
+ const parts = docId.split('/')
33
+ // For versioned docs, first part is the version
34
+ // We check if it looks like a version (starts with 'v' and has numbers, or is 'latest', 'next', etc.)
35
+ if (parts.length > 0) {
36
+ const potentialVersion = parts[0]
37
+ if (isVersionLikeString(potentialVersion)) {
38
+ return potentialVersion
39
+ }
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /**
45
+ * Strips the version prefix from a versioned document ID.
46
+ * Returns the ID without the version for use in routing.
47
+ *
48
+ * @param docId - The document ID (e.g., "v1.0/en/getting-started")
49
+ * @returns The ID without version prefix (e.g., "en/getting-started")
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * stripVersionFromDocId("v1.0/en/getting-started") // "en/getting-started"
54
+ * stripVersionFromDocId("latest/en/index") // "en/index"
55
+ * stripVersionFromDocId("en/getting-started") // "en/getting-started" (unchanged)
56
+ * ```
57
+ */
58
+ export const stripVersionFromDocId = (docId: string): string => {
59
+ const version = getVersionFromDocId(docId)
60
+ if (version) {
61
+ return docId.slice(version.length + 1) // +1 for the slash
62
+ }
63
+ return docId
64
+ }