@astrojs/starlight 0.0.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/404.astro +51 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/components/ContentPanel.astro +25 -0
- package/components/EditLink.astro +23 -0
- package/components/FallbackContentNotice.astro +27 -0
- package/components/HeadSEO.astro +60 -0
- package/components/Header.astro +62 -0
- package/components/Icon.astro +36 -0
- package/components/Icons.ts +43 -0
- package/components/LanguageSelect.astro +51 -0
- package/components/LastUpdated.astro +41 -0
- package/components/MarkdownContent.astro +112 -0
- package/components/MobileMenuToggle.astro +89 -0
- package/components/PrevNextLinks.astro +77 -0
- package/components/RightSidebarPanel.astro +38 -0
- package/components/Search.astro +296 -0
- package/components/Select.astro +80 -0
- package/components/Sidebar.astro +34 -0
- package/components/SidebarSublist.astro +78 -0
- package/components/SkipLink.astro +18 -0
- package/components/TableOfContents/TableOfContentsList.astro +35 -0
- package/components/TableOfContents/generateToC.ts +66 -0
- package/components/TableOfContents.astro +17 -0
- package/components/ThemeProvider.astro +50 -0
- package/components/ThemeSelect.astro +88 -0
- package/index.astro +102 -0
- package/index.ts +104 -0
- package/integrations/asides.ts +164 -0
- package/layout/PageFrame.astro +88 -0
- package/layout/TwoColumnContent.astro +42 -0
- package/package.json +42 -0
- package/schema.ts +23 -0
- package/style/asides.css +49 -0
- package/style/props.css +197 -0
- package/style/reset.css +43 -0
- package/style/shiki.css +13 -0
- package/style/util.css +32 -0
- package/types.ts +1 -0
- package/utils/git.ts +96 -0
- package/utils/localizedUrl.ts +32 -0
- package/utils/navigation.ts +232 -0
- package/utils/routing.ts +109 -0
- package/utils/slugs.ts +91 -0
- package/utils/user-config.ts +250 -0
- package/virtual.d.ts +9 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { basename, dirname } from 'node:path';
|
|
2
|
+
import config from 'virtual:starlight/user-config';
|
|
3
|
+
import { slugToPathname } from './slugs';
|
|
4
|
+
import { Route, getLocaleRoutes, routes } from './routing';
|
|
5
|
+
import type {
|
|
6
|
+
AutoSidebarGroup,
|
|
7
|
+
SidebarItem,
|
|
8
|
+
SidebarLinkItem,
|
|
9
|
+
} from './user-config';
|
|
10
|
+
|
|
11
|
+
export interface Link {
|
|
12
|
+
type: 'link';
|
|
13
|
+
label: string;
|
|
14
|
+
href: string;
|
|
15
|
+
isCurrent: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Group {
|
|
19
|
+
type: 'group';
|
|
20
|
+
label: string;
|
|
21
|
+
entries: (Link | Group)[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SidebarEntry = Link | Group;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A representation of the route structure. For each object entry:
|
|
28
|
+
* if it’s a folder, the key is the directory name, and value is the directory
|
|
29
|
+
* content; if it’s a route entry, the key is the last segment of the route, and value
|
|
30
|
+
* is the entry’s full slug.
|
|
31
|
+
*/
|
|
32
|
+
interface Dir {
|
|
33
|
+
[item: string]: Dir | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Convert an item in a user’s sidebar config to a sidebar entry. */
|
|
37
|
+
function configItemToEntry(
|
|
38
|
+
item: SidebarItem,
|
|
39
|
+
currentPathname: string,
|
|
40
|
+
locale: string | undefined,
|
|
41
|
+
routes: Route[]
|
|
42
|
+
): SidebarEntry {
|
|
43
|
+
if ('link' in item) {
|
|
44
|
+
return linkFromConfig(item, locale, currentPathname);
|
|
45
|
+
} else if ('autogenerate' in item) {
|
|
46
|
+
return groupFromAutogenerateConfig(item, locale, routes, currentPathname);
|
|
47
|
+
} else {
|
|
48
|
+
return {
|
|
49
|
+
type: 'group',
|
|
50
|
+
label: item.label,
|
|
51
|
+
entries: item.items.map((i) =>
|
|
52
|
+
configItemToEntry(i, currentPathname, locale, routes)
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Autogenerate a group of links from a user’s sidebar config. */
|
|
59
|
+
function groupFromAutogenerateConfig(
|
|
60
|
+
item: AutoSidebarGroup,
|
|
61
|
+
locale: string | undefined,
|
|
62
|
+
routes: Route[],
|
|
63
|
+
currentPathname: string
|
|
64
|
+
): Group {
|
|
65
|
+
const { directory } = item.autogenerate;
|
|
66
|
+
const localeDir = locale ? locale + '/' + directory : directory;
|
|
67
|
+
const dirDocs = routes.filter((doc) => doc.slug.startsWith(localeDir));
|
|
68
|
+
const tree = treeify(dirDocs, localeDir);
|
|
69
|
+
return {
|
|
70
|
+
type: 'group',
|
|
71
|
+
label: item.label,
|
|
72
|
+
entries: sidebarFromDir(tree, currentPathname, locale),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Check if a string starts with one of `http://` or `https://`. */
|
|
77
|
+
const isAbsolute = (link: string) => /^https?:\/\//.test(link);
|
|
78
|
+
|
|
79
|
+
/** Ensure the passed path starts and ends with trailing slashes. */
|
|
80
|
+
function ensureLeadingAndTrailingSlashes(href: string): string {
|
|
81
|
+
if (href[0] !== '/') href = '/' + href;
|
|
82
|
+
if (href[href.length - 1] !== '/') href += '/';
|
|
83
|
+
return href;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Create a link entry from a user config object. */
|
|
87
|
+
function linkFromConfig(
|
|
88
|
+
item: SidebarLinkItem,
|
|
89
|
+
locale: string | undefined,
|
|
90
|
+
currentPathname: string
|
|
91
|
+
) {
|
|
92
|
+
let href = item.link;
|
|
93
|
+
if (!isAbsolute(href)) {
|
|
94
|
+
href = ensureLeadingAndTrailingSlashes(href);
|
|
95
|
+
// Inject current locale into link.
|
|
96
|
+
if (locale) href = '/' + locale + href;
|
|
97
|
+
}
|
|
98
|
+
return makeLink(href, item.label, currentPathname);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Create a link entry. */
|
|
102
|
+
function makeLink(href: string, label: string, currentPathname: string): Link {
|
|
103
|
+
if (!isAbsolute(href)) {
|
|
104
|
+
href = ensureLeadingAndTrailingSlashes(href);
|
|
105
|
+
/** Base URL with trailing `/` stripped. */
|
|
106
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
|
|
107
|
+
if (base) href = base + href;
|
|
108
|
+
}
|
|
109
|
+
const isCurrent = href === currentPathname;
|
|
110
|
+
return { type: 'link', label, href, isCurrent };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get the segments leading to a page. */
|
|
114
|
+
function getBreadcrumbs(slug: string, baseDir: string): string[] {
|
|
115
|
+
// Ensure base directory ends in a trailing slash.
|
|
116
|
+
if (!baseDir.endsWith('/')) baseDir += '/';
|
|
117
|
+
// Strip base directory from slug if present.
|
|
118
|
+
const relativeSlug = slug.startsWith(baseDir)
|
|
119
|
+
? slug.replace(baseDir, '')
|
|
120
|
+
: slug;
|
|
121
|
+
let dir = dirname(relativeSlug);
|
|
122
|
+
// Return no breadcrumbs for items in the root directory.
|
|
123
|
+
if (dir === '.') return [];
|
|
124
|
+
return dir.split('/');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Turn a flat array of routes into a tree structure. */
|
|
128
|
+
function treeify(routes: Route[], baseDir: string): Dir {
|
|
129
|
+
const treeRoot: Dir = {};
|
|
130
|
+
routes.forEach((doc) => {
|
|
131
|
+
const breadcrumbs = getBreadcrumbs(doc.slug, baseDir);
|
|
132
|
+
|
|
133
|
+
// Walk down the route’s path to generate the tree.
|
|
134
|
+
let currentDir = treeRoot;
|
|
135
|
+
breadcrumbs.forEach((dir) => {
|
|
136
|
+
// Create new folder if needed.
|
|
137
|
+
if (typeof currentDir[dir] === 'undefined') currentDir[dir] = {};
|
|
138
|
+
// Go into the subdirectory.
|
|
139
|
+
currentDir = currentDir[dir] as Dir;
|
|
140
|
+
});
|
|
141
|
+
// We’ve walked through the path. Register the route in this directory.
|
|
142
|
+
currentDir[basename(doc.slug)] = doc.slug;
|
|
143
|
+
});
|
|
144
|
+
return treeRoot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Create a link entry for a given content collection entry. */
|
|
148
|
+
function linkFromSlug(slug: string, currentPathname: string): Link {
|
|
149
|
+
const doc = routes.find((doc) => doc.slug === slug)!;
|
|
150
|
+
return makeLink(
|
|
151
|
+
slugToPathname(doc.slug),
|
|
152
|
+
doc.entry.data.title,
|
|
153
|
+
currentPathname
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Create a group entry for a given content collection directory. */
|
|
158
|
+
function groupFromDir(
|
|
159
|
+
dir: Dir,
|
|
160
|
+
fullPath: string,
|
|
161
|
+
dirName: string,
|
|
162
|
+
currentPathname: string,
|
|
163
|
+
locale: string | undefined
|
|
164
|
+
): Group {
|
|
165
|
+
const entries = Object.entries(dir).map(([key, dirOrSlug]) =>
|
|
166
|
+
dirToItem(dirOrSlug, `${fullPath}/${key}`, key, currentPathname, locale)
|
|
167
|
+
);
|
|
168
|
+
return {
|
|
169
|
+
type: 'group',
|
|
170
|
+
label: dirName,
|
|
171
|
+
entries,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Create a sidebar entry for a directory or content slug. */
|
|
176
|
+
function dirToItem(
|
|
177
|
+
dirOrSlug: Dir[string],
|
|
178
|
+
fullPath: string,
|
|
179
|
+
dirName: string,
|
|
180
|
+
currentPathname: string,
|
|
181
|
+
locale: string | undefined
|
|
182
|
+
): SidebarEntry {
|
|
183
|
+
return typeof dirOrSlug === 'string'
|
|
184
|
+
? linkFromSlug(dirOrSlug, currentPathname)
|
|
185
|
+
: groupFromDir(dirOrSlug, fullPath, dirName, currentPathname, locale);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Create a sidebar entry for a given content directory. */
|
|
189
|
+
function sidebarFromDir(
|
|
190
|
+
tree: Dir,
|
|
191
|
+
currentPathname: string,
|
|
192
|
+
locale: string | undefined
|
|
193
|
+
) {
|
|
194
|
+
return Object.entries(tree).map(([key, dirOrSlug]) =>
|
|
195
|
+
dirToItem(dirOrSlug, key, key, currentPathname, locale)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Get the sidebar for the current page. */
|
|
200
|
+
export function getSidebar(
|
|
201
|
+
pathname: string,
|
|
202
|
+
locale: string | undefined
|
|
203
|
+
): SidebarEntry[] {
|
|
204
|
+
const routes = getLocaleRoutes(locale);
|
|
205
|
+
if (config.sidebar) {
|
|
206
|
+
return config.sidebar.map((group) =>
|
|
207
|
+
configItemToEntry(group, pathname, locale, routes)
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
const tree = treeify(routes, locale || '');
|
|
211
|
+
return sidebarFromDir(tree, pathname, locale);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Turn the nested tree structure of a sidebar into a flat list of all the links. */
|
|
216
|
+
function flattenSidebar(sidebar: SidebarEntry[]): Link[] {
|
|
217
|
+
return sidebar.flatMap((entry) =>
|
|
218
|
+
entry.type === 'group' ? flattenSidebar(entry.entries) : entry
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Get previous/next pages in the sidebar if there are any. */
|
|
223
|
+
export function getPrevNextLinks(sidebar: SidebarEntry[]): {
|
|
224
|
+
prev: Link | undefined;
|
|
225
|
+
next: Link | undefined;
|
|
226
|
+
} {
|
|
227
|
+
const entries = flattenSidebar(sidebar);
|
|
228
|
+
const currentIndex = entries.findIndex((entry) => entry.isCurrent);
|
|
229
|
+
const prev = entries[currentIndex - 1];
|
|
230
|
+
const next = entries[currentIndex + 1];
|
|
231
|
+
return { prev, next };
|
|
232
|
+
}
|
package/utils/routing.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GetStaticPathsItem } from 'astro';
|
|
2
|
+
import { CollectionEntry, getCollection } from 'astro:content';
|
|
3
|
+
import config from 'virtual:starlight/user-config';
|
|
4
|
+
import {
|
|
5
|
+
LocaleData,
|
|
6
|
+
localizedSlug,
|
|
7
|
+
slugToLocaleData,
|
|
8
|
+
slugToParam,
|
|
9
|
+
} from './slugs';
|
|
10
|
+
|
|
11
|
+
export interface Route extends LocaleData {
|
|
12
|
+
entry: CollectionEntry<'docs'>;
|
|
13
|
+
entryMeta: LocaleData;
|
|
14
|
+
slug: string;
|
|
15
|
+
isFallback?: true;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Path extends GetStaticPathsItem {
|
|
20
|
+
params: { slug: string | undefined };
|
|
21
|
+
props: Route;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** All entries in the docs content collection. */
|
|
25
|
+
const docs = await getCollection('docs');
|
|
26
|
+
|
|
27
|
+
function getRoutes(): Route[] {
|
|
28
|
+
const routes: Route[] = docs.map((entry) => ({
|
|
29
|
+
entry,
|
|
30
|
+
slug: entry.slug,
|
|
31
|
+
entryMeta: slugToLocaleData(entry.slug),
|
|
32
|
+
...slugToLocaleData(entry.slug),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// In multilingual sites, add required fallback routes.
|
|
36
|
+
if (config.isMultilingual) {
|
|
37
|
+
/** Entries in the docs content collection for the default locale. */
|
|
38
|
+
const defaultLocaleDocs = getLocaleDocs(
|
|
39
|
+
config.defaultLocale?.locale === 'root'
|
|
40
|
+
? undefined
|
|
41
|
+
: config.defaultLocale?.locale
|
|
42
|
+
);
|
|
43
|
+
for (const key in config.locales) {
|
|
44
|
+
if (key === config.defaultLocale.locale) continue;
|
|
45
|
+
const localeConfig = config.locales[key];
|
|
46
|
+
if (!localeConfig) continue;
|
|
47
|
+
const locale = key === 'root' ? undefined : key;
|
|
48
|
+
const localeDocs = getLocaleDocs(locale);
|
|
49
|
+
for (const fallback of defaultLocaleDocs) {
|
|
50
|
+
const slug = localizedSlug(fallback.slug, locale);
|
|
51
|
+
const doesNotNeedFallback = localeDocs.some((doc) => doc.slug === slug);
|
|
52
|
+
if (doesNotNeedFallback) continue;
|
|
53
|
+
routes.push({
|
|
54
|
+
entry: fallback,
|
|
55
|
+
slug,
|
|
56
|
+
isFallback: true,
|
|
57
|
+
lang: localeConfig.lang || 'en',
|
|
58
|
+
locale,
|
|
59
|
+
dir: localeConfig.dir,
|
|
60
|
+
entryMeta: slugToLocaleData(fallback.slug),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return routes;
|
|
67
|
+
}
|
|
68
|
+
export const routes = getRoutes();
|
|
69
|
+
|
|
70
|
+
function getPaths(): Path[] {
|
|
71
|
+
return routes.map((route) => ({
|
|
72
|
+
params: { slug: slugToParam(route.slug) },
|
|
73
|
+
props: route,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
export const paths = getPaths();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get all routes for a specific locale.
|
|
80
|
+
* A locale of `undefined` is treated as the “root” locale, if configured.
|
|
81
|
+
*/
|
|
82
|
+
export function getLocaleRoutes(locale: string | undefined): Route[] {
|
|
83
|
+
return filterByLocale(routes, locale);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get all entries in the docs content collection for a specific locale.
|
|
88
|
+
* A locale of `undefined` is treated as the “root” locale, if configured.
|
|
89
|
+
*/
|
|
90
|
+
function getLocaleDocs(locale: string | undefined): CollectionEntry<'docs'>[] {
|
|
91
|
+
return filterByLocale(docs, locale);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Filter an array to find items whose slug matches the passed locale. */
|
|
95
|
+
function filterByLocale<T extends { slug: string }>(
|
|
96
|
+
items: T[],
|
|
97
|
+
locale: string | undefined
|
|
98
|
+
): T[] {
|
|
99
|
+
if (config.locales) {
|
|
100
|
+
if (locale && locale in config.locales) {
|
|
101
|
+
return items.filter((i) => i.slug.startsWith(locale + '/'));
|
|
102
|
+
} else if (config.locales.root) {
|
|
103
|
+
const langKeys = Object.keys(config.locales).filter((k) => k !== 'root');
|
|
104
|
+
const isLangDir = new RegExp(`^(${langKeys.join('|')})/`);
|
|
105
|
+
return items.filter((i) => !isLangDir.test(i.slug));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return items;
|
|
109
|
+
}
|
package/utils/slugs.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CollectionEntry } from 'astro:content';
|
|
2
|
+
import config from 'virtual:starlight/user-config';
|
|
3
|
+
|
|
4
|
+
export interface LocaleData {
|
|
5
|
+
/** Writing direction. */
|
|
6
|
+
dir: 'ltr' | 'rtl';
|
|
7
|
+
/** BCP-47 language tag. */
|
|
8
|
+
lang: string;
|
|
9
|
+
/** The base path at which a language is served. `undefined` for root locale slugs. */
|
|
10
|
+
locale: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the “locale” of a slug. This is the base path at which a language is served.
|
|
15
|
+
* For example, if French docs are in `src/content/docs/french/`, the locale is `french`.
|
|
16
|
+
* Root locale slugs will return `undefined`.
|
|
17
|
+
* @param slug A collection entry slug
|
|
18
|
+
*/
|
|
19
|
+
function slugToLocale(
|
|
20
|
+
slug: CollectionEntry<'docs'>['slug']
|
|
21
|
+
): string | undefined {
|
|
22
|
+
const locales = Object.keys(config.locales || {});
|
|
23
|
+
const baseSegment = slug.split('/')[0];
|
|
24
|
+
if (baseSegment && locales.includes(baseSegment)) return baseSegment;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get locale information for a given slug. */
|
|
29
|
+
export function slugToLocaleData(
|
|
30
|
+
slug: CollectionEntry<'docs'>['slug']
|
|
31
|
+
): LocaleData {
|
|
32
|
+
const locale = slugToLocale(slug);
|
|
33
|
+
return { dir: localeToDir(locale), lang: localeToLang(locale), locale };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the BCP-47 language tag for the given locale.
|
|
38
|
+
* @param locale Locale string or `undefined` for the root locale.
|
|
39
|
+
*/
|
|
40
|
+
function localeToLang(locale: string | undefined): string {
|
|
41
|
+
const lang = locale
|
|
42
|
+
? config.locales?.[locale]?.lang
|
|
43
|
+
: config.locales?.root?.lang;
|
|
44
|
+
return lang || 'en';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the configured writing direction for the given locale.
|
|
49
|
+
* @param locale Locale string or `undefined` for the root locale.
|
|
50
|
+
*/
|
|
51
|
+
function localeToDir(locale: string | undefined): 'ltr' | 'rtl' {
|
|
52
|
+
const dir = locale
|
|
53
|
+
? config.locales?.[locale]?.dir
|
|
54
|
+
: config.locales?.root?.dir;
|
|
55
|
+
return dir || 'ltr';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function slugToParam(slug: string): string | undefined {
|
|
59
|
+
return slug === 'index'
|
|
60
|
+
? undefined
|
|
61
|
+
: slug.endsWith('/index')
|
|
62
|
+
? slug.replace('/index', '')
|
|
63
|
+
: slug;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function slugToPathname(slug: string): string {
|
|
67
|
+
const param = slugToParam(slug);
|
|
68
|
+
return param ? '/' + param + '/' : '/';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convert a slug to a different locale.
|
|
73
|
+
* For example, passing a slug of `en/home` and a locale of `fr` results in `fr/home`.
|
|
74
|
+
* An undefined locale is treated as the root locale, resulting in `home`
|
|
75
|
+
* @param slug A collection entry slug
|
|
76
|
+
* @param locale The target locale
|
|
77
|
+
* @example
|
|
78
|
+
* localizedSlug('en/home', 'fr') // => 'fr/home'
|
|
79
|
+
* localizedSlug('en/home', undefined) // => 'home'
|
|
80
|
+
*/
|
|
81
|
+
export function localizedSlug(
|
|
82
|
+
slug: CollectionEntry<'docs'>['slug'],
|
|
83
|
+
locale: string | undefined
|
|
84
|
+
): string {
|
|
85
|
+
const slugLocale = slugToLocale(slug);
|
|
86
|
+
if (slugLocale === locale) return slug;
|
|
87
|
+
if (slugLocale) {
|
|
88
|
+
return slug.replace(slugLocale + '/', locale ? locale + '/' : '');
|
|
89
|
+
}
|
|
90
|
+
return locale + '/' + slug;
|
|
91
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47';
|
|
3
|
+
|
|
4
|
+
const LocaleSchema = z.object({
|
|
5
|
+
/** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */
|
|
6
|
+
label: z
|
|
7
|
+
.string()
|
|
8
|
+
.describe(
|
|
9
|
+
'The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`.'
|
|
10
|
+
),
|
|
11
|
+
/** The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`. */
|
|
12
|
+
lang: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
'The BCP-47 tag for this language, e.g. `"en"`, `"ar"`, or `"zh-CN"`.'
|
|
17
|
+
),
|
|
18
|
+
/** The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left. */
|
|
19
|
+
dir: z
|
|
20
|
+
.enum(['rtl', 'ltr'])
|
|
21
|
+
.optional()
|
|
22
|
+
.default('ltr')
|
|
23
|
+
.describe(
|
|
24
|
+
'The writing direction of this language; `"ltr"` for left-to-right (the default) or `"rtl"` for right-to-left.'
|
|
25
|
+
),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const SidebarLinkItemSchema = z.object({
|
|
29
|
+
/** The visible label for this item in the sidebar. */
|
|
30
|
+
label: z.string(),
|
|
31
|
+
/** The link to this item’s content. Can be a relative link to local files or the full URL of an external page. */
|
|
32
|
+
link: z.string(),
|
|
33
|
+
});
|
|
34
|
+
export type SidebarLinkItem = z.infer<typeof SidebarLinkItemSchema>;
|
|
35
|
+
|
|
36
|
+
const AutoSidebarGroupSchema = z.object({
|
|
37
|
+
/** The visible label for this item in the sidebar. */
|
|
38
|
+
label: z.string(),
|
|
39
|
+
/** Enable autogenerating a sidebar category from a specific docs directory. */
|
|
40
|
+
autogenerate: z.object({
|
|
41
|
+
/** The directory to generate sidebar items for. */
|
|
42
|
+
directory: z.string(),
|
|
43
|
+
// TODO: not supported by Docusaurus but would be good to have
|
|
44
|
+
/** How many directories deep to include from this directory in the sidebar. Default: `Infinity`. */
|
|
45
|
+
// depth: z.number().optional(),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
export type AutoSidebarGroup = z.infer<typeof AutoSidebarGroupSchema>;
|
|
49
|
+
|
|
50
|
+
type ManualSidebarGroup = {
|
|
51
|
+
/** The visible label for this item in the sidebar. */
|
|
52
|
+
label: string;
|
|
53
|
+
/** Array of links and subcategories to display in this category. */
|
|
54
|
+
items: Array<
|
|
55
|
+
| SidebarLinkItem
|
|
56
|
+
| z.infer<typeof AutoSidebarGroupSchema>
|
|
57
|
+
| ManualSidebarGroup
|
|
58
|
+
>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ManualSidebarGroupSchema: z.ZodType<ManualSidebarGroup> = z.object({
|
|
62
|
+
/** The visible label for this item in the sidebar. */
|
|
63
|
+
label: z.string(),
|
|
64
|
+
/** Array of links and subcategories to display in this category. */
|
|
65
|
+
items: z.lazy(() =>
|
|
66
|
+
z
|
|
67
|
+
.union([
|
|
68
|
+
SidebarLinkItemSchema,
|
|
69
|
+
ManualSidebarGroupSchema,
|
|
70
|
+
AutoSidebarGroupSchema,
|
|
71
|
+
])
|
|
72
|
+
.array()
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const SidebarItemSchema = z.union([
|
|
77
|
+
SidebarLinkItemSchema,
|
|
78
|
+
ManualSidebarGroupSchema,
|
|
79
|
+
AutoSidebarGroupSchema,
|
|
80
|
+
]);
|
|
81
|
+
export type SidebarItem = z.infer<typeof SidebarItemSchema>;
|
|
82
|
+
|
|
83
|
+
const SidebarGroupSchema: z.ZodType<
|
|
84
|
+
ManualSidebarGroup | z.infer<typeof AutoSidebarGroupSchema>
|
|
85
|
+
> = z.union([ManualSidebarGroupSchema, AutoSidebarGroupSchema]);
|
|
86
|
+
|
|
87
|
+
const StarlightUserConfigSchema = z.object({
|
|
88
|
+
/** Title for your website. Will be used in metadata and as browser tab title. */
|
|
89
|
+
title: z
|
|
90
|
+
.string()
|
|
91
|
+
.describe(
|
|
92
|
+
'Title for your website. Will be used in metadata and as browser tab title.'
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
/** Description metadata for your website. Can be used in page metadata. */
|
|
96
|
+
description: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(
|
|
100
|
+
'Description metadata for your website. Can be used in page metadata.'
|
|
101
|
+
),
|
|
102
|
+
|
|
103
|
+
/** Optional details about the social media accounts for this site. */
|
|
104
|
+
social: z
|
|
105
|
+
.object({
|
|
106
|
+
/** Main Twitter handle for this site, e.g. `'astrodotbuild'`. */
|
|
107
|
+
twitter: z.string().optional(),
|
|
108
|
+
})
|
|
109
|
+
.optional(),
|
|
110
|
+
|
|
111
|
+
/** The tagline for your website. */
|
|
112
|
+
tagline: z.string().optional().describe('The tagline for your website.'),
|
|
113
|
+
|
|
114
|
+
/** Configure the defaults for the table of contents on each page. */
|
|
115
|
+
tableOfContents: z
|
|
116
|
+
.object({
|
|
117
|
+
/** The level to start including headings at in the table of contents. Default: 2. */
|
|
118
|
+
minHeadingLevel: z.number().int().min(1).max(6).optional().default(2),
|
|
119
|
+
/** The level to stop including headings at in the table of contents. Default: 3. */
|
|
120
|
+
maxHeadingLevel: z.number().int().min(1).max(6).optional().default(3),
|
|
121
|
+
})
|
|
122
|
+
.optional()
|
|
123
|
+
.default({ minHeadingLevel: 2, maxHeadingLevel: 3 })
|
|
124
|
+
.refine((toc) => toc.minHeadingLevel <= toc.maxHeadingLevel, {
|
|
125
|
+
message: 'minHeadingLevel must be less than or equal to maxHeadingLevel',
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
/** Enable and configure “Edit this page” links. */
|
|
129
|
+
editLink: z
|
|
130
|
+
.object({
|
|
131
|
+
/** Set the base URL for edit links. The final link will be `baseUrl` + the current page path. */
|
|
132
|
+
baseUrl: z.string().url().optional(),
|
|
133
|
+
})
|
|
134
|
+
.optional()
|
|
135
|
+
.default({}),
|
|
136
|
+
|
|
137
|
+
/** Configure locales for internationalization (i18n). */
|
|
138
|
+
locales: z
|
|
139
|
+
.object({
|
|
140
|
+
/** Configure a “root” locale to serve a default language from `/`. */
|
|
141
|
+
root: LocaleSchema.required({ lang: true }).optional(),
|
|
142
|
+
})
|
|
143
|
+
.catchall(LocaleSchema)
|
|
144
|
+
.transform((locales, ctx) => {
|
|
145
|
+
for (const key in locales) {
|
|
146
|
+
const locale = locales[key]!;
|
|
147
|
+
// Fall back to the key in the locales object as the lang.
|
|
148
|
+
let lang = locale.lang || key;
|
|
149
|
+
|
|
150
|
+
// Parse the lang tag so we can check it is valid according to BCP-47.
|
|
151
|
+
const schema = bcpParse(lang, { forgiving: true });
|
|
152
|
+
schema.region = schema.region?.toUpperCase();
|
|
153
|
+
const normalizedLang = bcpStringify(schema);
|
|
154
|
+
|
|
155
|
+
// Error if parsing the language tag failed.
|
|
156
|
+
if (!normalizedLang) {
|
|
157
|
+
ctx.addIssue({
|
|
158
|
+
code: z.ZodIssueCode.custom,
|
|
159
|
+
message: `Could not validate language tag "${lang}" at locales.${key}.lang.`,
|
|
160
|
+
});
|
|
161
|
+
return z.NEVER;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Let users know we’re modifying their configured `lang`.
|
|
165
|
+
if (normalizedLang !== lang) {
|
|
166
|
+
console.warn(
|
|
167
|
+
`Warning: using "${normalizedLang}" language tag for locales.${key}.lang instead of "${lang}".`
|
|
168
|
+
);
|
|
169
|
+
lang = normalizedLang;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Set the final value as the normalized lang, based on the key if needed.
|
|
173
|
+
locale.lang = lang;
|
|
174
|
+
}
|
|
175
|
+
return locales;
|
|
176
|
+
})
|
|
177
|
+
.optional()
|
|
178
|
+
.describe('Configure locales for internationalization (i18n).'),
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Specify the default language for this site.
|
|
182
|
+
*
|
|
183
|
+
* The default locale will be used to provide fallback content where translations are missing.
|
|
184
|
+
*/
|
|
185
|
+
defaultLocale: z.string().optional(),
|
|
186
|
+
|
|
187
|
+
/** Configure your site’s sidebar navigation items. */
|
|
188
|
+
sidebar: SidebarGroupSchema.array().optional(),
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Provide CSS files to customize the look and feel of your Starlight site.
|
|
192
|
+
*
|
|
193
|
+
* Supports local CSS files relative to the root of your project,
|
|
194
|
+
* e.g. `'/src/custom.css'`, and CSS you installed as an npm
|
|
195
|
+
* module, e.g. `'@fontsource/roboto'`.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* starlight({
|
|
199
|
+
* customCss: ['/src/custom-styles.css', '@fontsource/roboto'],
|
|
200
|
+
* })
|
|
201
|
+
*/
|
|
202
|
+
customCss: z.string().array().optional().default([]),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export const StarlightConfigSchema = StarlightUserConfigSchema.strict().transform(
|
|
206
|
+
({ locales, defaultLocale, ...config }, ctx) => {
|
|
207
|
+
if (locales !== undefined && Object.keys(locales).length > 1) {
|
|
208
|
+
// This is a multilingual site (more than one locale configured).
|
|
209
|
+
// Make sure we can find the default locale and if not, help the user set it.
|
|
210
|
+
// We treat the root locale as the default if present and no explicit default is set.
|
|
211
|
+
const defaultLocaleConfig = locales[defaultLocale || 'root'];
|
|
212
|
+
|
|
213
|
+
if (!defaultLocaleConfig) {
|
|
214
|
+
const availableLocales = Object.keys(locales)
|
|
215
|
+
.map((l) => `"${l}"`)
|
|
216
|
+
.join(', ');
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
code: 'custom',
|
|
219
|
+
message:
|
|
220
|
+
'Could not determine the default locale. ' +
|
|
221
|
+
'Please make sure `defaultLocale` in your Starlight config is one of ' +
|
|
222
|
+
availableLocales,
|
|
223
|
+
});
|
|
224
|
+
return z.NEVER;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
...config,
|
|
229
|
+
/** Flag indicating if this site has multiple locales set up. */
|
|
230
|
+
isMultilingual: true,
|
|
231
|
+
/** Full locale object for this site’s default language. */
|
|
232
|
+
defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale },
|
|
233
|
+
locales,
|
|
234
|
+
} as const;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// This is a monolingual site, so things are pretty simple.
|
|
238
|
+
return {
|
|
239
|
+
...config,
|
|
240
|
+
/** Flag indicating if this site has multiple locales set up. */
|
|
241
|
+
isMultilingual: false,
|
|
242
|
+
/** Full locale object for this site’s default language. */
|
|
243
|
+
defaultLocale: undefined,
|
|
244
|
+
locales: undefined,
|
|
245
|
+
} as const;
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
export type StarlightConfig = z.infer<typeof StarlightConfigSchema>;
|
|
250
|
+
export type StarlightUserConfig = z.input<typeof StarlightConfigSchema>;
|
package/virtual.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare module 'virtual:starlight/user-config' {
|
|
2
|
+
const Config: import('./types').StarlightConfig;
|
|
3
|
+
export default Config;
|
|
4
|
+
}
|
|
5
|
+
declare module 'virtual:starlight/project-context' {
|
|
6
|
+
export default { root: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module 'virtual:starlight/user-css' {}
|