starlight-auto-sidebar
Version:
Starlight plugin to tweak autogenerated sidebar entries.
401 lines (321 loc) • 12.9 kB
text/typescript
import type { StarlightRouteData } from '@astrojs/starlight/route-data'
import { slug } from 'github-slugger'
import { stripBase } from './astro'
import type { Metadata, ProjectMetadata } from './metadata'
import { stripLeadingAndTrailingSlash } from './path'
import {
DefaultLocale,
getDefaultLang,
getEntryOrder,
getEntryPrevNextLinks,
type EntryData,
type Locale,
} from './starlight'
import type { StarlightAutoSidebarContext } from './vite'
const updateContextRootSegmentLabel = Symbol.for('starlight-auto-sidebar:update-context-root-segment-label')
export async function updatePageSidebar(
items: StarlightRouteData['sidebar'],
metadata: ProjectMetadata,
locale: Locale,
context: StarlightAutoSidebarContext,
) {
const result = await updateSidebarItems(items, metadata, locale, context, [createUpdateContextRootSegment(items)])
await updateResultWithPrevNextLinks(result, result.sidebar, locale, context)
return result
}
async function updateSidebarItems(
items: SidebarItem[],
metadata: ProjectMetadata,
locale: Locale,
context: StarlightAutoSidebarContext,
segments: SidebarUpdateContextSegment[],
): Promise<SidebarUpdateResult> {
const result: SidebarUpdateResult = { sidebar: [] }
for (let index = 0; index < items.length; index++) {
const item = items[index]
if (!item) continue
if (isSidebarAutoItem(item)) {
const startIndex = index
const { entries, directory } = getAutogeneratedEntrySlice(items, startIndex)
const group = createAutogeneratedEntriesGroup(entries)
const autoSegments = [...segments, { group }]
const updateContext: SidebarUpdateContext = {
...context,
autogeneratedRootDepth: autoSegments.length,
currentDirectory: directory,
locale,
metadata,
segments: autoSegments,
}
// Sorting and filtering needs to be done before computing the prev/next links.
const { metadata: groupMetadata, context: nextContext } = getAutogeneratedGroupDirState(updateContext)
await sortGroupWithAutogeneratedEntries(group, groupMetadata, nextContext)
const { sidebar } = await updateGroupWithAutogeneratedEntries(group, nextContext)
items.splice(startIndex, entries.length, ...sidebar)
index = startIndex + sidebar.length - 1
result.sidebar.push(...sidebar)
} else if (isSidebarGroup(item)) {
const { sidebar } = await updateSidebarItems(item.entries, metadata, locale, context, [
...segments,
{ group: item },
])
item.entries = sidebar
result.sidebar.push({ ...item, entries: sidebar })
} else {
result.sidebar.push(item)
}
}
return result
}
async function updateGroupWithAutogeneratedEntries(
group: SidebarGroup,
context: SidebarUpdateContext,
): Promise<SidebarUpdateResult> {
const result: SidebarUpdateResult = { sidebar: [] }
result.sidebar = await Promise.all(
group.entries.map(async (item) => {
if (isSidebarAutoGroup(item)) {
const groupContext = createUpdateContextForGroup(item, context)
const { metadata: groupMetadata, context: nextContext } = getAutogeneratedGroupDirState(groupContext)
const label = groupMetadata?.label ?? item.label
const collapsed = groupMetadata?.collapsed ?? nextContext.cascade?.collapsed ?? item.collapsed
const badge = groupMetadata?.badge ?? item.badge
const { sidebar: entries } = await updateGroupWithAutogeneratedEntries(item, nextContext)
return { ...item, label, entries, collapsed, badge }
}
return item
}),
)
return result
}
async function sortGroupWithAutogeneratedEntries(
group: SidebarGroup,
metadata: Metadata | undefined,
context: SidebarUpdateContext,
): Promise<SidebarGroup> {
const sort = getAutogeneratedGroupSort(metadata, context)
const sortEntries = await getAutogeneratedGroupSortEntries(group.entries, context, sort)
const collator = new Intl.Collator(getDefaultLang())
const isReverse = sort === 'reverse-slug' || sort === 'reverse-label'
sortEntries.sort(
({ order: aOrder, path: aPath, sortValue: aSortValue }, { order: bOrder, path: bPath, sortValue: bSortValue }) => {
if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1
const comparison = collator.compare(aSortValue, bSortValue)
if (comparison !== 0) return comparison * (isReverse ? -1 : 1)
return collator.compare(aPath, bPath)
},
)
const entries: SidebarItem[] = []
for (const { hidden, index } of sortEntries) {
if (hidden) continue
const entry = group.entries[index]
if (entry) entries.push(entry)
}
group.entries = entries
return group
}
function getAutogeneratedGroupSortEntries(
items: SidebarItem[],
context: SidebarUpdateContext,
sort: AutogeneratedGroupSort,
): Promise<AutogeneratedGroupItemSortEntries> {
const sortByLabel = sort === 'label' || sort === 'reverse-label'
return Promise.all(
items.map(async (item, index) => {
if (isSidebarLink(item)) {
const id = getSidebarLinkId(item)
return {
index,
order: await getEntryOrder(id, context.locale),
path: id,
sortValue: sortByLabel ? item.label : id,
}
}
const itemContext = createUpdateContextForGroup(item, context)
const { metadata: groupMetadata, context: nextContext } = getAutogeneratedGroupDirState(itemContext)
await sortGroupWithAutogeneratedEntries(item, groupMetadata, nextContext)
const path = itemContext.currentDirectory
const sortEntry: AutogeneratedGroupItemSortEntries[number] = {
index,
order: groupMetadata?.order ?? Number.MAX_VALUE,
path,
sortValue: sortByLabel ? (groupMetadata?.label ?? item.label) : path,
}
if (
groupMetadata?.hidden ||
(context.limit && context.segments.length - context.autogeneratedRootDepth >= context.limit)
) {
sortEntry.hidden = true
}
return sortEntry
}),
)
}
async function updateResultWithPrevNextLinks(
result: SidebarUpdateResult,
sidebar: SidebarItem[],
locale: Locale,
context: StarlightAutoSidebarContext,
) {
if (context.pagination === false) return
const links = getSidebarLinks(sidebar)
const current = getCurrentSidebarLink(links)
if (!current) return
const { prev, next } = await getEntryPrevNextLinks(getSidebarLinkId(current.link), locale)
result.prev = links[current.index - 1] ?? null
updateResultWithEntryPrevNextLink(result, 'prev', prev)
result.next = links[current.index + 1] ?? null
updateResultWithEntryPrevNextLink(result, 'next', next)
}
function updateResultWithEntryPrevNextLink(
result: SidebarUpdateResult,
type: 'prev' | 'next',
entryPrevNextLink: EntryData['prev'],
) {
if (entryPrevNextLink === undefined) return
if (entryPrevNextLink === false) {
result[type] = null
} else if (typeof entryPrevNextLink === 'string' && result[type]) {
result[type] = { ...result[type], label: entryPrevNextLink }
} else if (typeof entryPrevNextLink === 'object' && result[type]) {
result[type] = {
...result[type],
label: entryPrevNextLink.label ?? result[type].label,
href: entryPrevNextLink.link ?? result[type].href,
}
}
}
function getSidebarLinks(items: SidebarItem[]): SidebarLink[] {
return items.flatMap((item) => (isSidebarLink(item) ? item : getSidebarLinks(item.entries)))
}
function getCurrentSidebarLink(links: SidebarLink[]) {
for (const [index, link] of links.entries()) {
if (link.isCurrent) return { index, link }
}
return
}
function getAutogeneratedGroupDirState(context: SidebarUpdateContext) {
const nextContext: SidebarUpdateContext = {
...context,
...(context.cascade ? { cascade: { ...context.cascade } } : {}),
}
const { currentDirectory, metadata, segments } = nextContext
const dirMetadata =
!nextContext.isMultilingual || !nextContext.locale
? metadata[currentDirectory]
: (metadata[getLocalizedMetadataDirectory(nextContext.locale, currentDirectory)] ??
metadata[getLocalizedMetadataDirectory(DefaultLocale, currentDirectory)])
if (dirMetadata?.depth !== undefined) {
nextContext.limit = dirMetadata.depth + segments.length - nextContext.autogeneratedRootDepth
}
if (dirMetadata?.cascade !== undefined) {
nextContext.cascade ??= {}
if (dirMetadata.cascade.includes('collapsed')) nextContext.cascade.collapsed = dirMetadata.collapsed
if (dirMetadata.cascade.includes('sort')) {
nextContext.cascade.sort = getAutogeneratedGroupSort(dirMetadata, nextContext)
}
}
return { metadata: dirMetadata, context: nextContext }
}
function getAutogeneratedGroupSort(
metadata: Metadata | undefined,
context: SidebarUpdateContext,
): AutogeneratedGroupSort {
return metadata?.sort ?? context.cascade?.sort ?? 'slug'
}
function isSidebarLink(item: SidebarItem | undefined): item is SidebarLink {
return item?.type === 'link'
}
function isSidebarAutoLink(item: SidebarItem | undefined): item is SidebarAutoLink {
return item?.type === 'link' && 'autogenerate' in item
}
function isSidebarGroup(item: SidebarItem | undefined): item is SidebarGroup {
return item?.type === 'group'
}
function isSidebarAutoGroup(item: SidebarItem | undefined): item is SidebarAutoGroup {
return item?.type === 'group' && 'autogenerate' in item
}
function isSidebarAutoItem(item: SidebarItem | undefined): item is SidebarAutoLink | SidebarAutoGroup {
return isSidebarAutoLink(item) || isSidebarAutoGroup(item)
}
function createUpdateContextForGroup(group: SidebarGroup, context: SidebarUpdateContext): SidebarUpdateContext {
return {
...context,
currentDirectory: getAutogeneratedChildDirectory(context.currentDirectory, group.label),
segments: [...context.segments, { group }],
}
}
function createUpdateContextRootSegment(entries: SidebarItem[]): SidebarUpdateContext['segments'][number] {
return {
group: { type: 'group', entries, label: String(updateContextRootSegmentLabel), collapsed: false, badge: undefined },
}
}
function getAutogeneratedChildDirectory(directory: string, label: string) {
const segment = slug(label)
return directory ? `${directory}/${segment}` : segment
}
function getLocalizedMetadataDirectory(locale: Locale, directory: string) {
return locale ? (directory ? `${locale}/${directory}` : locale) : directory
}
function getAutogeneratedEntrySlice(items: SidebarItem[], startIndex: number) {
const firstItem = items[startIndex]
if (!isSidebarAutoItem(firstItem)) throw new Error('Expected an autogenerated sidebar item.')
const directory = stripLeadingAndTrailingSlash(firstItem.autogenerate.directory)
const entries: SidebarItem[] = []
for (let index = startIndex; index < items.length; index++) {
const item = items[index]
if (!isSidebarAutoItem(item)) break
if (stripLeadingAndTrailingSlash(item.autogenerate.directory) !== directory) break
entries.push(item)
}
return { directory, entries }
}
// Starlight expands an autogenerated config item to multiple sibling entries. Such entries are wrapped in a fake group
// so that the existing group sorting and prev/next logic can process them similarly to other groups.
function createAutogeneratedEntriesGroup(entries: SidebarItem[]): SidebarGroup {
return {
type: 'group',
entries,
label: String(updateContextRootSegmentLabel),
collapsed: false,
badge: undefined,
}
}
function getSidebarLinkId(link: SidebarLink) {
return stripBase(stripLeadingAndTrailingSlash(link.href))
}
type SidebarItem = StarlightRouteData['sidebar'][number]
type SidebarLink = Extract<SidebarItem, { type: 'link' }>
type SidebarAutoLink = Extract<SidebarLink, { autogenerate: { directory: string } }>
type SidebarGroup = Extract<SidebarItem, { type: 'group' }>
type SidebarAutoGroup = Extract<SidebarGroup, { autogenerate: { directory: string } }>
type PrevNextLink = StarlightRouteData['pagination']['next']
interface SidebarUpdateResult {
sidebar: SidebarItem[]
prev?: PrevNextLink | null
next?: PrevNextLink | null
}
interface SidebarUpdateContextSegment {
group: SidebarGroup
}
interface SidebarUpdateContext extends StarlightAutoSidebarContext {
autogeneratedRootDepth: number
// Contains cascaded metadata that should be applied or not if cascading is enabled.
cascade?: {
collapsed?: Metadata['collapsed']
sort?: AutogeneratedGroupSort
}
currentDirectory: string
limit?: number
locale: Locale
metadata: ProjectMetadata
segments: SidebarUpdateContextSegment[]
}
type AutogeneratedGroupItemSortEntries = {
hidden?: true
index: number
order: number
path: string
sortValue: string
}[]
type AutogeneratedGroupSort = NonNullable<Metadata['sort']>