UNPKG

starlight-auto-sidebar

Version:

Starlight plugin to tweak autogenerated sidebar entries.

401 lines (321 loc) 12.9 kB
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']>