@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.
@@ -0,0 +1,319 @@
1
+ import type { Element, Root } from 'hast'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { rehypeVersionLinks } from './rehypeVersionLinks'
4
+
5
+ // Helper to create a simple HAST tree with an anchor element
6
+ const createTree = (href: string): Root => ({
7
+ type: 'root',
8
+ children: [
9
+ {
10
+ type: 'element',
11
+ tagName: 'a',
12
+ properties: { href },
13
+ children: [{ type: 'text', value: 'Link' }],
14
+ },
15
+ ],
16
+ })
17
+
18
+ // Helper to get the href from the tree after transformation
19
+ const getHref = (tree: Root): string | undefined => {
20
+ const anchor = tree.children[0] as Element
21
+ return anchor.properties?.href as string | undefined
22
+ }
23
+
24
+ describe('rehypeVersionLinks', () => {
25
+ const defaultOptions = {
26
+ routeBasePath: 'docs',
27
+ currentVersion: 'v2',
28
+ availableVersions: ['v1', 'v2'],
29
+ }
30
+
31
+ describe('external links', () => {
32
+ it('should not modify http links', () => {
33
+ const tree = createTree('http://example.com')
34
+ rehypeVersionLinks(defaultOptions)(tree)
35
+ expect(getHref(tree)).toBe('http://example.com')
36
+ })
37
+
38
+ it('should not modify https links', () => {
39
+ const tree = createTree('https://example.com/page')
40
+ rehypeVersionLinks(defaultOptions)(tree)
41
+ expect(getHref(tree)).toBe('https://example.com/page')
42
+ })
43
+
44
+ it('should not modify mailto links', () => {
45
+ const tree = createTree('mailto:test@example.com')
46
+ rehypeVersionLinks(defaultOptions)(tree)
47
+ expect(getHref(tree)).toBe('mailto:test@example.com')
48
+ })
49
+
50
+ it('should not modify tel links', () => {
51
+ const tree = createTree('tel:+1234567890')
52
+ rehypeVersionLinks(defaultOptions)(tree)
53
+ expect(getHref(tree)).toBe('tel:+1234567890')
54
+ })
55
+
56
+ it('should not modify anchor links', () => {
57
+ const tree = createTree('#section-id')
58
+ rehypeVersionLinks(defaultOptions)(tree)
59
+ expect(getHref(tree)).toBe('#section-id')
60
+ })
61
+ })
62
+
63
+ describe('cross-version links', () => {
64
+ it('should transform @version:/path syntax to versioned URL', () => {
65
+ const tree = createTree('@v1:/installation')
66
+ rehypeVersionLinks(defaultOptions)(tree)
67
+ expect(getHref(tree)).toBe('/docs/v1/installation')
68
+ })
69
+
70
+ it('should handle cross-version links with leading slash', () => {
71
+ const tree = createTree('@v1:/guide/intro')
72
+ rehypeVersionLinks(defaultOptions)(tree)
73
+ expect(getHref(tree)).toBe('/docs/v1/guide/intro')
74
+ })
75
+
76
+ it('should handle cross-version links without leading slash', () => {
77
+ const tree = createTree('@v1:guide/intro')
78
+ rehypeVersionLinks(defaultOptions)(tree)
79
+ expect(getHref(tree)).toBe('/docs/v1/guide/intro')
80
+ })
81
+
82
+ it('should handle @latest cross-version links', () => {
83
+ const tree = createTree('@latest:/getting-started')
84
+ rehypeVersionLinks(defaultOptions)(tree)
85
+ expect(getHref(tree)).toBe('/docs/latest/getting-started')
86
+ })
87
+
88
+ it('should handle nested paths in cross-version links', () => {
89
+ const tree = createTree('@v2:/api/methods/create')
90
+ rehypeVersionLinks(defaultOptions)(tree)
91
+ expect(getHref(tree)).toBe('/docs/v2/api/methods/create')
92
+ })
93
+
94
+ it('should handle version with dots', () => {
95
+ const options = {
96
+ ...defaultOptions,
97
+ availableVersions: ['1.0.0', '2.0.0'],
98
+ }
99
+ const tree = createTree('@1.0.0:/page')
100
+ rehypeVersionLinks(options)(tree)
101
+ expect(getHref(tree)).toBe('/docs/1.0.0/page')
102
+ })
103
+ })
104
+
105
+ describe('auto-versioning absolute docs paths', () => {
106
+ it('should add current version to unversioned absolute docs path', () => {
107
+ const tree = createTree('/docs/installation')
108
+ rehypeVersionLinks(defaultOptions)(tree)
109
+ expect(getHref(tree)).toBe('/docs/v2/installation')
110
+ })
111
+
112
+ it('should add current version to nested unversioned path', () => {
113
+ const tree = createTree('/docs/guide/advanced/topic')
114
+ rehypeVersionLinks(defaultOptions)(tree)
115
+ expect(getHref(tree)).toBe('/docs/v2/guide/advanced/topic')
116
+ })
117
+
118
+ it('should not modify already versioned absolute path', () => {
119
+ const tree = createTree('/docs/v1/installation')
120
+ rehypeVersionLinks(defaultOptions)(tree)
121
+ expect(getHref(tree)).toBe('/docs/v1/installation')
122
+ })
123
+
124
+ it('should not modify path with current version', () => {
125
+ const tree = createTree('/docs/v2/installation')
126
+ rehypeVersionLinks(defaultOptions)(tree)
127
+ expect(getHref(tree)).toBe('/docs/v2/installation')
128
+ })
129
+
130
+ it('should not modify path with latest alias', () => {
131
+ const tree = createTree('/docs/latest/installation')
132
+ rehypeVersionLinks(defaultOptions)(tree)
133
+ expect(getHref(tree)).toBe('/docs/latest/installation')
134
+ })
135
+ })
136
+
137
+ describe('relative links', () => {
138
+ it('should not modify relative links starting with ./', () => {
139
+ const tree = createTree('./other-page')
140
+ rehypeVersionLinks(defaultOptions)(tree)
141
+ expect(getHref(tree)).toBe('./other-page')
142
+ })
143
+
144
+ it('should not modify relative links starting with ../', () => {
145
+ const tree = createTree('../parent/page')
146
+ rehypeVersionLinks(defaultOptions)(tree)
147
+ expect(getHref(tree)).toBe('../parent/page')
148
+ })
149
+
150
+ it('should not modify relative links without prefix', () => {
151
+ const tree = createTree('sibling-page')
152
+ rehypeVersionLinks(defaultOptions)(tree)
153
+ expect(getHref(tree)).toBe('sibling-page')
154
+ })
155
+ })
156
+
157
+ describe('non-docs absolute paths', () => {
158
+ it('should not modify absolute paths outside docs base', () => {
159
+ const tree = createTree('/about')
160
+ rehypeVersionLinks(defaultOptions)(tree)
161
+ expect(getHref(tree)).toBe('/about')
162
+ })
163
+
164
+ it('should not modify absolute paths to other sections', () => {
165
+ const tree = createTree('/blog/post')
166
+ rehypeVersionLinks(defaultOptions)(tree)
167
+ expect(getHref(tree)).toBe('/blog/post')
168
+ })
169
+ })
170
+
171
+ describe('custom routeBasePath', () => {
172
+ it('should handle custom routeBasePath', () => {
173
+ const options = {
174
+ routeBasePath: 'guides',
175
+ currentVersion: 'v2',
176
+ availableVersions: ['v1', 'v2'],
177
+ }
178
+ const tree = createTree('/guides/installation')
179
+ rehypeVersionLinks(options)(tree)
180
+ expect(getHref(tree)).toBe('/guides/v2/installation')
181
+ })
182
+
183
+ it('should handle cross-version links with custom routeBasePath', () => {
184
+ const options = {
185
+ routeBasePath: 'api',
186
+ currentVersion: 'v3',
187
+ availableVersions: ['v2', 'v3'],
188
+ }
189
+ const tree = createTree('@v2:/endpoint')
190
+ rehypeVersionLinks(options)(tree)
191
+ expect(getHref(tree)).toBe('/api/v2/endpoint')
192
+ })
193
+ })
194
+
195
+ describe('edge cases', () => {
196
+ it('should handle empty href', () => {
197
+ const tree: Root = {
198
+ type: 'root',
199
+ children: [
200
+ {
201
+ type: 'element',
202
+ tagName: 'a',
203
+ properties: { href: '' },
204
+ children: [],
205
+ },
206
+ ],
207
+ }
208
+ rehypeVersionLinks(defaultOptions)(tree)
209
+ expect(getHref(tree)).toBe('')
210
+ })
211
+
212
+ it('should handle anchor without href', () => {
213
+ const tree: Root = {
214
+ type: 'root',
215
+ children: [
216
+ {
217
+ type: 'element',
218
+ tagName: 'a',
219
+ properties: {},
220
+ children: [],
221
+ },
222
+ ],
223
+ }
224
+ rehypeVersionLinks(defaultOptions)(tree)
225
+ expect(getHref(tree)).toBeUndefined()
226
+ })
227
+
228
+ it('should handle non-anchor elements', () => {
229
+ const tree: Root = {
230
+ type: 'root',
231
+ children: [
232
+ {
233
+ type: 'element',
234
+ tagName: 'div',
235
+ properties: { href: '/docs/page' },
236
+ children: [],
237
+ },
238
+ ],
239
+ }
240
+ rehypeVersionLinks(defaultOptions)(tree)
241
+ // Should not modify non-anchor elements
242
+ expect((tree.children[0] as Element).properties?.href).toBe('/docs/page')
243
+ })
244
+
245
+ it('should handle docs path that matches version name', () => {
246
+ const options = {
247
+ routeBasePath: 'docs',
248
+ currentVersion: 'v2',
249
+ availableVersions: ['v1', 'v2', 'beta'],
250
+ }
251
+ // Path starting with 'beta' which is a version
252
+ const tree = createTree('/docs/beta/page')
253
+ rehypeVersionLinks(options)(tree)
254
+ expect(getHref(tree)).toBe('/docs/beta/page')
255
+ })
256
+
257
+ it('should handle index path', () => {
258
+ const tree = createTree('/docs/index')
259
+ rehypeVersionLinks(defaultOptions)(tree)
260
+ expect(getHref(tree)).toBe('/docs/v2/index')
261
+ })
262
+
263
+ it('should handle root docs path', () => {
264
+ const tree = createTree('/docs/')
265
+ rehypeVersionLinks(defaultOptions)(tree)
266
+ expect(getHref(tree)).toBe('/docs/v2/')
267
+ })
268
+ })
269
+
270
+ describe('multiple links in tree', () => {
271
+ it('should transform all links in the tree', () => {
272
+ const tree: Root = {
273
+ type: 'root',
274
+ children: [
275
+ {
276
+ type: 'element',
277
+ tagName: 'a',
278
+ properties: { href: '/docs/page1' },
279
+ children: [],
280
+ },
281
+ {
282
+ type: 'element',
283
+ tagName: 'p',
284
+ properties: {},
285
+ children: [
286
+ {
287
+ type: 'element',
288
+ tagName: 'a',
289
+ properties: { href: '@v1:/page2' },
290
+ children: [],
291
+ },
292
+ ],
293
+ },
294
+ {
295
+ type: 'element',
296
+ tagName: 'a',
297
+ properties: { href: 'https://external.com' },
298
+ children: [],
299
+ },
300
+ ],
301
+ }
302
+
303
+ rehypeVersionLinks(defaultOptions)(tree)
304
+
305
+ // First link auto-versioned
306
+ expect((tree.children[0] as Element).properties?.href).toBe(
307
+ '/docs/v2/page1',
308
+ )
309
+ // Nested link cross-version transformed
310
+ expect(
311
+ ((tree.children[1] as Element).children[0] as Element).properties?.href,
312
+ ).toBe('/docs/v1/page2')
313
+ // External link unchanged
314
+ expect((tree.children[2] as Element).properties?.href).toBe(
315
+ 'https://external.com',
316
+ )
317
+ })
318
+ })
319
+ })
@@ -0,0 +1,156 @@
1
+ import type { Root } from 'hast'
2
+ import { visit } from 'unist-util-visit'
3
+
4
+ /**
5
+ * Options for the rehype version links plugin.
6
+ */
7
+ export interface RehypeVersionLinksOptions {
8
+ /**
9
+ * The base path for docs routes (e.g., 'docs', 'guides').
10
+ */
11
+ routeBasePath: string
12
+ /**
13
+ * The current version being rendered.
14
+ * Links without explicit version will resolve to this version.
15
+ */
16
+ currentVersion: string
17
+ /**
18
+ * Available versions for validation.
19
+ * Cross-version links to non-existent versions will log warnings.
20
+ */
21
+ availableVersions?: string[]
22
+ /**
23
+ * Enable verbose logging for link transformations.
24
+ * @default false
25
+ */
26
+ debug?: boolean
27
+ }
28
+
29
+ /**
30
+ * Cross-version link syntax: @version:/path
31
+ * Examples:
32
+ * - @v1:/installation → /docs/v1/installation
33
+ * - @v2:/guide/intro → /docs/v2/guide/intro
34
+ * - @latest:/getting-started → /docs/latest/getting-started
35
+ */
36
+ const CROSS_VERSION_LINK_REGEX = /^@([^:]+):(.+)$/
37
+
38
+ /**
39
+ * Rehype plugin to handle version-aware link resolution in documentation.
40
+ *
41
+ * Features:
42
+ * 1. Relative links (./page, ../other) resolve within the same version
43
+ * 2. Cross-version links (@v1:/path) allow explicit version targeting
44
+ * 3. Absolute docs links (/docs/page) automatically get versioned
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * // In astro.config.mjs
49
+ * export default defineConfig({
50
+ * markdown: {
51
+ * rehypePlugins: [
52
+ * [rehypeVersionLinks, {
53
+ * routeBasePath: 'docs',
54
+ * currentVersion: 'v2',
55
+ * availableVersions: ['v1', 'v2', 'latest'],
56
+ * }]
57
+ * ]
58
+ * }
59
+ * })
60
+ * ```
61
+ */
62
+ export function rehypeVersionLinks(options: RehypeVersionLinksOptions) {
63
+ const {
64
+ routeBasePath,
65
+ currentVersion,
66
+ availableVersions = [],
67
+ debug,
68
+ } = options
69
+
70
+ return (tree: Root) => {
71
+ visit(tree, 'element', (node) => {
72
+ // Only process anchor elements
73
+ if (node.tagName !== 'a') return
74
+
75
+ const href = node.properties?.href
76
+ if (typeof href !== 'string') return
77
+
78
+ // Skip external links, anchors, and protocol links
79
+ if (
80
+ href.startsWith('http://') ||
81
+ href.startsWith('https://') ||
82
+ href.startsWith('mailto:') ||
83
+ href.startsWith('#') ||
84
+ href.startsWith('tel:')
85
+ ) {
86
+ return
87
+ }
88
+
89
+ let newHref = href
90
+
91
+ // Check for cross-version link syntax: @version:/path
92
+ const crossVersionMatch = href.match(CROSS_VERSION_LINK_REGEX)
93
+ if (crossVersionMatch) {
94
+ const [, targetVersion, targetPath] = crossVersionMatch
95
+ const cleanPath = targetPath.startsWith('/')
96
+ ? targetPath.slice(1)
97
+ : targetPath
98
+
99
+ // Validate version exists
100
+ if (
101
+ availableVersions.length > 0 &&
102
+ !availableVersions.includes(targetVersion) &&
103
+ targetVersion !== 'latest'
104
+ ) {
105
+ // biome-ignore lint/suspicious/noConsole: Intentional warning for invalid cross-version links
106
+ console.warn(
107
+ `[rehypeVersionLinks] Unknown version "${targetVersion}" in link: ${href}`,
108
+ )
109
+ }
110
+
111
+ newHref = `/${routeBasePath}/${targetVersion}/${cleanPath}`
112
+
113
+ if (debug) {
114
+ // biome-ignore lint/suspicious/noConsole: Debug logging when enabled
115
+ console.log(
116
+ `[rehypeVersionLinks] Cross-version link: ${href} → ${newHref}`,
117
+ )
118
+ }
119
+ }
120
+ // Check if it's an absolute path starting with the docs base path
121
+ // e.g., /docs/installation or /docs/guide/intro
122
+ else if (href.startsWith(`/${routeBasePath}/`)) {
123
+ const pathAfterBase = href.slice(`/${routeBasePath}/`.length)
124
+
125
+ // Check if it already has a version
126
+ const firstSegment = pathAfterBase.split('/')[0]
127
+ const hasVersion =
128
+ availableVersions.includes(firstSegment) ||
129
+ firstSegment === 'latest' ||
130
+ firstSegment === currentVersion
131
+
132
+ if (!hasVersion) {
133
+ // Add the current version to the path
134
+ newHref = `/${routeBasePath}/${currentVersion}/${pathAfterBase}`
135
+
136
+ if (debug) {
137
+ // biome-ignore lint/suspicious/noConsole: Debug logging when enabled
138
+ console.log(
139
+ `[rehypeVersionLinks] Auto-versioned link: ${href} → ${newHref}`,
140
+ )
141
+ }
142
+ }
143
+ }
144
+ // Relative paths are handled by the browser/Astro based on current URL
145
+ // We don't need to transform them since they'll naturally stay in the same version
146
+
147
+ // Update the href if changed
148
+ if (newHref !== href) {
149
+ node.properties = node.properties ?? {}
150
+ node.properties.href = newHref
151
+ }
152
+ })
153
+ }
154
+ }
155
+
156
+ export default rehypeVersionLinks