Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-seas-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/starlight": patch
---

Optimizes sidebar data generation logic to speed up sites with large sidebars
3 changes: 0 additions & 3 deletions packages/starlight/__tests__/basics/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,12 @@ test('paths contain normalized slugs for path parameters', () => {
const expectedPaths: GetStaticPathsResult = [
{
params: { slug: '404' },
props: routes[0]!,
},
{
params: { slug: undefined },
props: routes[1]!,
},
{
params: { slug: 'guides/authoring-content' },
props: routes[2]!,
},
];

Expand Down
48 changes: 28 additions & 20 deletions packages/starlight/utils/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
stripExtension,
stripLeadingAndTrailingSlashes,
} from './path';
import { getLocaleRoutes, routes } from './routing';
import { getLocaleRoutes, getRouteById } from './routing';
import type {
SidebarGroup,
SidebarLink,
Expand All @@ -42,6 +42,7 @@ const SlugKey = Symbol('SlugKey');
const rootAutogenerate: SidebarAutogenerateRouteData = { directory: '' };

const neverPathFormatter = createPathFormatter({ trailingSlash: 'never' });
const sidebarCollator = new Intl.Collator(localeToLang(undefined));

const docsCollectionPathFromRoot = getCollectionPathFromRoot('docs', project);

Expand Down Expand Up @@ -142,7 +143,7 @@ function linkFromInternalSidebarLinkItem(
// Astro passes root `index.[md|mdx]` entries with a slug of `index`
const slug = item.slug === 'index' ? '' : item.slug;
const localizedSlug = locale ? (slug ? locale + '/' + slug : locale) : slug;
const route = routes.find((entry) => localizedSlug === entry.id);
const route = getRouteById(localizedSlug);
if (!route) {
const hasExternalSlashes = item.slug.at(0) === '/' || item.slug.at(-1) === '/';
if (hasExternalSlashes) {
Expand Down Expand Up @@ -206,11 +207,6 @@ function makeLink({ attrs = {}, badge, autogenerate, ...opts }: MakeLinkOptions)
};
}

/** Test if two paths are equivalent even if formatted differently. */
function pathsMatch(pathA: string, pathB: string) {
return neverPathFormatter(pathA) === neverPathFormatter(pathB);
}

/** Get the segments leading to a page. */
function getBreadcrumbs(path: string, baseDir: string): string[] {
// Strip extension from path.
Expand Down Expand Up @@ -294,21 +290,26 @@ function linkFromRoute(
* Directories have the weight of the lowest weighted route they contain.
*/
function getOrder(routeOrDir: Route | Dir): number {
return isDir(routeOrDir)
const cachedOrder = orderByEntry.get(routeOrDir);
if (cachedOrder !== undefined) return cachedOrder;
const order = 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);
orderByEntry.set(routeOrDir, order);
return order;
}

const orderByEntry = new WeakMap<Dir | Route, number>();

/** Sort a directory’s 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.id, isDir(b) ? b[SlugKey] : b.id);
return sidebarCollator.compare(isDir(a) ? a[SlugKey] : a.id, isDir(b) ? b[SlugKey] : b.id);
});
}

Expand Down Expand Up @@ -435,19 +436,20 @@ function setIntermediateSidebarCurrentEntry(

/** Finds the current page in a sidebar. */
function getSidebarCurrentEntry(sidebar: SidebarEntry[], pathname: string): SidebarLink | null {
for (const entry of sidebar) {
if (entry.type === 'link' && pathsMatch(encodeURI(entry.href), pathname)) {
return entry;
}

if (entry.type === 'group') {
const currentEntry = getSidebarCurrentEntry(entry.entries, pathname);
if (currentEntry) return currentEntry;
let entriesByPath = sidebarEntriesByPath.get(sidebar);
if (!entriesByPath) {
entriesByPath = new Map();
for (const entry of flattenSidebar(sidebar)) {
const path = neverPathFormatter(encodeURI(entry.href));
if (!entriesByPath.has(path)) entriesByPath.set(path, entry);
}
sidebarEntriesByPath.set(sidebar, entriesByPath);
}
return null;
return entriesByPath.get(neverPathFormatter(pathname)) ?? null;
}

const sidebarEntriesByPath = new WeakMap<SidebarEntry[], Map<string, SidebarLink>>();

/** Generates a deterministic string based on the content of the passed sidebar. */
export function getSidebarHash(sidebar: SidebarEntry[]): string {
let hash = 0;
Expand All @@ -472,11 +474,17 @@ function recursivelyBuildSidebarIdentity(sidebar: SidebarEntry[]): string {

/** Turn the nested tree structure of a sidebar into a flat list of all the links. */
export function flattenSidebar(sidebar: SidebarEntry[]): SidebarLink[] {
return sidebar.flatMap((entry) =>
const cachedSidebar = flattenedSidebars.get(sidebar);
if (cachedSidebar) return cachedSidebar;
const flattenedSidebar = sidebar.flatMap((entry) =>
entry.type === 'group' ? flattenSidebar(entry.entries) : entry
);
flattenedSidebars.set(sidebar, flattenedSidebar);
return flattenedSidebar;
}

const flattenedSidebars = new WeakMap<SidebarEntry[], SidebarLink[]>();

/** Get previous/next pages in the sidebar or the ones from the frontmatter if any. */
export function getPrevNextLinks(
sidebar: SidebarEntry[],
Expand Down
12 changes: 8 additions & 4 deletions packages/starlight/utils/routing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ validateLogoImports();

interface Path extends GetStaticPathsItem {
params: { slug: string | undefined };
props: Route;
}

/**
Expand Down Expand Up @@ -61,10 +60,10 @@ function getRoutes(): Route[] {
if (!localeConfig) continue;
const locale = key === 'root' ? undefined : key;
const localeDocs = getLocaleDocs(locale);
const localeDocIds = new Set(localeDocs.map(({ id }) => id));
for (const fallback of defaultLocaleDocs) {
const id = localizedSlug(fallback.id, locale);
const doesNotNeedFallback = localeDocs.some((doc) => doc.id === id);
if (doesNotNeedFallback) continue;
if (localeDocIds.has(id)) continue;
routes.push({
entry: fallback,
id,
Expand All @@ -82,6 +81,12 @@ function getRoutes(): Route[] {
}
export const routes = getRoutes();

const routesById = new Map(routes.map((route) => [route.id, route]));

export function getRouteById(id: string): Route | undefined {
return routesById.get(id);
}

function getParamRouteMapping(): ReadonlyMap<string | undefined, Route> {
const map = new Map<string | undefined, Route>();
for (const route of routes) {
Expand All @@ -98,7 +103,6 @@ export function getRouteBySlugParam(slugParam: string | undefined): Route | unde
function getPaths(): Path[] {
return routes.map((route) => ({
params: { slug: slugToParam(route.id) },
props: route,
}));
}
export const paths = getPaths();
Expand Down
Loading