starlight-versions 0.9.1 → 0.10.1
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/CHANGELOG.md +18 -0
- package/components/VersionSearch.astro +7 -2
- package/components/VersionSelect.astro +27 -23
- package/libs/config.ts +18 -4
- package/libs/fs.ts +16 -8
- package/libs/markdown.ts +55 -16
- package/libs/path.ts +5 -1
- package/libs/starlight.ts +69 -7
- package/libs/versions.ts +248 -37
- package/middleware.ts +99 -12
- package/package.json +1 -1
- package/schema.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# starlight-versions
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#80](https://github.com/HiDeoo/starlight-versions/pull/80) [`dd385c8`](https://github.com/HiDeoo/starlight-versions/commit/dd385c88940cc36e5733d0f082a04907f44bb2f0) Thanks [@kozmagabor](https://github.com/kozmagabor)! - Fixes versioning of assets stored alongside documentation pages.
|
|
8
|
+
|
|
9
|
+
## 0.10.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#76](https://github.com/HiDeoo/starlight-versions/pull/76) [`a4134e7`](https://github.com/HiDeoo/starlight-versions/commit/a4134e7ac0df8778367f4ada77c3b5ee8976cc06) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds support for excluding pages from versioning.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [#79](https://github.com/HiDeoo/starlight-versions/pull/79) [`4329441`](https://github.com/HiDeoo/starlight-versions/commit/4329441a853506584a2e9c43429b83a14a5152a6) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Fixes pagination links when using Astro's `base` configuration option with multiple segments.
|
|
18
|
+
|
|
19
|
+
- [#79](https://github.com/HiDeoo/starlight-versions/pull/79) [`4329441`](https://github.com/HiDeoo/starlight-versions/commit/4329441a853506584a2e9c43429b83a14a5152a6) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Fixes invalid versioned links for paths similar to Astro's `base` configuration option.
|
|
20
|
+
|
|
3
21
|
## 0.9.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
|
@@ -10,7 +10,7 @@ import project from 'virtual:starlight/project-context'
|
|
|
10
10
|
import starlightConfig from 'virtual:starlight/user-config'
|
|
11
11
|
import starlightVersionsConfig from 'virtual:starlight-versions-config'
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { getPagefindVersionIdentifiers, getVersionFromSlug, getVersionURL } from '../libs/versions'
|
|
14
14
|
|
|
15
15
|
const { entry } = Astro.locals.starlightRoute
|
|
16
16
|
|
|
@@ -24,7 +24,11 @@ const pagefindTranslations = {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const pageVersion = getVersionFromSlug(starlightVersionsConfig, starlightConfig, entry.id)
|
|
27
|
-
const pageVersionIdentifier =
|
|
27
|
+
const [pageVersionIdentifier, ...otherPageVersionsIdentifiers] = getPagefindVersionIdentifiers(
|
|
28
|
+
starlightVersionsConfig,
|
|
29
|
+
pageVersion,
|
|
30
|
+
entry.id,
|
|
31
|
+
)
|
|
28
32
|
|
|
29
33
|
const dataAttributes: DOMStringMap = {
|
|
30
34
|
'data-pagefind-filter': `version:${pageVersionIdentifier}`,
|
|
@@ -35,6 +39,7 @@ if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash
|
|
|
35
39
|
---
|
|
36
40
|
|
|
37
41
|
<site-search class={Astro.props['class']} {...dataAttributes}>
|
|
42
|
+
{otherPageVersionsIdentifiers.map((version) => <span hidden data-pagefind-filter={`version:${version}`} />)}
|
|
38
43
|
<button data-open-modal disabled aria-label={Astro.locals.t('search.label')} aria-keyshortcuts="Control+K">
|
|
39
44
|
<Icon name="magnifier" />
|
|
40
45
|
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
|
|
@@ -3,37 +3,41 @@ import { Icon } from '@astrojs/starlight/components'
|
|
|
3
3
|
import starlightConfig from 'virtual:starlight/user-config'
|
|
4
4
|
import starlightVersionsConfig from 'virtual:starlight-versions-config'
|
|
5
5
|
|
|
6
|
-
import { getVersionFromSlug, getVersionURL } from '../libs/versions'
|
|
6
|
+
import { getVersionFromSlug, getVersionURL, isExcludedDocPath } from '../libs/versions'
|
|
7
7
|
|
|
8
|
-
const { id } = Astro.locals.starlightRoute
|
|
8
|
+
const { entry, id } = Astro.locals.starlightRoute
|
|
9
9
|
|
|
10
10
|
const pageVersion = getVersionFromSlug(starlightVersionsConfig, starlightConfig, id)
|
|
11
|
+
const isExcluded = pageVersion === undefined && isExcludedDocPath(starlightVersionsConfig, entry.filePath)
|
|
11
12
|
---
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, undefined).pathname}
|
|
21
|
-
>{starlightVersionsConfig.current.label}</option
|
|
22
|
-
>
|
|
23
|
-
{
|
|
24
|
-
starlightVersionsConfig.versions.map((version) => (
|
|
14
|
+
{
|
|
15
|
+
!isExcluded && (
|
|
16
|
+
<starlight-version-select>
|
|
17
|
+
<label>
|
|
18
|
+
<span class="sr-only">{Astro.locals.t('starlightVersions.select.accessibleLabel')}</span>
|
|
19
|
+
<Icon name="seti:clock" class="icon label-icon" />
|
|
20
|
+
<select value={pageVersion?.label ?? 'current'} autocomplete="off">
|
|
25
21
|
<option
|
|
26
|
-
selected={
|
|
27
|
-
value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url,
|
|
22
|
+
selected={pageVersion === undefined}
|
|
23
|
+
value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, undefined).pathname}
|
|
28
24
|
>
|
|
29
|
-
{
|
|
25
|
+
{starlightVersionsConfig.current.label}
|
|
30
26
|
</option>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
{starlightVersionsConfig.versions.map((version) => (
|
|
28
|
+
<option
|
|
29
|
+
selected={version.slug === pageVersion?.slug}
|
|
30
|
+
value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, version).pathname}
|
|
31
|
+
>
|
|
32
|
+
{version.label ?? version.slug}
|
|
33
|
+
</option>
|
|
34
|
+
))}
|
|
35
|
+
</select>
|
|
36
|
+
<Icon name="down-caret" class="icon caret-icon" />
|
|
37
|
+
</label>
|
|
38
|
+
</starlight-version-select>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
37
41
|
|
|
38
42
|
<style>
|
|
39
43
|
label {
|
package/libs/config.ts
CHANGED
|
@@ -18,6 +18,14 @@ export const StarlightVersionsConfigSchema = z
|
|
|
18
18
|
label: z.string().default('Latest'),
|
|
19
19
|
})
|
|
20
20
|
.prefault({}),
|
|
21
|
+
/**
|
|
22
|
+
* A list of source file glob patterns that should be excluded from versioning.
|
|
23
|
+
*
|
|
24
|
+
* Excluded pages always use the latest content across all versions.
|
|
25
|
+
*
|
|
26
|
+
* @default []
|
|
27
|
+
*/
|
|
28
|
+
exclude: z.array(z.string()).default([]),
|
|
21
29
|
/**
|
|
22
30
|
* A list of all archived versions of the documentation.
|
|
23
31
|
*/
|
|
@@ -25,10 +33,16 @@ export const StarlightVersionsConfigSchema = z
|
|
|
25
33
|
message: 'At least one version of the documentation must be defined.',
|
|
26
34
|
}),
|
|
27
35
|
})
|
|
28
|
-
.transform((value) =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
.transform((value) => {
|
|
37
|
+
// This gets populated later when generating versioned sidebars.
|
|
38
|
+
const excludedSlugsByVersion: Record<string, string[]> = {}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
...value,
|
|
42
|
+
excludedSlugsByVersion,
|
|
43
|
+
versionsBySlug: Object.fromEntries(value.versions.map((version) => [version.slug, version])),
|
|
44
|
+
}
|
|
45
|
+
})
|
|
32
46
|
|
|
33
47
|
export type StarlightVersionsUserConfig = z.input<typeof StarlightVersionsConfigSchema>
|
|
34
48
|
export type StarlightVersionsConfig = z.output<typeof StarlightVersionsConfigSchema>
|
package/libs/fs.ts
CHANGED
|
@@ -32,13 +32,17 @@ export async function copyDirectory(sourceDir: URL, destDir: URL, callback: Copy
|
|
|
32
32
|
await copyDirectory(source, dest, callback, false)
|
|
33
33
|
} else if (entry.isFile()) {
|
|
34
34
|
const source = new URL(entry.name, sourceDir)
|
|
35
|
-
const content = await fs.readFile(source, 'utf8')
|
|
36
35
|
|
|
37
|
-
const updatedContent = await callback({ type: 'file',
|
|
36
|
+
const updatedContent = await callback({ type: 'file', url: source })
|
|
37
|
+
if (updatedContent === true) continue
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const dest = new URL(entry.name, destDir)
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
if (updatedContent === undefined) {
|
|
42
|
+
await copyFile(source, dest)
|
|
43
|
+
} else if (typeof updatedContent === 'string') {
|
|
44
|
+
await fs.writeFile(dest, updatedContent)
|
|
45
|
+
}
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
}
|
|
@@ -72,8 +76,12 @@ export async function isDirectoryEntry(entry: Dirent) {
|
|
|
72
76
|
return stats.isDirectory()
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Defines how entries are handled when copying a directory with `copyDirectory()`.
|
|
81
|
+
*
|
|
82
|
+
* - For files, return `undefined` to copy as-is, a string to write transformed content, or `true` to skip.
|
|
83
|
+
* - For directories, return `undefined` to use the default destination, a `URL` to override it, or `true` to skip.
|
|
84
|
+
*/
|
|
75
85
|
export type CopyDirectoryCallback = (
|
|
76
|
-
entry:
|
|
77
|
-
|
|
78
|
-
| { type: 'directory'; name: string; isRoot: boolean; source: URL; dest: URL },
|
|
79
|
-
) => Promise<string | boolean | URL>
|
|
86
|
+
entry: { type: 'file'; url: URL } | { type: 'directory'; name: string; isRoot: boolean; source: URL; dest: URL },
|
|
87
|
+
) => Promise<string | true | URL | undefined>
|
package/libs/markdown.ts
CHANGED
|
@@ -10,7 +10,13 @@ import remarkMdx from 'remark-mdx'
|
|
|
10
10
|
import { CONTINUE, SKIP, visit } from 'unist-util-visit'
|
|
11
11
|
import type { VFile } from 'vfile'
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ensureTrailingSlash,
|
|
15
|
+
isAbsoluteLink,
|
|
16
|
+
stripLeadingAndTrailingSlashes,
|
|
17
|
+
stripLeadingSlash,
|
|
18
|
+
stripTrailingSlash,
|
|
19
|
+
} from './path'
|
|
14
20
|
import { getFrontmatterNodeValue, parseFrontmatter } from './starlight'
|
|
15
21
|
import type { Version, VersionAsset } from './versions'
|
|
16
22
|
|
|
@@ -51,6 +57,10 @@ export function remarkStarlightVersions() {
|
|
|
51
57
|
return handleImports(node, file)
|
|
52
58
|
}
|
|
53
59
|
case 'mdxJsxFlowElement': {
|
|
60
|
+
if (node.name === 'a') {
|
|
61
|
+
return handleLinkElements(node, file)
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
if (node.name && mediaElements.has(node.name)) {
|
|
55
65
|
return handleMediaElements(node, file)
|
|
56
66
|
}
|
|
@@ -99,19 +109,17 @@ function handleFrontmatter(tree: Root, file: VFile) {
|
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
if (typeof frontmatter.prev === 'object' && frontmatter.prev.link
|
|
103
|
-
frontmatter.prev.link =
|
|
112
|
+
if (typeof frontmatter.prev === 'object' && frontmatter.prev.link) {
|
|
113
|
+
frontmatter.prev.link = transformLink(frontmatter.prev.link, file)
|
|
104
114
|
}
|
|
105
115
|
|
|
106
|
-
if (typeof frontmatter.next === 'object' && frontmatter.next.link
|
|
107
|
-
frontmatter.next.link =
|
|
116
|
+
if (typeof frontmatter.next === 'object' && frontmatter.next.link) {
|
|
117
|
+
frontmatter.next.link = transformLink(frontmatter.next.link, file)
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
if (frontmatter.hero?.actions) {
|
|
111
121
|
for (const action of frontmatter.hero.actions) {
|
|
112
|
-
|
|
113
|
-
action.link = addVersionToLink(action.link, file)
|
|
114
|
-
}
|
|
122
|
+
action.link = transformLink(action.link, file)
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
|
|
@@ -134,23 +142,50 @@ function handleFrontmatter(tree: Root, file: VFile) {
|
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
function handleLinks(node: Link, file: VFile) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
node.url = addVersionToLink(node.url, file)
|
|
145
|
+
node.url = transformLink(node.url, file)
|
|
140
146
|
|
|
141
147
|
return SKIP
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
function handleLinkElements(node: MdxJsxTextElement, file: VFile) {
|
|
150
|
+
function handleLinkElements(node: MdxJsxFlowElement | MdxJsxTextElement, file: VFile) {
|
|
145
151
|
const href = node.attributes.find((attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'href')
|
|
146
152
|
|
|
147
|
-
if (!href || typeof href.value !== 'string'
|
|
153
|
+
if (!href || typeof href.value !== 'string') return CONTINUE
|
|
148
154
|
|
|
149
|
-
href.value =
|
|
155
|
+
href.value = transformLink(href.value, file)
|
|
150
156
|
|
|
151
157
|
return CONTINUE
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
function transformLink(link: string, file: VFile) {
|
|
161
|
+
if (isAbsoluteLink(link)) return link
|
|
162
|
+
|
|
163
|
+
const excludedLink = getExcludedLink(link, file)
|
|
164
|
+
if (excludedLink) return excludedLink
|
|
165
|
+
|
|
166
|
+
return isPublicAsset(link) ? addVersionToLink(link, file) : link
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getExcludedLink(link: string, file: VFile) {
|
|
170
|
+
const { excludedSlugs, slug } = file.data
|
|
171
|
+
|
|
172
|
+
if (!excludedSlugs || !slug || link.startsWith('#') || link.startsWith('?')) return undefined
|
|
173
|
+
|
|
174
|
+
const isAbsolute = link.startsWith('/')
|
|
175
|
+
const url = new URL(link, `https://example.com/${stripLeadingAndTrailingSlashes(slug)}/`)
|
|
176
|
+
let pathname = url.pathname
|
|
177
|
+
const base = file.data.base ?? ''
|
|
178
|
+
|
|
179
|
+
if (base && (pathname === base || pathname.startsWith(ensureTrailingSlash(base)))) {
|
|
180
|
+
pathname = pathname.slice(base.length)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!excludedSlugs.includes(stripLeadingAndTrailingSlashes(pathname))) return undefined
|
|
184
|
+
if (isAbsolute) return link
|
|
185
|
+
|
|
186
|
+
return `${base}${pathname}${url.search}${url.hash}`
|
|
187
|
+
}
|
|
188
|
+
|
|
154
189
|
function handleImages(node: Image, file: VFile) {
|
|
155
190
|
if (isAbsoluteLink(node.url)) return SKIP
|
|
156
191
|
|
|
@@ -189,10 +224,10 @@ function addVersionToLink(link: string, file: VFile) {
|
|
|
189
224
|
assert.ok(file.data.version, 'A version must be provided to add a version to an Astro asset.')
|
|
190
225
|
|
|
191
226
|
const base = file.data.base ?? ''
|
|
192
|
-
const hasBase =
|
|
227
|
+
const hasBase = base && (link === base || link.startsWith(ensureTrailingSlash(base)))
|
|
193
228
|
|
|
194
229
|
if (hasBase) {
|
|
195
|
-
link = link.
|
|
230
|
+
link = link.slice(base.length)
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
const segments = link.split('/')
|
|
@@ -211,8 +246,10 @@ function addVersionToLink(link: string, file: VFile) {
|
|
|
211
246
|
|
|
212
247
|
function addVersionToAstroAsset(asset: string, file: VFile) {
|
|
213
248
|
assert.ok(file.data.version, 'A version must be provided to add a version to an Astro asset.')
|
|
249
|
+
assert.ok(file.data.docsDir, 'A docs directory must be provided to add a version to an Astro asset.')
|
|
214
250
|
|
|
215
251
|
const source = new URL(asset, file.data.url)
|
|
252
|
+
if (source.href.startsWith(file.data.docsDir.href)) return asset
|
|
216
253
|
|
|
217
254
|
const segments = asset.split('/')
|
|
218
255
|
segments.splice(-1, 0, file.data.version.slug)
|
|
@@ -252,6 +289,8 @@ function isPublicAsset(asset: string) {
|
|
|
252
289
|
export interface TransformContext {
|
|
253
290
|
assets: VersionAsset[]
|
|
254
291
|
base: string
|
|
292
|
+
docsDir: URL
|
|
293
|
+
excludedSlugs: string[]
|
|
255
294
|
locale: string | undefined
|
|
256
295
|
publicDir: URL
|
|
257
296
|
slug: string
|
package/libs/path.ts
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path'
|
|
|
2
2
|
|
|
3
3
|
import { slug } from 'github-slugger'
|
|
4
4
|
|
|
5
|
-
const absoluteLinkRegex = /^
|
|
5
|
+
const absoluteLinkRegex = /^[a-z][a-z\d+.-]*:/i
|
|
6
6
|
|
|
7
7
|
export function ensureTrailingSlash(filePath: string): string {
|
|
8
8
|
if (filePath.endsWith('/')) {
|
|
@@ -28,6 +28,10 @@ export function stripTrailingSlash(filePath: string) {
|
|
|
28
28
|
return filePath.slice(0, -1)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export function stripLeadingAndTrailingSlashes(filePath: string) {
|
|
32
|
+
return stripLeadingSlash(stripTrailingSlash(filePath))
|
|
33
|
+
}
|
|
34
|
+
|
|
31
35
|
export function slugifyPath(filePath: string): string {
|
|
32
36
|
const segments = stripExtension(filePath).split('/')
|
|
33
37
|
|
package/libs/starlight.ts
CHANGED
|
@@ -7,7 +7,16 @@ import type { HookParameters } from '@astrojs/starlight/types'
|
|
|
7
7
|
import type { z } from 'astro/zod'
|
|
8
8
|
import yaml from 'yaml'
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ensureTrailingSlash,
|
|
12
|
+
isAbsoluteLink,
|
|
13
|
+
slugifyPath,
|
|
14
|
+
stripLeadingAndTrailingSlashes,
|
|
15
|
+
stripTrailingSlash,
|
|
16
|
+
} from './path'
|
|
17
|
+
|
|
18
|
+
const frontmatterRegex = /^---\r?\n(?<yaml>.*?)\r?\n---(?:\r?\n|$)/s
|
|
19
|
+
const queryOrFragmentRegex = /[?#]/
|
|
11
20
|
|
|
12
21
|
export function parseFrontmatter(frontmatter: string) {
|
|
13
22
|
return yaml.parse(frontmatter) as StarlightFrontmatter
|
|
@@ -17,13 +26,45 @@ export function getFrontmatterNodeValue(frontmatter: StarlightFrontmatter) {
|
|
|
17
26
|
return yaml.stringify(frontmatter).trim()
|
|
18
27
|
}
|
|
19
28
|
|
|
29
|
+
export function getDocPath(docsDir: URL, doc: URL) {
|
|
30
|
+
return path.relative(url.fileURLToPath(docsDir), url.fileURLToPath(doc)).replaceAll('\\', '/')
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
export function getDocSlug(docsDir: URL, doc: URL) {
|
|
21
|
-
const
|
|
22
|
-
const slug = slugifyPath(docPath)
|
|
34
|
+
const slug = slugifyPath(getDocPath(docsDir, doc))
|
|
23
35
|
|
|
24
36
|
return slug === 'index' ? '/' : slug.replace(/\/index$/, '')
|
|
25
37
|
}
|
|
26
38
|
|
|
39
|
+
export function resolveDocSlug(docsDir: URL, doc: URL, content: string) {
|
|
40
|
+
const frontmatter = frontmatterRegex.exec(content)?.groups?.['yaml']
|
|
41
|
+
const customSlug = frontmatter ? parseFrontmatter(frontmatter).slug : undefined
|
|
42
|
+
|
|
43
|
+
return stripLeadingAndTrailingSlashes(typeof customSlug === 'string' ? customSlug : getDocSlug(docsDir, doc))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// https://github.com/withastro/starlight/blob/ff5090a8fd7ae068b29e3c5af7197c5e5afdfa4e/packages/starlight/utils/slugs.ts#L104
|
|
47
|
+
export function getURLSlug(url: URL) {
|
|
48
|
+
let pathname = decodeURI(url.pathname)
|
|
49
|
+
|
|
50
|
+
const base = stripTrailingSlash(import.meta.env.BASE_URL)
|
|
51
|
+
if (pathname === base || pathname.startsWith(ensureTrailingSlash(base))) pathname = pathname.slice(base.length)
|
|
52
|
+
|
|
53
|
+
const segments = pathname.split('/')
|
|
54
|
+
const htmlExt = '.html'
|
|
55
|
+
|
|
56
|
+
if (segments.at(-1) === 'index.html') {
|
|
57
|
+
// Remove trailing `index.html`.
|
|
58
|
+
segments.pop()
|
|
59
|
+
} else if (segments.at(-1)?.endsWith(htmlExt)) {
|
|
60
|
+
// Remove trailing `.html`.
|
|
61
|
+
const last = segments.pop()
|
|
62
|
+
if (last) segments.push(last.slice(0, -1 * htmlExt.length))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return segments.filter(Boolean).join('/')
|
|
66
|
+
}
|
|
67
|
+
|
|
27
68
|
export function getDocLocale(slug: string, starlightConfig: StarlightUserConfig): string | undefined {
|
|
28
69
|
const locales = Object.keys(starlightConfig.locales ?? {})
|
|
29
70
|
|
|
@@ -36,14 +77,22 @@ export function getDocLocale(slug: string, starlightConfig: StarlightUserConfig)
|
|
|
36
77
|
return locales.find((locale) => locale === localeSegment)
|
|
37
78
|
}
|
|
38
79
|
|
|
39
|
-
export function
|
|
80
|
+
export function getSidebarLinks(sidebar: StarlightSidebar): Extract<StarlightSidebar[number], { type: 'link' }>[] {
|
|
81
|
+
return sidebar.flatMap((entry) => (entry.type === 'group' ? getSidebarLinks(entry.entries) : entry))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function addPrefixToSidebarConfig(
|
|
85
|
+
prefix: string,
|
|
86
|
+
sidebar: SidebarConfigInputItem[],
|
|
87
|
+
excludedSlugs: string[] = [],
|
|
88
|
+
): SidebarConfig {
|
|
40
89
|
return sidebar.map((item) => {
|
|
41
90
|
if (typeof item === 'string') {
|
|
42
|
-
return addPrefixToSlug(prefix, item)
|
|
91
|
+
return isExcludedInternalSidebarSlug(item, excludedSlugs) ? item : addPrefixToSlug(prefix, item)
|
|
43
92
|
} else if ('items' in item) {
|
|
44
93
|
return {
|
|
45
94
|
...item,
|
|
46
|
-
items: addPrefixToSidebarConfig(prefix, item.items),
|
|
95
|
+
items: addPrefixToSidebarConfig(prefix, item.items, excludedSlugs),
|
|
47
96
|
}
|
|
48
97
|
} else if (isSidebarLegacyAutoGroup(item)) {
|
|
49
98
|
return addPrefixToLegacyAutogeneratedGroup(prefix, item)
|
|
@@ -55,12 +104,15 @@ export function addPrefixToSidebarConfig(prefix: string, sidebar: SidebarConfigI
|
|
|
55
104
|
} else if ('slug' in item) {
|
|
56
105
|
return {
|
|
57
106
|
...item,
|
|
58
|
-
slug: addPrefixToSlug(prefix, item.slug),
|
|
107
|
+
slug: isExcludedInternalSidebarSlug(item.slug, excludedSlugs) ? item.slug : addPrefixToSlug(prefix, item.slug),
|
|
59
108
|
}
|
|
60
109
|
} else if (isAbsoluteLink(item.link)) {
|
|
61
110
|
return item
|
|
62
111
|
}
|
|
63
112
|
|
|
113
|
+
const link = item.link.startsWith('./') ? item.link.slice(2) : item.link
|
|
114
|
+
if (isExcludedSidebarSlug(link, excludedSlugs)) return item
|
|
115
|
+
|
|
64
116
|
const segments = item.link.split('/')
|
|
65
117
|
segments.splice(/^[./]/.test(item.link) ? 1 : 0, 0, prefix)
|
|
66
118
|
|
|
@@ -71,6 +123,16 @@ export function addPrefixToSidebarConfig(prefix: string, sidebar: SidebarConfigI
|
|
|
71
123
|
})
|
|
72
124
|
}
|
|
73
125
|
|
|
126
|
+
function isExcludedSidebarSlug(value: string, excludedSlugs: string[]) {
|
|
127
|
+
const pathname = value.split(queryOrFragmentRegex)[0] ?? ''
|
|
128
|
+
const slug = stripLeadingAndTrailingSlashes(pathname)
|
|
129
|
+
return excludedSlugs.includes(slug)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isExcludedInternalSidebarSlug(slug: string, excludedSlugs: string[]) {
|
|
133
|
+
return isExcludedSidebarSlug(slug === 'index' ? '' : slug, excludedSlugs)
|
|
134
|
+
}
|
|
135
|
+
|
|
74
136
|
function isSidebarLegacyAutoGroup(item: SidebarConfigInputItem): item is SidebarLegacyAutoGroup {
|
|
75
137
|
return typeof item === 'object' && 'autogenerate' in item && 'label' in item
|
|
76
138
|
}
|
package/libs/versions.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
1
4
|
import type { StarlightConfig } from '@astrojs/starlight/types'
|
|
2
5
|
import type { AstroConfig, AstroIntegrationLogger } from 'astro'
|
|
3
6
|
import { z } from 'astro/zod'
|
|
4
7
|
|
|
5
8
|
import type { StarlightVersionsConfig } from '..'
|
|
6
|
-
import type
|
|
9
|
+
import { docsVersionsSchema, type DocsVersionsConfig } from '../schema'
|
|
7
10
|
|
|
8
11
|
import {
|
|
9
12
|
copyDirectory,
|
|
@@ -15,10 +18,19 @@ import {
|
|
|
15
18
|
writeJSONFile,
|
|
16
19
|
} from './fs'
|
|
17
20
|
import { transformMarkdown } from './markdown'
|
|
18
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ensureTrailingSlash,
|
|
23
|
+
getExtension,
|
|
24
|
+
stripExtension,
|
|
25
|
+
stripLeadingAndTrailingSlashes,
|
|
26
|
+
stripTrailingSlash,
|
|
27
|
+
} from './path'
|
|
19
28
|
import { throwPluginError } from './plugin'
|
|
20
29
|
import {
|
|
21
30
|
getDocSlug,
|
|
31
|
+
getDocPath,
|
|
32
|
+
getURLSlug,
|
|
33
|
+
resolveDocSlug,
|
|
22
34
|
type StarlightSidebarUserConfig,
|
|
23
35
|
type StarlightUserConfig,
|
|
24
36
|
addPrefixToSidebarConfig,
|
|
@@ -28,6 +40,9 @@ import {
|
|
|
28
40
|
|
|
29
41
|
const currentVersionSidebarGroupLabel = Symbol('StarlightVersionsCurrentVersionSidebarGroupLabel')
|
|
30
42
|
|
|
43
|
+
// https://github.com/withastro/starlight/blob/23207221169b31e0cc210451a39a19c80fa1f69e/packages/starlight/loaders.ts#L6
|
|
44
|
+
const docsExtensions = new Set(['.markdown', '.mdown', '.mkdn', '.mkd', '.mdwn', '.md', '.mdx'])
|
|
45
|
+
|
|
31
46
|
export const VersionBaseSchema = z.object({
|
|
32
47
|
/**
|
|
33
48
|
* The version redirect strategy used when navigating to this version:
|
|
@@ -56,7 +71,7 @@ export const VersionSchema = z.object({
|
|
|
56
71
|
* @example '2.0'
|
|
57
72
|
* @example '3-1-2'
|
|
58
73
|
*/
|
|
59
|
-
slug: z.string().refine((value) =>
|
|
74
|
+
slug: z.string().refine((value) => stripLeadingAndTrailingSlashes(value)),
|
|
60
75
|
})
|
|
61
76
|
|
|
62
77
|
export async function ensureNewVersion(
|
|
@@ -71,6 +86,13 @@ export async function ensureNewVersion(
|
|
|
71
86
|
|
|
72
87
|
if (!newVersion) return
|
|
73
88
|
|
|
89
|
+
const excludedDocs = await getExcludedDocs(
|
|
90
|
+
config,
|
|
91
|
+
locales.filter((locale) => locale !== 'root'),
|
|
92
|
+
docsDir,
|
|
93
|
+
)
|
|
94
|
+
const excludedSlugs = [...excludedDocs.values()].toSorted()
|
|
95
|
+
|
|
74
96
|
const assets: VersionAsset[] = []
|
|
75
97
|
|
|
76
98
|
await copyDirectory(docsDir, new URL(ensureTrailingSlash(newVersion.slug), docsDir), async (entry) => {
|
|
@@ -86,7 +108,7 @@ export async function ensureNewVersion(
|
|
|
86
108
|
}
|
|
87
109
|
|
|
88
110
|
// Do not skip other non-root directories.
|
|
89
|
-
return
|
|
111
|
+
return
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
// Skip root version directories.
|
|
@@ -95,17 +117,22 @@ export async function ensureNewVersion(
|
|
|
95
117
|
const localeDir = locales.find((locale) => locale === entry.name)
|
|
96
118
|
|
|
97
119
|
// Copy root directories not matching any locale.
|
|
98
|
-
if (!localeDir) return
|
|
120
|
+
if (!localeDir) return
|
|
99
121
|
|
|
100
122
|
// Otherwise, swap the locale and version directories.
|
|
101
123
|
return new URL(`../../${localeDir}/${newVersion.slug}/`, entry.dest)
|
|
102
124
|
}
|
|
103
125
|
|
|
126
|
+
if (!docsExtensions.has(getExtension(entry.url.pathname))) return
|
|
127
|
+
if (excludedDocs.has(getDocPath(docsDir, entry.url))) return true
|
|
128
|
+
|
|
104
129
|
const slug = getDocSlug(docsDir, entry.url)
|
|
105
130
|
|
|
106
|
-
const md = await transformMarkdown(entry.
|
|
131
|
+
const md = await transformMarkdown(await fs.readFile(entry.url, 'utf8'), {
|
|
107
132
|
assets: [],
|
|
108
133
|
base: stripTrailingSlash(astroConfig.base),
|
|
134
|
+
docsDir,
|
|
135
|
+
excludedSlugs,
|
|
109
136
|
locale: getDocLocale(slug, starlightConfig),
|
|
110
137
|
publicDir: astroConfig.publicDir,
|
|
111
138
|
slug,
|
|
@@ -122,7 +149,7 @@ export async function ensureNewVersion(
|
|
|
122
149
|
await copyFile(asset.source, asset.dest)
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
await makeVersionConfig(newVersion, starlightConfig, astroConfig.srcDir)
|
|
152
|
+
await makeVersionConfig(newVersion, starlightConfig, astroConfig.srcDir, excludedSlugs)
|
|
126
153
|
|
|
127
154
|
logger.info(`Created new version '${newVersion.slug}'.`)
|
|
128
155
|
}
|
|
@@ -139,11 +166,24 @@ export async function getVersionedSidebar(
|
|
|
139
166
|
},
|
|
140
167
|
]
|
|
141
168
|
|
|
169
|
+
const excludedSlugsByVersion: Record<string, string[]> = {}
|
|
170
|
+
|
|
142
171
|
for (const version of config.versions) {
|
|
143
|
-
const
|
|
144
|
-
|
|
172
|
+
const versionConfig = await getVersionConfig(version, srcDir)
|
|
173
|
+
const excludedSlugs = versionConfig.excluded
|
|
174
|
+
|
|
175
|
+
excludedSlugsByVersion[version.slug] = excludedSlugs
|
|
176
|
+
|
|
177
|
+
sidebar.push({
|
|
178
|
+
label: version.slug,
|
|
179
|
+
items: versionConfig.sidebar
|
|
180
|
+
? addPrefixToSidebarConfig(version.slug, versionConfig.sidebar, excludedSlugs)
|
|
181
|
+
: [{ autogenerate: { directory: version.slug } }],
|
|
182
|
+
})
|
|
145
183
|
}
|
|
146
184
|
|
|
185
|
+
config.excludedSlugsByVersion = excludedSlugsByVersion
|
|
186
|
+
|
|
147
187
|
return sidebar
|
|
148
188
|
}
|
|
149
189
|
|
|
@@ -170,15 +210,26 @@ export function getVersionURL(
|
|
|
170
210
|
url: URL,
|
|
171
211
|
version: Version | undefined,
|
|
172
212
|
): URL {
|
|
213
|
+
const canonicalSlug = getCanonicalSlug(config, starlightConfig, url)
|
|
214
|
+
|
|
215
|
+
if (version && isSlugExcludedFromVersion(config, version, canonicalSlug)) {
|
|
216
|
+
return getVersionURL(
|
|
217
|
+
{ ...config, current: { ...config.current, redirect: 'same-page' } },
|
|
218
|
+
starlightConfig,
|
|
219
|
+
url,
|
|
220
|
+
undefined,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
173
224
|
const versionURL = new URL(url)
|
|
174
225
|
const versionSlug = version?.slug ?? ''
|
|
175
226
|
const versionRedirect = version?.redirect ?? config.current.redirect
|
|
176
227
|
|
|
177
228
|
const base = stripTrailingSlash(import.meta.env.BASE_URL)
|
|
178
|
-
const hasBase = versionURL.pathname.startsWith(base)
|
|
229
|
+
const hasBase = versionURL.pathname === base || versionURL.pathname.startsWith(ensureTrailingSlash(base))
|
|
179
230
|
|
|
180
231
|
if (hasBase) {
|
|
181
|
-
versionURL.pathname = versionURL.pathname.
|
|
232
|
+
versionURL.pathname = versionURL.pathname.slice(base.length)
|
|
182
233
|
}
|
|
183
234
|
|
|
184
235
|
let baseSegment: string | undefined
|
|
@@ -243,6 +294,42 @@ export function getVersionURL(
|
|
|
243
294
|
return versionURL
|
|
244
295
|
}
|
|
245
296
|
|
|
297
|
+
export function getCanonicalSlug(config: StarlightVersionsConfig, starlightConfig: StarlightConfig, url: URL) {
|
|
298
|
+
const slug = getURLSlug(url)
|
|
299
|
+
const version = getVersionFromSlug(config, starlightConfig, slug)
|
|
300
|
+
return version ? stripVersionSegment(slug, version.slug) : slug
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function isSlugExcludedFromVersion(config: StarlightVersionsConfig, version: Version | undefined, slug: string) {
|
|
304
|
+
return version ? (config.excludedSlugsByVersion[version.slug]?.includes(slug) ?? false) : false
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function isExcludedDocPath(config: StarlightVersionsConfig, filePath: string) {
|
|
308
|
+
const normalizedPath = filePath.replaceAll('\\', '/')
|
|
309
|
+
const docsRoot = 'content/docs/'
|
|
310
|
+
const docsRootIndex = normalizedPath.indexOf(docsRoot)
|
|
311
|
+
if (docsRootIndex === -1) return false
|
|
312
|
+
|
|
313
|
+
const docsPath = normalizedPath.slice(docsRootIndex + docsRoot.length)
|
|
314
|
+
|
|
315
|
+
return config.exclude.some((excludeGlob) => path.posix.matchesGlob(docsPath, excludeGlob))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function getPagefindVersionIdentifiers(
|
|
319
|
+
config: StarlightVersionsConfig,
|
|
320
|
+
version: Version | undefined,
|
|
321
|
+
slug: string,
|
|
322
|
+
): [string, ...string[]] {
|
|
323
|
+
if (version) return [version.slug]
|
|
324
|
+
|
|
325
|
+
return [
|
|
326
|
+
'current',
|
|
327
|
+
...config.versions
|
|
328
|
+
.filter((archivedVersion) => isSlugExcludedFromVersion(config, archivedVersion, slug))
|
|
329
|
+
.map((archivedVersion) => archivedVersion.slug),
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
|
|
246
333
|
// An undefined version is valid and represents the current version.
|
|
247
334
|
export function getVersionFromSlug(
|
|
248
335
|
config: StarlightVersionsConfig,
|
|
@@ -268,8 +355,95 @@ export function getVersionFromSlug(
|
|
|
268
355
|
return config.versions.find((version) => version.slug === versionSegment)
|
|
269
356
|
}
|
|
270
357
|
|
|
271
|
-
export function
|
|
272
|
-
|
|
358
|
+
export function addExcludedLinksToVersionSidebar(
|
|
359
|
+
currentSidebar: StarlightSidebar,
|
|
360
|
+
archivedSidebar: StarlightSidebar,
|
|
361
|
+
version: Version,
|
|
362
|
+
excludedSlugs: string[],
|
|
363
|
+
baseURL: URL,
|
|
364
|
+
) {
|
|
365
|
+
addExcludedLinksToVersionSidebarEntries(currentSidebar, archivedSidebar, version.slug, excludedSlugs, baseURL)
|
|
366
|
+
|
|
367
|
+
return archivedSidebar
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function addExcludedLinksToVersionSidebarEntries(
|
|
371
|
+
currentSidebar: StarlightSidebar,
|
|
372
|
+
archivedSidebar: StarlightSidebar,
|
|
373
|
+
version: string,
|
|
374
|
+
excludedSlugs: string[],
|
|
375
|
+
baseURL: URL,
|
|
376
|
+
) {
|
|
377
|
+
let cursor = 0
|
|
378
|
+
|
|
379
|
+
for (const entry of currentSidebar) {
|
|
380
|
+
const archivedIndex = archivedSidebar.findIndex(
|
|
381
|
+
(candidate, index) => index >= cursor && isMatchingSidebarEntry(entry, candidate, version, baseURL),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if (archivedIndex !== -1) {
|
|
385
|
+
const archivedEntry = archivedSidebar[archivedIndex]
|
|
386
|
+
|
|
387
|
+
if (entry.type === 'group' && archivedEntry?.type === 'group') {
|
|
388
|
+
addExcludedLinksToVersionSidebarEntries(entry.entries, archivedEntry.entries, version, excludedSlugs, baseURL)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
cursor = archivedIndex + 1
|
|
392
|
+
continue
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const clone = cloneExcludedSidebarEntry(entry, excludedSlugs, baseURL)
|
|
396
|
+
if (!clone) continue
|
|
397
|
+
|
|
398
|
+
archivedSidebar.splice(cursor, 0, clone)
|
|
399
|
+
cursor++
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function cloneExcludedSidebarEntry(
|
|
404
|
+
entry: StarlightSidebar[number],
|
|
405
|
+
excludedSlugs: string[],
|
|
406
|
+
baseURL: URL,
|
|
407
|
+
): StarlightSidebar[number] | undefined {
|
|
408
|
+
if (!('autogenerate' in entry)) return undefined
|
|
409
|
+
|
|
410
|
+
if (entry.type === 'link') {
|
|
411
|
+
const slug = getSidebarSlug(entry.href, baseURL)
|
|
412
|
+
if (!excludedSlugs.includes(slug)) return undefined
|
|
413
|
+
return { ...entry, isCurrent: false }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const entries = entry.entries.flatMap((child) => {
|
|
417
|
+
const clone = cloneExcludedSidebarEntry(child, excludedSlugs, baseURL)
|
|
418
|
+
return clone ? [clone] : []
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
return entries.length > 0 ? { ...entry, entries } : undefined
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isMatchingSidebarEntry(
|
|
425
|
+
current: StarlightSidebar[number],
|
|
426
|
+
archived: StarlightSidebar[number],
|
|
427
|
+
version: string,
|
|
428
|
+
baseURL: URL,
|
|
429
|
+
) {
|
|
430
|
+
if (current.type !== archived.type) return false
|
|
431
|
+
if (current.type === 'link' && archived.type === 'link') {
|
|
432
|
+
return getSidebarSlug(current.href, baseURL) === getSidebarSlug(archived.href, baseURL, version)
|
|
433
|
+
}
|
|
434
|
+
if (current.type !== 'group' || archived.type !== 'group') return false
|
|
435
|
+
|
|
436
|
+
const currentAuto = 'autogenerate' in current
|
|
437
|
+
const archivedAuto = 'autogenerate' in archived
|
|
438
|
+
if (currentAuto !== archivedAuto || current.label !== archived.label) return false
|
|
439
|
+
if (!currentAuto || !archivedAuto) return true
|
|
440
|
+
|
|
441
|
+
return current.autogenerate.directory === stripVersionSegment(archived.autogenerate.directory, version)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getSidebarSlug(href: string, baseURL: URL, version?: string) {
|
|
445
|
+
const slug = getURLSlug(new URL(href, baseURL))
|
|
446
|
+
return version ? stripVersionSegment(slug, version) : slug
|
|
273
447
|
}
|
|
274
448
|
|
|
275
449
|
// An undefined version is valid and represents the current version.
|
|
@@ -278,12 +452,9 @@ export function getVersionFromPaginationLink(
|
|
|
278
452
|
link: string,
|
|
279
453
|
locale: string | undefined,
|
|
280
454
|
): Version | undefined {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// Remove the base segment if configured.
|
|
285
|
-
segments.splice(0, 1)
|
|
286
|
-
}
|
|
455
|
+
const base = stripTrailingSlash(import.meta.env.BASE_URL)
|
|
456
|
+
const pathname = link === base || link.startsWith(ensureTrailingSlash(base)) ? link.slice(base.length) : link
|
|
457
|
+
const segments = stripLeadingAndTrailingSlashes(pathname).split('/')
|
|
287
458
|
|
|
288
459
|
if (locale) {
|
|
289
460
|
// Remove the locale segment if the current locale is not a root locale.
|
|
@@ -297,30 +468,57 @@ export function getVersionFromPaginationLink(
|
|
|
297
468
|
return config.versions.find((version) => version.slug === versionSegment)
|
|
298
469
|
}
|
|
299
470
|
|
|
300
|
-
async function getSidebarVersionGroup(version: Version, srcDir: URL) {
|
|
301
|
-
const versionConfig = await getVersionConfig(version, srcDir)
|
|
302
|
-
|
|
303
|
-
if (!versionConfig.sidebar) {
|
|
304
|
-
return {
|
|
305
|
-
label: version.slug,
|
|
306
|
-
items: [{ autogenerate: { directory: version.slug } }],
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
label: version.slug,
|
|
312
|
-
items: addPrefixToSidebarConfig(version.slug, versionConfig.sidebar),
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
471
|
async function getVersionConfig(version: Version, srcDir: URL) {
|
|
317
472
|
try {
|
|
318
|
-
return
|
|
473
|
+
return docsVersionsSchema().parse(
|
|
474
|
+
await readJSONFile<Partial<DocsVersionsConfig>>(getVersionConfigURL(version, srcDir)),
|
|
475
|
+
)
|
|
319
476
|
} catch (error) {
|
|
320
477
|
throw new Error(`Failed to read the version '${version.slug}' configuration file.`, { cause: error })
|
|
321
478
|
}
|
|
322
479
|
}
|
|
323
480
|
|
|
481
|
+
async function getExcludedDocs(config: StarlightVersionsConfig, locales: string[], docsDir: URL) {
|
|
482
|
+
const excludedDocs = new Map<string, string>()
|
|
483
|
+
|
|
484
|
+
async function visitDirectory(directory: URL) {
|
|
485
|
+
for (const entry of await listDirectory(directory)) {
|
|
486
|
+
const isDirectory = await isDirectoryEntry(entry)
|
|
487
|
+
const entryURL = new URL(isDirectory ? ensureTrailingSlash(entry.name) : entry.name, directory)
|
|
488
|
+
const docPath = getDocPath(docsDir, entryURL)
|
|
489
|
+
|
|
490
|
+
if (isDirectory) {
|
|
491
|
+
const [firstSegment, secondSegment] = docPath.split('/')
|
|
492
|
+
|
|
493
|
+
if (
|
|
494
|
+
(firstSegment && firstSegment in config.versionsBySlug) ||
|
|
495
|
+
(firstSegment && locales.includes(firstSegment) && secondSegment && secondSegment in config.versionsBySlug)
|
|
496
|
+
) {
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await visitDirectory(entryURL)
|
|
501
|
+
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
!entry.isFile() ||
|
|
507
|
+
!docsExtensions.has(getExtension(docPath)) ||
|
|
508
|
+
!config.exclude.some((excludeGlob) => path.posix.matchesGlob(docPath, excludeGlob))
|
|
509
|
+
) {
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
excludedDocs.set(docPath, resolveDocSlug(docsDir, entryURL, await fs.readFile(entryURL, 'utf8')))
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await visitDirectory(docsDir)
|
|
518
|
+
|
|
519
|
+
return excludedDocs
|
|
520
|
+
}
|
|
521
|
+
|
|
324
522
|
async function checkForNewVersion(config: StarlightVersionsConfig, docsDir: URL): Promise<Version | undefined> {
|
|
325
523
|
let newVersion: Version | undefined
|
|
326
524
|
|
|
@@ -345,12 +543,18 @@ async function checkForNewVersion(config: StarlightVersionsConfig, docsDir: URL)
|
|
|
345
543
|
return newVersion
|
|
346
544
|
}
|
|
347
545
|
|
|
348
|
-
async function makeVersionConfig(
|
|
546
|
+
async function makeVersionConfig(
|
|
547
|
+
version: Version,
|
|
548
|
+
starlightConfig: StarlightUserConfig,
|
|
549
|
+
srcDir: URL,
|
|
550
|
+
excludedSlugs: string[],
|
|
551
|
+
) {
|
|
349
552
|
const versionsDir = getVersionContentCollectionURL(srcDir)
|
|
350
553
|
|
|
351
554
|
await ensureDirectory(versionsDir)
|
|
352
555
|
await writeJSONFile(getVersionConfigURL(version, srcDir), {
|
|
353
556
|
sidebar: starlightConfig.sidebar,
|
|
557
|
+
excluded: excludedSlugs,
|
|
354
558
|
} satisfies DocsVersionsConfig)
|
|
355
559
|
}
|
|
356
560
|
|
|
@@ -362,6 +566,13 @@ function getVersionConfigURL(version: Version, srcDir: URL) {
|
|
|
362
566
|
return new URL(`${version.slug}.json`, getVersionContentCollectionURL(srcDir))
|
|
363
567
|
}
|
|
364
568
|
|
|
569
|
+
function stripVersionSegment(route: string, version: string) {
|
|
570
|
+
const segments = route.split('/')
|
|
571
|
+
const index = segments.indexOf(version)
|
|
572
|
+
if (index !== -1) segments.splice(index, 1)
|
|
573
|
+
return segments.join('/')
|
|
574
|
+
}
|
|
575
|
+
|
|
365
576
|
export type Version = z.output<typeof VersionSchema>
|
|
366
577
|
|
|
367
578
|
export interface VersionAsset {
|
package/middleware.ts
CHANGED
|
@@ -2,32 +2,119 @@ import { defineRouteMiddleware, type StarlightRouteData } from '@astrojs/starlig
|
|
|
2
2
|
import starlightConfig from 'virtual:starlight/user-config'
|
|
3
3
|
import starlightVersionsConfig from 'virtual:starlight-versions-config'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { getSidebarLinks } from './libs/starlight'
|
|
6
|
+
import {
|
|
7
|
+
addExcludedLinksToVersionSidebar,
|
|
8
|
+
getCanonicalSlug,
|
|
9
|
+
getVersionFromPaginationLink,
|
|
10
|
+
getVersionFromSlug,
|
|
11
|
+
getVersionSidebar,
|
|
12
|
+
isSlugExcludedFromVersion,
|
|
13
|
+
type Version,
|
|
14
|
+
} from './libs/versions'
|
|
6
15
|
|
|
7
16
|
export const onRequest = defineRouteMiddleware((context) => {
|
|
8
17
|
const { starlightRoute } = context.locals
|
|
9
|
-
const { entry, locale,
|
|
10
|
-
|
|
11
|
-
starlightRoute.sidebar = getVersionSidebar(
|
|
12
|
-
getVersionFromSlug(starlightVersionsConfig, starlightConfig, entry.id),
|
|
13
|
-
sidebar,
|
|
14
|
-
)
|
|
18
|
+
const { entry, locale, sidebar } = starlightRoute
|
|
15
19
|
|
|
16
20
|
const pageVersion = getVersionFromSlug(starlightVersionsConfig, starlightConfig, entry.id)
|
|
21
|
+
const versionSidebar = getVersionSidebar(pageVersion, sidebar)
|
|
22
|
+
|
|
23
|
+
if (pageVersion) {
|
|
24
|
+
const excludedSlugs = starlightVersionsConfig.excludedSlugsByVersion[pageVersion.slug] ?? []
|
|
25
|
+
if (excludedSlugs.length > 0) {
|
|
26
|
+
addExcludedLinksToVersionSidebar(
|
|
27
|
+
getVersionSidebar(undefined, sidebar),
|
|
28
|
+
versionSidebar,
|
|
29
|
+
pageVersion,
|
|
30
|
+
excludedSlugs,
|
|
31
|
+
context.url,
|
|
32
|
+
)
|
|
33
|
+
starlightRoute.pagination = getPaginationFromSidebar(starlightRoute, versionSidebar)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
starlightRoute.sidebar = versionSidebar
|
|
17
38
|
|
|
18
|
-
starlightRoute.pagination.prev = getPaginationLink(locale, pageVersion, pagination.prev)
|
|
19
|
-
starlightRoute.pagination.next = getPaginationLink(locale, pageVersion, pagination.next)
|
|
39
|
+
starlightRoute.pagination.prev = getPaginationLink(context.url, locale, pageVersion, starlightRoute.pagination.prev)
|
|
40
|
+
starlightRoute.pagination.next = getPaginationLink(context.url, locale, pageVersion, starlightRoute.pagination.next)
|
|
20
41
|
})
|
|
21
42
|
|
|
22
|
-
function getPaginationLink(
|
|
43
|
+
function getPaginationLink(
|
|
44
|
+
baseURL: URL,
|
|
45
|
+
locale: string | undefined,
|
|
46
|
+
currentVersion: Version | undefined,
|
|
47
|
+
link: PaginationLink,
|
|
48
|
+
) {
|
|
23
49
|
if (!link) return undefined
|
|
24
50
|
|
|
25
51
|
const linkVersion = getVersionFromPaginationLink(starlightVersionsConfig, link.href, locale)
|
|
52
|
+
const canonicalSlug = getCanonicalSlug(starlightVersionsConfig, starlightConfig, new URL(link.href, baseURL))
|
|
26
53
|
|
|
27
|
-
// If the current version is not the same as the link version, remove
|
|
28
|
-
return (currentVersion === undefined && linkVersion === undefined) ||
|
|
54
|
+
// If the current version is not the same as the link version and it's not an excluded version link, remove it.
|
|
55
|
+
return (currentVersion === undefined && linkVersion === undefined) ||
|
|
56
|
+
currentVersion?.slug === linkVersion?.slug ||
|
|
57
|
+
(linkVersion === undefined && isSlugExcludedFromVersion(starlightVersionsConfig, currentVersion, canonicalSlug))
|
|
29
58
|
? link
|
|
30
59
|
: undefined
|
|
31
60
|
}
|
|
32
61
|
|
|
62
|
+
// https://github.com/withastro/starlight/blob/main/packages/starlight/utils/navigation.ts#L481
|
|
63
|
+
function getPaginationFromSidebar(
|
|
64
|
+
route: StarlightRouteData,
|
|
65
|
+
sidebar: StarlightRouteData['sidebar'],
|
|
66
|
+
): StarlightRouteData['pagination'] {
|
|
67
|
+
const entries = getSidebarLinks(sidebar)
|
|
68
|
+
const currentIndex = entries.findIndex((entry) => entry.isCurrent)
|
|
69
|
+
|
|
70
|
+
const prev = applyPaginationConfig(
|
|
71
|
+
entries[currentIndex - 1],
|
|
72
|
+
starlightConfig.pagination,
|
|
73
|
+
route.entry.data.prev,
|
|
74
|
+
route.pagination.prev,
|
|
75
|
+
)
|
|
76
|
+
const next = applyPaginationConfig(
|
|
77
|
+
currentIndex > -1 ? entries[currentIndex + 1] : undefined,
|
|
78
|
+
starlightConfig.pagination,
|
|
79
|
+
route.entry.data.next,
|
|
80
|
+
route.pagination.next,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return { prev, next }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// https://github.com/withastro/starlight/blob/main/packages/starlight/utils/navigation.ts#L501
|
|
87
|
+
function applyPaginationConfig(
|
|
88
|
+
link: PaginationLink,
|
|
89
|
+
paginationEnabled: boolean,
|
|
90
|
+
config: StarlightRouteData['entry']['data']['prev'],
|
|
91
|
+
existing: PaginationLink,
|
|
92
|
+
): PaginationLink {
|
|
93
|
+
// Explicitly remove the link.
|
|
94
|
+
if (config === false) return undefined
|
|
95
|
+
// Use the generated link if any.
|
|
96
|
+
else if (config === true) return link
|
|
97
|
+
// If a link exists, update its label if needed.
|
|
98
|
+
else if (typeof config === 'string' && link) {
|
|
99
|
+
return { ...link, label: config }
|
|
100
|
+
} else if (typeof config === 'object') {
|
|
101
|
+
if (link) {
|
|
102
|
+
// If a link exists, update both its label and href if needed.
|
|
103
|
+
return {
|
|
104
|
+
...link,
|
|
105
|
+
label: config.label ?? link.label,
|
|
106
|
+
href: config.link ?? link.href,
|
|
107
|
+
// Explicitly remove sidebar link attributes for prev/next links.
|
|
108
|
+
attrs: {},
|
|
109
|
+
}
|
|
110
|
+
} else if (config.link && config.label) {
|
|
111
|
+
// If there is no link and the frontmatter contains both a URL and a label, use it.
|
|
112
|
+
return existing
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Otherwise, if the global config is enabled, return the generated link if any.
|
|
117
|
+
return paginationEnabled ? link : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
33
120
|
type PaginationLink = StarlightRouteData['pagination']['next']
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -4,10 +4,12 @@ import type { StarlightSidebarUserConfig } from './libs/starlight'
|
|
|
4
4
|
|
|
5
5
|
export function docsVersionsSchema() {
|
|
6
6
|
return z.object({
|
|
7
|
-
sidebar: z.
|
|
7
|
+
sidebar: z.custom<NonNullable<StarlightSidebarUserConfig>>().optional(),
|
|
8
|
+
excluded: z.array(z.string()).default([]),
|
|
8
9
|
})
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface DocsVersionsConfig {
|
|
12
13
|
sidebar?: StarlightSidebarUserConfig
|
|
14
|
+
excluded: string[]
|
|
13
15
|
}
|