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.
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/components/VersionBanner.astro +38 -0
- package/components/VersionNotice.astro +48 -0
- package/components/VersionPagination.astro +34 -0
- package/components/VersionSearch.astro +486 -0
- package/components/VersionSelect.astro +112 -0
- package/components/VersionSidebar.astro +15 -0
- package/env.d.ts +10 -0
- package/index.ts +70 -0
- package/libs/config.ts +34 -0
- package/libs/fs.ts +67 -0
- package/libs/markdown.ts +246 -0
- package/libs/path.ts +49 -0
- package/libs/plugin.ts +36 -0
- package/libs/starlight.ts +88 -0
- package/libs/versions.ts +355 -0
- package/libs/vite.ts +21 -0
- package/overrides/Banner.astro +9 -0
- package/overrides/PageTitle.astro +9 -0
- package/overrides/Pagination.astro +7 -0
- package/overrides/Search.astro +7 -0
- package/overrides/Sidebar.astro +7 -0
- package/overrides/ThemeSelect.astro +9 -0
- package/package.json +71 -0
- package/schema.ts +13 -0
- package/virtual.d.ts +23 -0
|
@@ -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
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>
|
package/libs/markdown.ts
ADDED
|
@@ -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
|
+
}
|