UNPKG

starlight-auto-sidebar

Version:

Starlight plugin to tweak autogenerated sidebar groups.

343 lines (278 loc) 11.7 kB
import type { StarlightRouteData } from '@astrojs/starlight/route-data' import type { HookParameters } from '@astrojs/starlight/types' 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('update-context-root-segment-label') // The first segment represents the entire sidebar and the second one the autogenerated group root segment so when // accessing a segment, we need to offset indices by 2. const sidebarUpdateContextSegmentOffset = 2 export async function updatePageSidebar( items: StarlightRouteData['sidebar'], metadata: ProjectMetadata, locale: Locale, context: StarlightAutoSidebarContext, ) { return updateSidebarItems(items, metadata, locale, context) } async function updateSidebarItems( items: SidebarItem[], metadata: ProjectMetadata, locale: Locale, context: StarlightAutoSidebarContext, ): Promise<SidebarUpdateResult> { const result: SidebarUpdateResult = { sidebar: [] } for (const [index, itemConfig] of context.sidebar.entries()) { const item = items[index] if (!item) { continue } else if (isSidebarSlugItemConfig(itemConfig) || isSidebarLinkItemConfig(itemConfig)) { result.sidebar.push(item) } else if (isSidebarManualGroupConfig(itemConfig) && isSidebarGroup(item)) { const { sidebar: entries, prev, next, } = await updateSidebarItems(item.entries, metadata, locale, { ...context, sidebar: itemConfig.items }) if (prev !== undefined) result.prev = prev if (next !== undefined) result.next = next result.sidebar.push({ ...item, entries }) } else if (isSidebarAutogeneratedGroupConfig(itemConfig) && isSidebarGroup(item)) { const updateContext: SidebarUpdateContext = { ...context, config: itemConfig, locale, metadata, segments: [createUpdateContextRootSegment(items), { group: item, index }], } // Sorting and filtering needs to be done before computing the prev/next links. await sortAutogeneratedGroupItems(item, getAutogeneratedGroupDirMetadata(updateContext), updateContext) const { sidebar: entries, prev, next } = await updateAutogeneratedGroup(item, updateContext) if (prev !== undefined) result.prev = prev if (next !== undefined) result.next = next result.sidebar.push({ ...item, entries }) } } return result } async function updateAutogeneratedGroup( group: SidebarGroup, context: SidebarUpdateContext, ): Promise<SidebarUpdateResult> { const result: SidebarUpdateResult = { sidebar: [] } result.sidebar = await Promise.all( group.entries.map(async (item, index) => { if (isSidebarGroup(item)) { const groupContext = createUpdateContextForGroup(item, index, context) const metadata = getAutogeneratedGroupDirMetadata(groupContext) const label = metadata?.label ?? item.label const collapsed = metadata?.collapsed ?? groupContext.cascade?.collapsed ?? item.collapsed const badge = metadata?.badge ?? item.badge const { sidebar: entries, prev, next } = await updateAutogeneratedGroup(item, groupContext) if (prev !== undefined) result.prev = prev if (next !== undefined) result.next = next return { ...item, label, entries, collapsed, badge } } if (item.isCurrent && context.pagination !== false) { const { prev, next } = await getEntryPrevNextLinks(getSidebarLinkId(item), context.locale) result.prev = getPrevNextLink('prev', index, context) ?? null updateResultWithEntryPrevNextLink(result, 'prev', prev) result.next = getPrevNextLink('next', index, context) ?? null updateResultWithEntryPrevNextLink(result, 'next', next) } return item }), ) return result } async function sortAutogeneratedGroupItems( group: SidebarGroup, metadata: Metadata | undefined, context: SidebarUpdateContext, ): Promise<SidebarGroup> { const orders = await getAutogeneratedGroupItemsOrder(group.entries, context) const collator = new Intl.Collator(getDefaultLang()) const sort = metadata?.sort === 'reverse-slug' || context.cascade?.sort === 'reverse-slug' orders.sort(({ order: aOrder, path: aPath }, { order: bOrder, path: bPath }) => { if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1 return collator.compare(aPath, bPath) * (sort ? -1 : 1) }) const entries: SidebarItem[] = [] for (const { hidden, index } of orders) { if (hidden) continue // eslint-disable-next-line @typescript-eslint/no-non-null-assertion entries.push(group.entries[index]!) } group.entries = entries return group } function getAutogeneratedGroupItemsOrder( items: SidebarItem[], context: SidebarUpdateContext, ): Promise<AutogeneratedGroupItemsOrder> { 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 } } const itemContext = createUpdateContextForGroup(item, index, context) const metadata = getAutogeneratedGroupDirMetadata(itemContext) await sortAutogeneratedGroupItems(item, metadata, itemContext) const order: AutogeneratedGroupItemsOrder[number] = { index, order: metadata?.order ?? Number.MAX_VALUE, path: getAutoGeneratedGroupPath(item, context), } if ( metadata?.hidden || (context.limit && context.segments.length - sidebarUpdateContextSegmentOffset >= context.limit) ) order.hidden = true return order }), ) } function getPrevNextLink( type: 'prev' | 'next', index: number, context: SidebarUpdateContext, segmentCursor = 0, ): SidebarLink | undefined { const segment = context.segments.at(segmentCursor - 1) if (!segment) return const { group, index: groupIndex } = segment let item = group.entries[index + 1 * (type === 'next' ? 1 : -1)] if (!item) return getPrevNextLink(type, groupIndex, context, segmentCursor - 1) if (isSidebarLink(item)) return item if (isSidebarGroup(item)) while (isSidebarGroup(item)) item = item.entries.at(type === 'next' ? 0 : -1) return item } 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 getAutogeneratedGroupDirMetadata(context: SidebarUpdateContext) { const { config, metadata, segments } = context const id = isAutogeneratedGroupRootSegment(context) ? config.autogenerate.directory : `${config.autogenerate.directory}/${getUpdateContextTrail(context)}` const dirMetadata = !context.isMultilingual || !context.locale ? metadata[id] : (metadata[`${context.locale}/${id}`] ?? metadata[DefaultLocale ? `${DefaultLocale}/${id}` : id]) if (dirMetadata?.depth !== undefined) { context.limit = dirMetadata.depth + segments.length - sidebarUpdateContextSegmentOffset } if (dirMetadata?.cascade !== undefined) { context.cascade ??= {} if (dirMetadata.cascade.includes('collapsed')) context.cascade.collapsed = dirMetadata.collapsed if (dirMetadata.cascade.includes('sort')) context.cascade.sort = dirMetadata.sort } return dirMetadata } function isSidebarSlugItemConfig(itemConfig: SidebarItemConfig): itemConfig is SidebarSlugItemConfig { return typeof itemConfig === 'string' || 'slug' in itemConfig } function isSidebarLinkItemConfig(itemConfig: SidebarItemConfig): itemConfig is SidebarLinkItemConfig { return typeof itemConfig === 'object' && 'link' in itemConfig } export function isSidebarManualGroupConfig(itemConfig: SidebarItemConfig): itemConfig is SidebarManualGroupConfig { return typeof itemConfig === 'object' && 'items' in itemConfig } export function isSidebarAutogeneratedGroupConfig( itemConfig: SidebarItemConfig, ): itemConfig is SidebarAutogeneratedGroupConfig { return typeof itemConfig === 'object' && 'autogenerate' in itemConfig } function isSidebarLink(item: SidebarItem | undefined): item is SidebarLink { return item?.type === 'link' } function isSidebarGroup(item: SidebarItem | undefined): item is SidebarGroup { return item?.type === 'group' } function getAutoGeneratedGroupPath(group: SidebarGroup, context: SidebarUpdateContext) { const trail = isAutogeneratedGroupRootSegment(context) ? context.config.autogenerate.directory : `${context.config.autogenerate.directory}/${getUpdateContextTrail(context)}` return `${trail}/${group.label}` } function isAutogeneratedGroupRootSegment(context: SidebarUpdateContext) { return context.segments.length === sidebarUpdateContextSegmentOffset } function createUpdateContextForGroup( group: SidebarGroup, index: number, context: SidebarUpdateContext, ): SidebarUpdateContext { return { ...context, segments: [...context.segments, { group, index }] } } function createUpdateContextRootSegment(entries: SidebarItem[]): SidebarUpdateContext['segments'][number] { return { group: { type: 'group', entries, label: String(updateContextRootSegmentLabel), collapsed: false, badge: undefined }, index: -1, } } function getSidebarLinkId(link: SidebarLink) { return stripBase(stripLeadingAndTrailingSlash(link.href)) } function getUpdateContextTrail(context: SidebarUpdateContext) { const segments = context.segments.slice(sidebarUpdateContextSegmentOffset) return segments.map((segment) => segment.group.label).join('/') } export type SidebarUserConfig = NonNullable<HookParameters<'config:setup'>['config']['sidebar']> export type SidebarItemConfig = SidebarUserConfig[number] export type SidebarSlugItemConfig = Extract<SidebarItemConfig, string | { slug: string }> export type SidebarLinkItemConfig = Extract<SidebarItemConfig, { link: string }> export type SidebarManualGroupConfig = Extract<SidebarItemConfig, { items: SidebarItemConfig[] }> export type SidebarAutogeneratedGroupConfig = Extract<SidebarItemConfig, { autogenerate: { directory: string } }> type SidebarItem = StarlightRouteData['sidebar'][number] export type SidebarLink = Extract<SidebarItem, { type: 'link' }> type SidebarGroup = Extract<SidebarItem, { type: 'group' }> type PrevNextLink = StarlightRouteData['pagination']['next'] interface SidebarUpdateResult { sidebar: SidebarItem[] prev?: PrevNextLink | null next?: PrevNextLink | null } interface SidebarUpdateContext extends StarlightAutoSidebarContext { // Contains cascaded metadata that should be applied and not if cascading is enabled. cascade?: { collapsed?: Metadata['collapsed'] sort?: Metadata['sort'] } config: SidebarAutogeneratedGroupConfig limit?: number locale: Locale metadata: ProjectMetadata segments: { group: SidebarGroup; index: number }[] } type AutogeneratedGroupItemsOrder = { hidden?: true index: number order: number path: string }[]