Files
website/node_modules/@astrojs/starlight/utils/navigation.ts
2024-05-06 17:15:30 -04:00

366 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import config from 'virtual:starlight/user-config';
import type { Badge } from '../schemas/badge';
import type { PrevNextLinkConfig } from '../schemas/prevNextLink';
import type {
AutoSidebarGroup,
LinkHTMLAttributes,
SidebarItem,
SidebarLinkItem,
} from '../schemas/sidebar';
import { createPathFormatter } from './createPathFormatter';
import { formatPath } from './format-path';
import { pickLang } from './i18n';
import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingAndTrailingSlashes } from './path';
import { getLocaleRoutes, type Route } from './routing';
import { localeToLang, slugToPathname } from './slugs';
const DirKey = Symbol('DirKey');
const SlugKey = Symbol('SlugKey');
export interface Link {
type: 'link';
label: string;
href: string;
isCurrent: boolean;
badge: Badge | undefined;
attrs: LinkHTMLAttributes;
}
interface Group {
type: 'group';
label: string;
entries: (Link | Group)[];
collapsed: boolean;
badge: Badge | undefined;
}
export type SidebarEntry = Link | Group;
/**
* A representation of the route structure. For each object entry:
* if its a folder, the key is the directory name, and value is the directory
* content; if its a route entry, the key is the last segment of the route, and value
* is the full entry.
*/
interface Dir {
[DirKey]: undefined;
[SlugKey]: string;
[item: string]: Dir | Route;
}
/** Create a new directory object. */
function makeDir(slug: string): Dir {
const dir = {} as Dir;
// Add DirKey and SlugKey as non-enumerable properties so that `Object.entries(dir)` ignores them.
Object.defineProperty(dir, DirKey, { enumerable: false });
Object.defineProperty(dir, SlugKey, { value: slug, enumerable: false });
return dir;
}
/** Test if the passed object is a directory record. */
function isDir(data: Record<string, unknown>): data is Dir {
return DirKey in data;
}
/** Convert an item in a users sidebar config to a sidebar entry. */
function configItemToEntry(
item: SidebarItem,
currentPathname: string,
locale: string | undefined,
routes: Route[]
): SidebarEntry {
if ('link' in item) {
return linkFromConfig(item, locale, currentPathname);
} else if ('autogenerate' in item) {
return groupFromAutogenerateConfig(item, locale, routes, currentPathname);
} else {
return {
type: 'group',
label: pickLang(item.translations, localeToLang(locale)) || item.label,
entries: item.items.map((i) => configItemToEntry(i, currentPathname, locale, routes)),
collapsed: item.collapsed,
badge: item.badge,
};
}
}
/** Autogenerate a group of links from a users sidebar config. */
function groupFromAutogenerateConfig(
item: AutoSidebarGroup,
locale: string | undefined,
routes: Route[],
currentPathname: string
): Group {
const { collapsed: subgroupCollapsed, directory } = item.autogenerate;
const localeDir = locale ? locale + '/' + directory : directory;
const dirDocs = routes.filter(
(doc) =>
// Match against `foo.md` or `foo/index.md`.
stripExtension(doc.id) === localeDir ||
// Match against `foo/anything/else.md`.
doc.id.startsWith(localeDir + '/')
);
const tree = treeify(dirDocs, localeDir);
return {
type: 'group',
label: pickLang(item.translations, localeToLang(locale)) || item.label,
entries: sidebarFromDir(tree, currentPathname, locale, subgroupCollapsed ?? item.collapsed),
collapsed: item.collapsed,
badge: item.badge,
};
}
/** Check if a string starts with one of `http://` or `https://`. */
const isAbsolute = (link: string) => /^https?:\/\//.test(link);
/** Create a link entry from a user config object. */
function linkFromConfig(
item: SidebarLinkItem,
locale: string | undefined,
currentPathname: string
) {
let href = item.link;
if (!isAbsolute(href)) {
href = ensureLeadingSlash(href);
// Inject current locale into link.
if (locale) href = '/' + locale + href;
}
const label = pickLang(item.translations, localeToLang(locale)) || item.label;
return makeLink(href, label, currentPathname, item.badge, item.attrs);
}
/** Create a link entry. */
function makeLink(
href: string,
label: string,
currentPathname: string,
badge?: Badge,
attrs?: LinkHTMLAttributes
): Link {
if (!isAbsolute(href)) {
href = formatPath(href);
}
const isCurrent = pathsMatch(encodeURI(href), currentPathname);
return { type: 'link', label, href, isCurrent, badge, attrs: attrs ?? {} };
}
/** Test if two paths are equivalent even if formatted differently. */
function pathsMatch(pathA: string, pathB: string) {
const format = createPathFormatter({ trailingSlash: 'never' });
return format(pathA) === format(pathB);
}
/** Get the segments leading to a page. */
function getBreadcrumbs(path: string, baseDir: string): string[] {
// Strip extension from path.
const pathWithoutExt = stripExtension(path);
// Index paths will match `baseDir` and dont include breadcrumbs.
if (pathWithoutExt === baseDir) return [];
// Ensure base directory ends in a trailing slash.
baseDir = ensureTrailingSlash(baseDir);
// Strip base directory from path if present.
const relativePath = pathWithoutExt.startsWith(baseDir)
? pathWithoutExt.replace(baseDir, '')
: pathWithoutExt;
return relativePath.split('/');
}
/** Turn a flat array of routes into a tree structure. */
function treeify(routes: Route[], baseDir: string): Dir {
const treeRoot: Dir = makeDir(baseDir);
routes
// Remove any entries that should be hidden
.filter((doc) => !doc.entry.data.sidebar.hidden)
// Sort by depth, to build the tree depth first.
.sort((a, b) => b.id.split('/').length - a.id.split('/').length)
// Build the tree
.forEach((doc) => {
const parts = getBreadcrumbs(doc.id, baseDir);
let currentNode = treeRoot;
parts.forEach((part, index) => {
const isLeaf = index === parts.length - 1;
// Handle directory index pages by renaming them to `index`
if (isLeaf && currentNode.hasOwnProperty(part)) {
currentNode = currentNode[part] as Dir;
part = 'index';
}
// Recurse down the tree if this isnt the leaf node.
if (!isLeaf) {
const path = currentNode[SlugKey];
currentNode[part] ||= makeDir(stripLeadingAndTrailingSlashes(path + '/' + part));
currentNode = currentNode[part] as Dir;
} else {
currentNode[part] = doc;
}
});
});
return treeRoot;
}
/** Create a link entry for a given content collection entry. */
function linkFromRoute(route: Route, currentPathname: string): Link {
return makeLink(
slugToPathname(route.slug),
route.entry.data.sidebar.label || route.entry.data.title,
currentPathname,
route.entry.data.sidebar.badge,
route.entry.data.sidebar.attrs
);
}
/**
* Get the sort weight for a given route or directory. Lower numbers rank higher.
* Directories have the weight of the lowest weighted route they contain.
*/
function getOrder(routeOrDir: Route | Dir): number {
return isDir(routeOrDir)
? Math.min(...Object.values(routeOrDir).flatMap(getOrder))
: // If no order value is found, set it to the largest number possible.
routeOrDir.entry.data.sidebar.order ?? Number.MAX_VALUE;
}
/** Sort a directorys entries by user-specified order or alphabetically if no order specified. */
function sortDirEntries(dir: [string, Dir | Route][]): [string, Dir | Route][] {
const collator = new Intl.Collator(localeToLang(undefined));
return dir.sort(([_keyA, a], [_keyB, b]) => {
const [aOrder, bOrder] = [getOrder(a), getOrder(b)];
// Pages are sorted by order in ascending order.
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1;
// If two pages have the same order value they will be sorted by their slug.
return collator.compare(isDir(a) ? a[SlugKey] : a.slug, isDir(b) ? b[SlugKey] : b.slug);
});
}
/** Create a group entry for a given content collection directory. */
function groupFromDir(
dir: Dir,
fullPath: string,
dirName: string,
currentPathname: string,
locale: string | undefined,
collapsed: boolean
): Group {
const entries = sortDirEntries(Object.entries(dir)).map(([key, dirOrRoute]) =>
dirToItem(dirOrRoute, `${fullPath}/${key}`, key, currentPathname, locale, collapsed)
);
return {
type: 'group',
label: dirName,
entries,
collapsed,
badge: undefined,
};
}
/** Create a sidebar entry for a directory or content entry. */
function dirToItem(
dirOrRoute: Dir[string],
fullPath: string,
dirName: string,
currentPathname: string,
locale: string | undefined,
collapsed: boolean
): SidebarEntry {
return isDir(dirOrRoute)
? groupFromDir(dirOrRoute, fullPath, dirName, currentPathname, locale, collapsed)
: linkFromRoute(dirOrRoute, currentPathname);
}
/** Create a sidebar entry for a given content directory. */
function sidebarFromDir(
tree: Dir,
currentPathname: string,
locale: string | undefined,
collapsed: boolean
) {
return sortDirEntries(Object.entries(tree)).map(([key, dirOrRoute]) =>
dirToItem(dirOrRoute, key, key, currentPathname, locale, collapsed)
);
}
/** Get the sidebar for the current page. */
export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] {
const routes = getLocaleRoutes(locale);
if (config.sidebar) {
return config.sidebar.map((group) => configItemToEntry(group, pathname, locale, routes));
} else {
const tree = treeify(routes, locale || '');
return sidebarFromDir(tree, pathname, locale, false);
}
}
/** Turn the nested tree structure of a sidebar into a flat list of all the links. */
export function flattenSidebar(sidebar: SidebarEntry[]): Link[] {
return sidebar.flatMap((entry) =>
entry.type === 'group' ? flattenSidebar(entry.entries) : entry
);
}
/** Get previous/next pages in the sidebar or the ones from the frontmatter if any. */
export function getPrevNextLinks(
sidebar: SidebarEntry[],
paginationEnabled: boolean,
config: {
prev?: PrevNextLinkConfig;
next?: PrevNextLinkConfig;
}
): {
/** Link to previous page in the sidebar. */
prev: Link | undefined;
/** Link to next page in the sidebar. */
next: Link | undefined;
} {
const entries = flattenSidebar(sidebar);
const currentIndex = entries.findIndex((entry) => entry.isCurrent);
const prev = applyPrevNextLinkConfig(entries[currentIndex - 1], paginationEnabled, config.prev);
const next = applyPrevNextLinkConfig(
currentIndex > -1 ? entries[currentIndex + 1] : undefined,
paginationEnabled,
config.next
);
return { prev, next };
}
/** Apply a prev/next link config to a navigation link. */
function applyPrevNextLinkConfig(
link: Link | undefined,
paginationEnabled: boolean,
config: PrevNextLinkConfig | undefined
): Link | undefined {
// Explicitly remove the link.
if (config === false) return undefined;
// Use the generated link if any.
else if (config === true) return link;
// If a link exists, update its label if needed.
else if (typeof config === 'string' && link) {
return { ...link, label: config };
} else if (typeof config === 'object') {
if (link) {
// If a link exists, update both its label and href if needed.
return {
...link,
label: config.label ?? link.label,
href: config.link ?? link.href,
// Explicitly remove sidebar link attributes for prev/next links.
attrs: {},
};
} else if (config.link && config.label) {
// If there is no link and the frontmatter contains both a URL and a label,
// create a new link.
return makeLink(config.link, config.label, '');
}
}
// Otherwise, if the global config is enabled, return the generated link if any.
return paginationEnabled ? link : undefined;
}
/** Remove the extension from a path. */
const stripExtension = (path: string) => path.replace(/\.\w+$/, '');