starlight-auto-sidebar
Version:
Starlight plugin to tweak autogenerated sidebar groups.
343 lines (278 loc) • 11.7 kB
text/typescript
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
}[]