starlight-versions 0.9.1 → 0.10.0

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # starlight-versions
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#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.
12
+
13
+ - [#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.
14
+
3
15
  ## 0.9.1
4
16
 
5
17
  ### 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 { getVersionFromSlug, getVersionIdentifier, getVersionURL } from '../libs/versions'
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 = getVersionIdentifier(pageVersion)
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
- <starlight-version-select>
14
- <label>
15
- <span class="sr-only">{Astro.locals.t('starlightVersions.select.accessibleLabel')}</span>
16
- <Icon name="seti:clock" class="icon label-icon" />
17
- <select value={pageVersion?.label ?? 'current'} autocomplete="off">
18
- <option
19
- selected={pageVersion === undefined}
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={version.slug === pageVersion?.slug}
27
- value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, version).pathname}
22
+ selected={pageVersion === undefined}
23
+ value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, undefined).pathname}
28
24
  >
29
- {version.label ?? version.slug}
25
+ {starlightVersionsConfig.current.label}
30
26
  </option>
31
- ))
32
- }
33
- </select>
34
- <Icon name="down-caret" class="icon caret-icon" />
35
- </label>
36
- </starlight-version-select>
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
- ...value,
30
- versionsBySlug: Object.fromEntries(value.versions.map((version) => [version.slug, version])),
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/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 { isAbsoluteLink, stripLeadingSlash, stripTrailingSlash } from './path'
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?.startsWith('/')) {
103
- frontmatter.prev.link = addVersionToLink(frontmatter.prev.link, file)
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?.startsWith('/')) {
107
- frontmatter.next.link = addVersionToLink(frontmatter.next.link, file)
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
- if (action.link.startsWith('/')) {
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
- if (!isPublicAsset(node.url)) return SKIP
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' || !isPublicAsset(href.value)) return CONTINUE
153
+ if (!href || typeof href.value !== 'string') return CONTINUE
148
154
 
149
- href.value = addVersionToLink(href.value, file)
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 = file.data.base && link.startsWith(file.data.base)
227
+ const hasBase = base && (link === base || link.startsWith(ensureTrailingSlash(base)))
193
228
 
194
229
  if (hasBase) {
195
- link = link.replace(base, '')
230
+ link = link.slice(base.length)
196
231
  }
197
232
 
198
233
  const segments = link.split('/')
@@ -252,6 +287,7 @@ function isPublicAsset(asset: string) {
252
287
  export interface TransformContext {
253
288
  assets: VersionAsset[]
254
289
  base: string
290
+ excludedSlugs: string[]
255
291
  locale: string | undefined
256
292
  publicDir: URL
257
293
  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 = /^https?:\/\//
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 { isAbsoluteLink, slugifyPath } from './path'
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 docPath = path.relative(url.fileURLToPath(docsDir), url.fileURLToPath(doc)).replaceAll('\\', '/')
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 addPrefixToSidebarConfig(prefix: string, sidebar: SidebarConfigInputItem[]): SidebarConfig {
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 { DocsVersionsConfig } from '../schema'
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 { ensureTrailingSlash, getExtension, stripExtension, stripLeadingSlash, stripTrailingSlash } from './path'
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,
@@ -56,7 +68,7 @@ export const VersionSchema = z.object({
56
68
  * @example '2.0'
57
69
  * @example '3-1-2'
58
70
  */
59
- slug: z.string().refine((value) => stripLeadingSlash(stripTrailingSlash(value))),
71
+ slug: z.string().refine((value) => stripLeadingAndTrailingSlashes(value)),
60
72
  })
61
73
 
62
74
  export async function ensureNewVersion(
@@ -71,6 +83,13 @@ export async function ensureNewVersion(
71
83
 
72
84
  if (!newVersion) return
73
85
 
86
+ const excludedDocs = await getExcludedDocs(
87
+ config,
88
+ locales.filter((locale) => locale !== 'root'),
89
+ docsDir,
90
+ )
91
+ const excludedSlugs = [...excludedDocs.values()].toSorted()
92
+
74
93
  const assets: VersionAsset[] = []
75
94
 
76
95
  await copyDirectory(docsDir, new URL(ensureTrailingSlash(newVersion.slug), docsDir), async (entry) => {
@@ -101,11 +120,14 @@ export async function ensureNewVersion(
101
120
  return new URL(`../../${localeDir}/${newVersion.slug}/`, entry.dest)
102
121
  }
103
122
 
123
+ if (excludedDocs.has(getDocPath(docsDir, entry.url))) return false
124
+
104
125
  const slug = getDocSlug(docsDir, entry.url)
105
126
 
106
127
  const md = await transformMarkdown(entry.content, {
107
128
  assets: [],
108
129
  base: stripTrailingSlash(astroConfig.base),
130
+ excludedSlugs,
109
131
  locale: getDocLocale(slug, starlightConfig),
110
132
  publicDir: astroConfig.publicDir,
111
133
  slug,
@@ -122,7 +144,7 @@ export async function ensureNewVersion(
122
144
  await copyFile(asset.source, asset.dest)
123
145
  }
124
146
 
125
- await makeVersionConfig(newVersion, starlightConfig, astroConfig.srcDir)
147
+ await makeVersionConfig(newVersion, starlightConfig, astroConfig.srcDir, excludedSlugs)
126
148
 
127
149
  logger.info(`Created new version '${newVersion.slug}'.`)
128
150
  }
@@ -139,11 +161,24 @@ export async function getVersionedSidebar(
139
161
  },
140
162
  ]
141
163
 
164
+ const excludedSlugsByVersion: Record<string, string[]> = {}
165
+
142
166
  for (const version of config.versions) {
143
- const versionSidebar = await getSidebarVersionGroup(version, srcDir)
144
- sidebar.push(versionSidebar)
167
+ const versionConfig = await getVersionConfig(version, srcDir)
168
+ const excludedSlugs = versionConfig.excluded
169
+
170
+ excludedSlugsByVersion[version.slug] = excludedSlugs
171
+
172
+ sidebar.push({
173
+ label: version.slug,
174
+ items: versionConfig.sidebar
175
+ ? addPrefixToSidebarConfig(version.slug, versionConfig.sidebar, excludedSlugs)
176
+ : [{ autogenerate: { directory: version.slug } }],
177
+ })
145
178
  }
146
179
 
180
+ config.excludedSlugsByVersion = excludedSlugsByVersion
181
+
147
182
  return sidebar
148
183
  }
149
184
 
@@ -170,15 +205,26 @@ export function getVersionURL(
170
205
  url: URL,
171
206
  version: Version | undefined,
172
207
  ): URL {
208
+ const canonicalSlug = getCanonicalSlug(config, starlightConfig, url)
209
+
210
+ if (version && isSlugExcludedFromVersion(config, version, canonicalSlug)) {
211
+ return getVersionURL(
212
+ { ...config, current: { ...config.current, redirect: 'same-page' } },
213
+ starlightConfig,
214
+ url,
215
+ undefined,
216
+ )
217
+ }
218
+
173
219
  const versionURL = new URL(url)
174
220
  const versionSlug = version?.slug ?? ''
175
221
  const versionRedirect = version?.redirect ?? config.current.redirect
176
222
 
177
223
  const base = stripTrailingSlash(import.meta.env.BASE_URL)
178
- const hasBase = versionURL.pathname.startsWith(base)
224
+ const hasBase = versionURL.pathname === base || versionURL.pathname.startsWith(ensureTrailingSlash(base))
179
225
 
180
226
  if (hasBase) {
181
- versionURL.pathname = versionURL.pathname.replace(base, '')
227
+ versionURL.pathname = versionURL.pathname.slice(base.length)
182
228
  }
183
229
 
184
230
  let baseSegment: string | undefined
@@ -243,6 +289,42 @@ export function getVersionURL(
243
289
  return versionURL
244
290
  }
245
291
 
292
+ export function getCanonicalSlug(config: StarlightVersionsConfig, starlightConfig: StarlightConfig, url: URL) {
293
+ const slug = getURLSlug(url)
294
+ const version = getVersionFromSlug(config, starlightConfig, slug)
295
+ return version ? stripVersionSegment(slug, version.slug) : slug
296
+ }
297
+
298
+ export function isSlugExcludedFromVersion(config: StarlightVersionsConfig, version: Version | undefined, slug: string) {
299
+ return version ? (config.excludedSlugsByVersion[version.slug]?.includes(slug) ?? false) : false
300
+ }
301
+
302
+ export function isExcludedDocPath(config: StarlightVersionsConfig, filePath: string) {
303
+ const normalizedPath = filePath.replaceAll('\\', '/')
304
+ const docsRoot = 'content/docs/'
305
+ const docsRootIndex = normalizedPath.indexOf(docsRoot)
306
+ if (docsRootIndex === -1) return false
307
+
308
+ const docsPath = normalizedPath.slice(docsRootIndex + docsRoot.length)
309
+
310
+ return config.exclude.some((excludeGlob) => path.posix.matchesGlob(docsPath, excludeGlob))
311
+ }
312
+
313
+ export function getPagefindVersionIdentifiers(
314
+ config: StarlightVersionsConfig,
315
+ version: Version | undefined,
316
+ slug: string,
317
+ ): [string, ...string[]] {
318
+ if (version) return [version.slug]
319
+
320
+ return [
321
+ 'current',
322
+ ...config.versions
323
+ .filter((archivedVersion) => isSlugExcludedFromVersion(config, archivedVersion, slug))
324
+ .map((archivedVersion) => archivedVersion.slug),
325
+ ]
326
+ }
327
+
246
328
  // An undefined version is valid and represents the current version.
247
329
  export function getVersionFromSlug(
248
330
  config: StarlightVersionsConfig,
@@ -268,8 +350,95 @@ export function getVersionFromSlug(
268
350
  return config.versions.find((version) => version.slug === versionSegment)
269
351
  }
270
352
 
271
- export function getVersionIdentifier(version: Version | undefined): string {
272
- return version?.slug ?? 'current'
353
+ export function addExcludedLinksToVersionSidebar(
354
+ currentSidebar: StarlightSidebar,
355
+ archivedSidebar: StarlightSidebar,
356
+ version: Version,
357
+ excludedSlugs: string[],
358
+ baseURL: URL,
359
+ ) {
360
+ addExcludedLinksToVersionSidebarEntries(currentSidebar, archivedSidebar, version.slug, excludedSlugs, baseURL)
361
+
362
+ return archivedSidebar
363
+ }
364
+
365
+ function addExcludedLinksToVersionSidebarEntries(
366
+ currentSidebar: StarlightSidebar,
367
+ archivedSidebar: StarlightSidebar,
368
+ version: string,
369
+ excludedSlugs: string[],
370
+ baseURL: URL,
371
+ ) {
372
+ let cursor = 0
373
+
374
+ for (const entry of currentSidebar) {
375
+ const archivedIndex = archivedSidebar.findIndex(
376
+ (candidate, index) => index >= cursor && isMatchingSidebarEntry(entry, candidate, version, baseURL),
377
+ )
378
+
379
+ if (archivedIndex !== -1) {
380
+ const archivedEntry = archivedSidebar[archivedIndex]
381
+
382
+ if (entry.type === 'group' && archivedEntry?.type === 'group') {
383
+ addExcludedLinksToVersionSidebarEntries(entry.entries, archivedEntry.entries, version, excludedSlugs, baseURL)
384
+ }
385
+
386
+ cursor = archivedIndex + 1
387
+ continue
388
+ }
389
+
390
+ const clone = cloneExcludedSidebarEntry(entry, excludedSlugs, baseURL)
391
+ if (!clone) continue
392
+
393
+ archivedSidebar.splice(cursor, 0, clone)
394
+ cursor++
395
+ }
396
+ }
397
+
398
+ function cloneExcludedSidebarEntry(
399
+ entry: StarlightSidebar[number],
400
+ excludedSlugs: string[],
401
+ baseURL: URL,
402
+ ): StarlightSidebar[number] | undefined {
403
+ if (!('autogenerate' in entry)) return undefined
404
+
405
+ if (entry.type === 'link') {
406
+ const slug = getSidebarSlug(entry.href, baseURL)
407
+ if (!excludedSlugs.includes(slug)) return undefined
408
+ return { ...entry, isCurrent: false }
409
+ }
410
+
411
+ const entries = entry.entries.flatMap((child) => {
412
+ const clone = cloneExcludedSidebarEntry(child, excludedSlugs, baseURL)
413
+ return clone ? [clone] : []
414
+ })
415
+
416
+ return entries.length > 0 ? { ...entry, entries } : undefined
417
+ }
418
+
419
+ function isMatchingSidebarEntry(
420
+ current: StarlightSidebar[number],
421
+ archived: StarlightSidebar[number],
422
+ version: string,
423
+ baseURL: URL,
424
+ ) {
425
+ if (current.type !== archived.type) return false
426
+ if (current.type === 'link' && archived.type === 'link') {
427
+ return getSidebarSlug(current.href, baseURL) === getSidebarSlug(archived.href, baseURL, version)
428
+ }
429
+ if (current.type !== 'group' || archived.type !== 'group') return false
430
+
431
+ const currentAuto = 'autogenerate' in current
432
+ const archivedAuto = 'autogenerate' in archived
433
+ if (currentAuto !== archivedAuto || current.label !== archived.label) return false
434
+ if (!currentAuto || !archivedAuto) return true
435
+
436
+ return current.autogenerate.directory === stripVersionSegment(archived.autogenerate.directory, version)
437
+ }
438
+
439
+ function getSidebarSlug(href: string, baseURL: URL, version?: string) {
440
+ const slug = getURLSlug(new URL(href, baseURL))
441
+ return version ? stripVersionSegment(slug, version) : slug
273
442
  }
274
443
 
275
444
  // An undefined version is valid and represents the current version.
@@ -278,12 +447,9 @@ export function getVersionFromPaginationLink(
278
447
  link: string,
279
448
  locale: string | undefined,
280
449
  ): Version | undefined {
281
- const [, ...segments] = link.split('/')
282
-
283
- if (import.meta.env.BASE_URL !== '/') {
284
- // Remove the base segment if configured.
285
- segments.splice(0, 1)
286
- }
450
+ const base = stripTrailingSlash(import.meta.env.BASE_URL)
451
+ const pathname = link === base || link.startsWith(ensureTrailingSlash(base)) ? link.slice(base.length) : link
452
+ const segments = stripLeadingAndTrailingSlashes(pathname).split('/')
287
453
 
288
454
  if (locale) {
289
455
  // Remove the locale segment if the current locale is not a root locale.
@@ -297,30 +463,53 @@ export function getVersionFromPaginationLink(
297
463
  return config.versions.find((version) => version.slug === versionSegment)
298
464
  }
299
465
 
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
466
  async function getVersionConfig(version: Version, srcDir: URL) {
317
467
  try {
318
- return await readJSONFile<DocsVersionsConfig>(getVersionConfigURL(version, srcDir))
468
+ return docsVersionsSchema().parse(
469
+ await readJSONFile<Partial<DocsVersionsConfig>>(getVersionConfigURL(version, srcDir)),
470
+ )
319
471
  } catch (error) {
320
472
  throw new Error(`Failed to read the version '${version.slug}' configuration file.`, { cause: error })
321
473
  }
322
474
  }
323
475
 
476
+ async function getExcludedDocs(config: StarlightVersionsConfig, locales: string[], docsDir: URL) {
477
+ const excludedDocs = new Map<string, string>()
478
+
479
+ async function visitDirectory(directory: URL) {
480
+ for (const entry of await listDirectory(directory)) {
481
+ const isDirectory = await isDirectoryEntry(entry)
482
+ const entryURL = new URL(isDirectory ? ensureTrailingSlash(entry.name) : entry.name, directory)
483
+ const docPath = getDocPath(docsDir, entryURL)
484
+
485
+ if (isDirectory) {
486
+ const [firstSegment, secondSegment] = docPath.split('/')
487
+
488
+ if (
489
+ (firstSegment && firstSegment in config.versionsBySlug) ||
490
+ (firstSegment && locales.includes(firstSegment) && secondSegment && secondSegment in config.versionsBySlug)
491
+ ) {
492
+ continue
493
+ }
494
+
495
+ await visitDirectory(entryURL)
496
+
497
+ continue
498
+ }
499
+
500
+ if (!entry.isFile() || !config.exclude.some((excludeGlob) => path.posix.matchesGlob(docPath, excludeGlob))) {
501
+ continue
502
+ }
503
+
504
+ excludedDocs.set(docPath, resolveDocSlug(docsDir, entryURL, await fs.readFile(entryURL, 'utf8')))
505
+ }
506
+ }
507
+
508
+ await visitDirectory(docsDir)
509
+
510
+ return excludedDocs
511
+ }
512
+
324
513
  async function checkForNewVersion(config: StarlightVersionsConfig, docsDir: URL): Promise<Version | undefined> {
325
514
  let newVersion: Version | undefined
326
515
 
@@ -345,12 +534,18 @@ async function checkForNewVersion(config: StarlightVersionsConfig, docsDir: URL)
345
534
  return newVersion
346
535
  }
347
536
 
348
- async function makeVersionConfig(version: Version, starlightConfig: StarlightUserConfig, srcDir: URL) {
537
+ async function makeVersionConfig(
538
+ version: Version,
539
+ starlightConfig: StarlightUserConfig,
540
+ srcDir: URL,
541
+ excludedSlugs: string[],
542
+ ) {
349
543
  const versionsDir = getVersionContentCollectionURL(srcDir)
350
544
 
351
545
  await ensureDirectory(versionsDir)
352
546
  await writeJSONFile(getVersionConfigURL(version, srcDir), {
353
547
  sidebar: starlightConfig.sidebar,
548
+ excluded: excludedSlugs,
354
549
  } satisfies DocsVersionsConfig)
355
550
  }
356
551
 
@@ -362,6 +557,13 @@ function getVersionConfigURL(version: Version, srcDir: URL) {
362
557
  return new URL(`${version.slug}.json`, getVersionContentCollectionURL(srcDir))
363
558
  }
364
559
 
560
+ function stripVersionSegment(route: string, version: string) {
561
+ const segments = route.split('/')
562
+ const index = segments.indexOf(version)
563
+ if (index !== -1) segments.splice(index, 1)
564
+ return segments.join('/')
565
+ }
566
+
365
567
  export type Version = z.output<typeof VersionSchema>
366
568
 
367
569
  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 { getVersionFromPaginationLink, getVersionFromSlug, getVersionSidebar, type Version } from './libs/versions'
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, pagination, sidebar } = starlightRoute
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(locale: string | undefined, currentVersion: Version | undefined, link: PaginationLink) {
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 the link.
28
- return (currentVersion === undefined && linkVersion === undefined) || currentVersion?.slug === linkVersion?.slug
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starlight-versions",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "description": "Starlight plugin to version documentation pages.",
6
6
  "author": "HiDeoo <github@hideoo.dev> (https://hideoo.dev)",
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.any().optional(),
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
  }