starlight-versions 0.1.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.
@@ -0,0 +1,112 @@
1
+ ---
2
+ import { Icon } from '@astrojs/starlight/components'
3
+ import type { Props } from '@astrojs/starlight/props'
4
+ import starlightConfig from 'virtual:starlight/user-config'
5
+ import starlightVersionsConfig from 'virtual:starlight-versions-config'
6
+
7
+ import { getVersionFromSlug, getVersionURL } from '../libs/versions'
8
+
9
+ const { slug } = Astro.props
10
+
11
+ const pageVersion = getVersionFromSlug(starlightVersionsConfig, starlightConfig, slug)
12
+ ---
13
+
14
+ <starlight-version-select>
15
+ <label>
16
+ <span class="sr-only">Select version</span>
17
+ <Icon name="seti:clock" class="icon label-icon" />
18
+ <select value={pageVersion?.label ?? 'current'}>
19
+ <option
20
+ selected={pageVersion === undefined}
21
+ value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, undefined).pathname}
22
+ >{starlightVersionsConfig.current.label}</option
23
+ >
24
+ {
25
+ starlightVersionsConfig.versions.map((version) => (
26
+ <option
27
+ selected={version.slug === pageVersion?.slug}
28
+ value={getVersionURL(starlightVersionsConfig, starlightConfig, Astro.url, version).pathname}
29
+ >
30
+ {version.label ?? version.slug}
31
+ </option>
32
+ ))
33
+ }
34
+ </select>
35
+ <Icon name="down-caret" class="icon caret-icon" />
36
+ </label>
37
+ </starlight-version-select>
38
+
39
+ <style>
40
+ label {
41
+ --sl-versions-label-icon-size: 0.875rem;
42
+ --sl-versions-caret-icon-size: 1.25rem;
43
+
44
+ align-items: center;
45
+ color: var(--sl-color-gray-1);
46
+ display: flex;
47
+ gap: 0.25rem;
48
+ position: relative;
49
+ }
50
+
51
+ label:hover {
52
+ color: var(--sl-color-gray-2);
53
+ }
54
+
55
+ .icon {
56
+ pointer-events: none;
57
+ position: absolute;
58
+ top: 50%;
59
+ transform: translateY(-50%);
60
+ }
61
+
62
+ .label-icon {
63
+ font-size: var(--sl-versions-label-icon-size);
64
+ inset-inline-start: 0;
65
+ }
66
+
67
+ .caret-icon {
68
+ font-size: var(--sl-versions-caret-icon-size);
69
+ inset-inline-end: 0;
70
+ }
71
+
72
+ select {
73
+ appearance: none;
74
+ background-color: transparent;
75
+ border: 0;
76
+ color: inherit;
77
+ cursor: pointer;
78
+ padding-block: 0.625rem;
79
+ padding-inline: calc(var(--sl-versions-label-icon-size) + 0.375rem)
80
+ calc(var(--sl-versions-caret-icon-size) + 0.25rem);
81
+ text-overflow: ellipsis;
82
+ width: 6.5em;
83
+ }
84
+
85
+ option {
86
+ background-color: var(--sl-color-bg-nav);
87
+ color: var(--sl-color-gray-1);
88
+ }
89
+
90
+ @media (min-width: 50rem) {
91
+ select {
92
+ font-size: var(--sl-text-sm);
93
+ }
94
+ }
95
+ </style>
96
+
97
+ <script>
98
+ customElements.define(
99
+ 'starlight-version-select',
100
+ class StarlightVersionSelect extends HTMLElement {
101
+ constructor() {
102
+ super()
103
+
104
+ this.querySelector('select')?.addEventListener('change', (event) => {
105
+ if (event.currentTarget instanceof HTMLSelectElement) {
106
+ window.location.pathname = event.currentTarget.value
107
+ }
108
+ })
109
+ }
110
+ },
111
+ )
112
+ </script>
@@ -0,0 +1,15 @@
1
+ ---
2
+ import Default from '@astrojs/starlight/components/Sidebar.astro'
3
+ import type { Props } from '@astrojs/starlight/props'
4
+ import starlightConfig from 'virtual:starlight/user-config'
5
+ import starlightVersionsConfig from 'virtual:starlight-versions-config'
6
+
7
+ import { getVersionFromSlug, getVersionSidebar } from '../libs/versions'
8
+
9
+ const sidebar = getVersionSidebar(
10
+ getVersionFromSlug(starlightVersionsConfig, starlightConfig, Astro.props.entry.slug),
11
+ Astro.props.sidebar,
12
+ )
13
+ ---
14
+
15
+ <Default {...Astro.props} {sidebar}><slot /></Default>
package/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly BASE_URL: string
5
+ readonly DEV: boolean
6
+ }
7
+
8
+ interface ImportMeta {
9
+ readonly env: ImportMetaEnv
10
+ }
package/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { StarlightPlugin } from '@astrojs/starlight/types'
2
+
3
+ import { StarlightVersionsConfigSchema, type StarlightVersionsUserConfig } from './libs/config'
4
+ import { overrideComponents, throwPluginError } from './libs/plugin'
5
+ import { ensureNewVersion, getVersionedSidebar } from './libs/versions'
6
+ import { vitePluginStarlightVersions } from './libs/vite'
7
+
8
+ export type { StarlightVersionsConfig, StarlightVersionsUserConfig } from './libs/config'
9
+
10
+ export default function starlightVersionsPlugin(userConfig: StarlightVersionsUserConfig): StarlightPlugin {
11
+ const parsedConfig = StarlightVersionsConfigSchema.safeParse(userConfig)
12
+
13
+ if (!parsedConfig.success) {
14
+ throwPluginError(
15
+ `The provided plugin configuration is invalid.\n${parsedConfig.error.issues.map((issue) => issue.message).join('\n')}`,
16
+ )
17
+ }
18
+
19
+ const config = parsedConfig.data
20
+
21
+ return {
22
+ name: 'starlight-versions-plugin',
23
+ hooks: {
24
+ async setup({ addIntegration, astroConfig, command, config: starlightConfig, logger, updateConfig }) {
25
+ if (command !== 'dev' && command !== 'build') return
26
+
27
+ try {
28
+ await ensureNewVersion(config, starlightConfig, astroConfig.srcDir, logger)
29
+ } catch (error) {
30
+ throwPluginError(
31
+ error instanceof Error ? error.message : 'An error occurred while creating a new documentation version.',
32
+ )
33
+ }
34
+
35
+ try {
36
+ const versionedSidebar = await getVersionedSidebar(config, starlightConfig.sidebar, astroConfig.srcDir)
37
+
38
+ updateConfig({
39
+ components: overrideComponents(
40
+ starlightConfig,
41
+ [
42
+ { name: 'ThemeSelect', fallback: 'VersionSelect' },
43
+ { name: 'Sidebar', fallback: 'VersionSidebar' },
44
+ { name: 'Pagination', fallback: 'VersionPagination' },
45
+ { name: 'Search', fallback: 'VersionSearch' },
46
+ { name: 'PageTitle', fallback: 'VersionNotice' },
47
+ { name: 'Banner', fallback: 'VersionBanner' },
48
+ ],
49
+ logger,
50
+ ),
51
+ sidebar: versionedSidebar,
52
+ })
53
+ } catch (error) {
54
+ throwPluginError(
55
+ error instanceof Error ? error.message : 'An error occurred while generating versioned sidebars.',
56
+ )
57
+ }
58
+
59
+ addIntegration({
60
+ name: 'starlight-versions-integration',
61
+ hooks: {
62
+ 'astro:config:setup': ({ updateConfig }) => {
63
+ updateConfig({ vite: { plugins: [vitePluginStarlightVersions(config)] } })
64
+ },
65
+ },
66
+ })
67
+ },
68
+ },
69
+ }
70
+ }
package/libs/config.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { z } from 'astro/zod'
2
+
3
+ import { VersionBaseSchema, VersionSchema } from './versions'
4
+
5
+ export const StarlightVersionsConfigSchema = z
6
+ .object({
7
+ /**
8
+ * Options related to the current version of the documentation.
9
+ */
10
+ current: z
11
+ .object({
12
+ /**
13
+ * The label used in the UI to represent the current version.
14
+ *
15
+ * @default 'Latest'
16
+ */
17
+ label: z.string().default('Latest'),
18
+ })
19
+ .merge(VersionBaseSchema)
20
+ .default({}),
21
+ /**
22
+ * A list of all archived versions of the documentation.
23
+ */
24
+ versions: z.array(VersionSchema).refine((value) => value.length > 0, {
25
+ message: 'At least one version of the documentation must be defined.',
26
+ }),
27
+ })
28
+ .transform((value) => ({
29
+ ...value,
30
+ versionsBySlug: Object.fromEntries(value.versions.map((version) => [version.slug, version])),
31
+ }))
32
+
33
+ export type StarlightVersionsUserConfig = z.input<typeof StarlightVersionsConfigSchema>
34
+ export type StarlightVersionsConfig = z.output<typeof StarlightVersionsConfigSchema>
package/libs/fs.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { PathLike } from 'node:fs'
2
+ import fs from 'node:fs/promises'
3
+
4
+ import { ensureTrailingSlash } from './path'
5
+
6
+ export function listDirectory(directory: URL) {
7
+ return fs.readdir(directory, { withFileTypes: true })
8
+ }
9
+
10
+ export async function copyDirectory(sourceDir: URL, destDir: URL, callback: CopyDirectoryCallback, isRoot = true) {
11
+ const dirEntries = await listDirectory(sourceDir)
12
+
13
+ if (isRoot && dirEntries.length === 0) {
14
+ throw new Error(
15
+ `Failed to copy the empty directory ('${sourceDir.pathname}') to the destination ('${destDir.pathname}').`,
16
+ )
17
+ }
18
+
19
+ await ensureDirectory(destDir)
20
+
21
+ for (const entry of dirEntries) {
22
+ if (entry.isDirectory()) {
23
+ const source = new URL(ensureTrailingSlash(entry.name), sourceDir)
24
+ let dest = new URL(ensureTrailingSlash(entry.name), destDir)
25
+
26
+ const skipOrDest = await callback({ type: 'directory', name: entry.name, isRoot, dest, source })
27
+ if (skipOrDest === true) continue
28
+ if (skipOrDest instanceof URL) dest = skipOrDest
29
+
30
+ await ensureDirectory(dest)
31
+ await copyDirectory(source, dest, callback, false)
32
+ } else if (entry.isFile()) {
33
+ const source = new URL(entry.name, sourceDir)
34
+ const content = await fs.readFile(source, 'utf8')
35
+
36
+ const updatedContent = await callback({ type: 'file', content, url: source })
37
+
38
+ if (typeof updatedContent !== 'string') continue
39
+
40
+ await fs.writeFile(new URL(entry.name, destDir), updatedContent)
41
+ }
42
+ }
43
+ }
44
+
45
+ export function copyFile(source: URL, dest: URL) {
46
+ return fs.cp(source, dest, { force: true })
47
+ }
48
+
49
+ export function writeJSONFile(file: PathLike, data: unknown) {
50
+ return fs.writeFile(file, JSON.stringify(data, null, 2))
51
+ }
52
+
53
+ export async function readJSONFile<T extends object>(file: PathLike): Promise<T> {
54
+ const content = await fs.readFile(file, 'utf8')
55
+
56
+ return JSON.parse(content) as T
57
+ }
58
+
59
+ export function ensureDirectory(directory: PathLike) {
60
+ return fs.mkdir(directory, { recursive: true })
61
+ }
62
+
63
+ export type CopyDirectoryCallback = (
64
+ entry:
65
+ | { type: 'file'; content: string; url: URL }
66
+ | { type: 'directory'; name: string; isRoot: boolean; source: URL; dest: URL },
67
+ ) => Promise<string | boolean | URL>
@@ -0,0 +1,246 @@
1
+ import assert from 'node:assert'
2
+
3
+ import type { Image, Link, Root } from 'mdast'
4
+ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
5
+ import type { MdxjsEsm } from 'mdast-util-mdxjs-esm'
6
+ import { remark } from 'remark'
7
+ import remarkFrontmatter from 'remark-frontmatter'
8
+ import remarkMdx from 'remark-mdx'
9
+ import { CONTINUE, SKIP, visit } from 'unist-util-visit'
10
+ import type { VFile } from 'vfile'
11
+
12
+ import { isAbsoluteLink, stripLeadingSlash, stripTrailingSlash } from './path'
13
+ import { getFrontmatterNodeValue, parseFrontmatter } from './starlight'
14
+ import type { Version, VersionAsset } from './versions'
15
+
16
+ const importPathRegex = /(from ?["'])([^"']*)(["'];?\s?)$/gm
17
+ const astroAssetRegex = /\.(png|jpg|jpeg|tiff|webp|gif|svg|avif)$/i
18
+
19
+ const mediaElements = new Set(['img', 'source', 'Image', 'audio', 'video'])
20
+
21
+ const processor = remark().use(remarkMdx).use(remarkFrontmatter).use(remarkStarlightVersions)
22
+
23
+ export async function transformMarkdown(markdown: string, context: TransformContext) {
24
+ const file = await processor.process({
25
+ data: { ...context },
26
+ value: markdown,
27
+ })
28
+
29
+ return {
30
+ assets: file.data.assets,
31
+ content: String(file),
32
+ }
33
+ }
34
+
35
+ export function remarkStarlightVersions() {
36
+ return function transformer(tree: Root, file: VFile) {
37
+ visit(tree, (node) => {
38
+ switch (node.type) {
39
+ case 'link': {
40
+ return handleLinks(node, file)
41
+ }
42
+ case 'mdxJsxTextElement': {
43
+ if (node.name === 'a') {
44
+ return handleLinkElements(node, file)
45
+ }
46
+
47
+ return CONTINUE
48
+ }
49
+ case 'mdxjsEsm': {
50
+ return handleImports(node, file)
51
+ }
52
+ case 'mdxJsxFlowElement': {
53
+ if (node.name && mediaElements.has(node.name)) {
54
+ return handleMediaElements(node, file)
55
+ }
56
+
57
+ return CONTINUE
58
+ }
59
+ case 'image': {
60
+ return handleImages(node, file)
61
+ }
62
+ default: {
63
+ return CONTINUE
64
+ }
65
+ }
66
+ })
67
+
68
+ handleFrontmatter(tree, file)
69
+ }
70
+ }
71
+
72
+ function handleFrontmatter(tree: Root, file: VFile) {
73
+ // The frontmatter is always at the root of the tree.
74
+ for (const node of tree.children) {
75
+ if (node.type !== 'yaml') {
76
+ continue
77
+ }
78
+
79
+ const frontmatter = parseFrontmatter(node.value)
80
+
81
+ if (frontmatter.slug) {
82
+ frontmatter.slug = stripLeadingSlash(
83
+ stripTrailingSlash(`${file.data.version?.slug}/${stripTrailingSlash(frontmatter.slug)}`),
84
+ )
85
+ } else if (file.data.slug && file.data.version) {
86
+ if (file.data.slug === '/') {
87
+ frontmatter.slug = file.data.version.slug
88
+ } else {
89
+ const segments = file.data.slug.split('/')
90
+
91
+ if (file.data.locale && segments[0] === file.data.locale) {
92
+ segments.splice(1, 0, file.data.version.slug)
93
+ } else {
94
+ segments.splice(0, 0, file.data.version.slug)
95
+ }
96
+
97
+ frontmatter.slug = segments.join('/')
98
+ }
99
+ }
100
+
101
+ if (typeof frontmatter.prev === 'object' && frontmatter.prev.link?.startsWith('/')) {
102
+ frontmatter.prev.link = addVersionToLink(frontmatter.prev.link, file)
103
+ }
104
+
105
+ if (typeof frontmatter.next === 'object' && frontmatter.next.link?.startsWith('/')) {
106
+ frontmatter.next.link = addVersionToLink(frontmatter.next.link, file)
107
+ }
108
+
109
+ if (frontmatter.hero?.actions) {
110
+ for (const action of frontmatter.hero.actions) {
111
+ if (action.link.startsWith('/')) {
112
+ action.link = addVersionToLink(action.link, file)
113
+ }
114
+ }
115
+ }
116
+
117
+ if (frontmatter.hero?.image) {
118
+ if (frontmatter.hero.image.file?.startsWith('../')) {
119
+ frontmatter.hero.image.file = addVersionToAstroAsset(frontmatter.hero.image.file, file)
120
+ }
121
+ if (frontmatter.hero.image.dark?.startsWith('../')) {
122
+ frontmatter.hero.image.dark = addVersionToAstroAsset(frontmatter.hero.image.dark, file)
123
+ }
124
+ if (frontmatter.hero.image.light?.startsWith('../')) {
125
+ frontmatter.hero.image.light = addVersionToAstroAsset(frontmatter.hero.image.light, file)
126
+ }
127
+ }
128
+
129
+ node.value = getFrontmatterNodeValue(frontmatter)
130
+
131
+ break
132
+ }
133
+ }
134
+
135
+ function handleLinks(node: Link, file: VFile) {
136
+ if (!isPublicAsset(node.url)) return SKIP
137
+
138
+ node.url = addVersionToLink(node.url, file)
139
+
140
+ return SKIP
141
+ }
142
+
143
+ function handleLinkElements(node: MdxJsxTextElement, file: VFile) {
144
+ const href = node.attributes.find((attribute) => attribute.type === 'mdxJsxAttribute' && attribute.name === 'href')
145
+
146
+ if (!href || typeof href.value !== 'string' || !isPublicAsset(href.value)) return CONTINUE
147
+
148
+ href.value = addVersionToLink(href.value, file)
149
+
150
+ return CONTINUE
151
+ }
152
+
153
+ function handleImages(node: Image, file: VFile) {
154
+ if (isAbsoluteLink(node.url)) return SKIP
155
+
156
+ node.url = (isPublicAsset(node.url) ? addVersionToPublicAsset : addVersionToAstroAsset)(node.url, file)
157
+
158
+ return SKIP
159
+ }
160
+
161
+ function handleMediaElements(node: MdxJsxFlowElement, file: VFile) {
162
+ const srcOrSrcset = node.attributes.find(
163
+ (attribute) => attribute.type === 'mdxJsxAttribute' && (attribute.name === 'src' || attribute.name === 'srcset'),
164
+ )
165
+
166
+ if (!srcOrSrcset || typeof srcOrSrcset.value !== 'string' || !isPublicAsset(srcOrSrcset.value)) return CONTINUE
167
+
168
+ srcOrSrcset.value = addVersionToPublicAsset(srcOrSrcset.value, file)
169
+
170
+ return SKIP
171
+ }
172
+
173
+ function handleImports(node: MdxjsEsm, file: VFile) {
174
+ node.value = node.value.replaceAll(importPathRegex, (match, start, importPath: string, end) => {
175
+ if (!importPath.startsWith('../')) return match
176
+
177
+ if (isAstroAsset(importPath)) {
178
+ return `${start}${addVersionToAstroAsset(importPath, file)}${end}`
179
+ }
180
+
181
+ return `${start}../${importPath}${end}`
182
+ })
183
+
184
+ return SKIP
185
+ }
186
+
187
+ function addVersionToLink(link: string, file: VFile) {
188
+ assert(file.data.version, 'A version must be provided to add a version to an Astro asset.')
189
+
190
+ const segments = link.split('/')
191
+ segments.splice(1, 0, file.data.version.slug)
192
+
193
+ return segments.join('/')
194
+ }
195
+
196
+ function addVersionToAstroAsset(asset: string, file: VFile) {
197
+ assert(file.data.version, 'A version must be provided to add a version to an Astro asset.')
198
+
199
+ const source = new URL(asset, file.data.url)
200
+
201
+ const segments = asset.split('/')
202
+ segments.splice(-1, 0, file.data.version.slug)
203
+
204
+ addVersionAsset(file, { source, dest: new URL(segments.join('/'), file.data.url) })
205
+
206
+ segments.splice(0, 0, '..')
207
+
208
+ return segments.join('/')
209
+ }
210
+
211
+ function addVersionToPublicAsset(asset: string, file: VFile) {
212
+ assert(file.data.version, 'A version must be provided to add a version to an public asset.')
213
+
214
+ const source = new URL(asset, file.data.url)
215
+
216
+ const segments = asset.split('/')
217
+ segments.splice(-1, 0, file.data.version.slug)
218
+
219
+ addVersionAsset(file, { source, dest: new URL(segments.join('/'), file.data.url) })
220
+
221
+ return segments.join('/')
222
+ }
223
+
224
+ function addVersionAsset(file: VFile, asset: VersionAsset) {
225
+ file.data.assets?.push(asset)
226
+ }
227
+
228
+ function isAstroAsset(asset: string) {
229
+ return astroAssetRegex.test(asset)
230
+ }
231
+
232
+ function isPublicAsset(asset: string) {
233
+ return asset.startsWith('/')
234
+ }
235
+
236
+ export interface TransformContext {
237
+ assets: VersionAsset[]
238
+ locale: string | undefined
239
+ slug: string
240
+ url: URL
241
+ version: Version
242
+ }
243
+
244
+ declare module 'vfile' {
245
+ interface DataMap extends TransformContext {}
246
+ }
package/libs/path.ts ADDED
@@ -0,0 +1,49 @@
1
+ import path from 'node:path'
2
+
3
+ import { slug } from 'github-slugger'
4
+
5
+ const absoluteLinkRegex = /^https?:\/\//
6
+
7
+ export function ensureTrailingSlash(filePath: string): string {
8
+ if (filePath.endsWith('/')) {
9
+ return filePath
10
+ }
11
+
12
+ return `${filePath}/`
13
+ }
14
+
15
+ export function stripLeadingSlash(filePath: string) {
16
+ if (!filePath.startsWith('/')) {
17
+ return filePath
18
+ }
19
+
20
+ return filePath.slice(1)
21
+ }
22
+
23
+ export function stripTrailingSlash(filePath: string) {
24
+ if (!filePath.endsWith('/')) {
25
+ return filePath
26
+ }
27
+
28
+ return filePath.slice(0, -1)
29
+ }
30
+
31
+ export function slugifyPath(filePath: string): string {
32
+ const segments = stripExtension(filePath).split('/')
33
+
34
+ return segments.map((segment) => slug(segment)).join('/')
35
+ }
36
+
37
+ export function getExtension(filePath: string) {
38
+ return path.extname(filePath)
39
+ }
40
+
41
+ export function stripExtension(filePath: string) {
42
+ const parsedPath = path.parse(filePath)
43
+
44
+ return path.posix.join(parsedPath.dir, parsedPath.name)
45
+ }
46
+
47
+ export function isAbsoluteLink(link: string) {
48
+ return absoluteLinkRegex.test(link)
49
+ }
package/libs/plugin.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { AstroIntegrationLogger } from 'astro'
2
+ import { AstroError } from 'astro/errors'
3
+
4
+ import type { StarlightUserConfig } from './starlight'
5
+
6
+ export function throwPluginError(message: string): never {
7
+ throw new AstroError(
8
+ message,
9
+ `See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-versions/issues/new/choose`,
10
+ )
11
+ }
12
+
13
+ export function overrideComponents(
14
+ starlightConfig: StarlightUserConfig,
15
+ overrides: ComponentOverride[],
16
+ logger: AstroIntegrationLogger,
17
+ ): StarlightUserConfig['components'] {
18
+ const components = { ...starlightConfig.components }
19
+ for (const { name, fallback } of overrides) {
20
+ if (starlightConfig.components?.[name]) {
21
+ logger.warn(`A \`<${name}>\` component override is already defined in your Starlight configuration.`)
22
+ logger.warn(
23
+ `To use \`starlight-versions\`, either remove this override or manually render the content from \`starlight-versions/components/${fallback}.astro\`.`,
24
+ )
25
+ continue
26
+ }
27
+ components[name] = `starlight-versions/overrides/${name}.astro`
28
+ }
29
+
30
+ return components
31
+ }
32
+
33
+ interface ComponentOverride {
34
+ name: keyof NonNullable<StarlightUserConfig['components']>
35
+ fallback: string
36
+ }