polen
Version:
A framework for delightful GraphQL developer portals
284 lines (245 loc) • 9.55 kB
text/typescript
import { FileRouter } from '#lib/file-router/index'
import { Str } from '@wollybeard/kit'
import type { Page } from './page.js'
import type { ScanResult } from './scan.js'
/**
* Represents a complete sidebar structure with navigation items.
* This is the main data structure used to render sidebars in the UI.
*/
export interface Sidebar {
/** Array of navigation items that can be either direct links or sections containing multiple links */
items: Item[]
}
/**
* A sidebar navigation item that can be either a direct link or a section containing multiple links.
* @see {@link ItemLink} for direct navigation links
* @see {@link ItemSection} for grouped navigation sections
*/
export type Item = ItemLink | ItemSection
/**
* A direct navigation link in the sidebar.
* Used for pages that don't have child pages.
*
* @example
* ```ts
* const link: ItemLink = {
* type: 'ItemLink',
* title: 'Get Started',
* pathExp: 'guide/get-started'
* }
* ```
*/
export interface ItemLink {
/** Discriminator for TypeScript union types */
type: `ItemLink`
/** Display title for the link (e.g., "Get Started") */
title: string
/** Path expression relative to the base path, without leading slash (e.g., "guide/get-started") */
pathExp: string
}
/**
* A collapsible section in the sidebar that groups related links.
* Used for directories that contain multiple pages.
*
* @example
* ```ts
* const section: ItemSection = {
* type: 'ItemSection',
* title: 'Guide',
* pathExp: 'guide',
* isLinkToo: true, // Has an index page
* links: [
* { type: 'ItemLink', title: 'Installation', pathExp: 'guide/installation' },
* { type: 'ItemLink', title: 'Configuration', pathExp: 'guide/configuration' }
* ]
* }
* ```
*/
export interface ItemSection {
/** Discriminator for TypeScript union types */
type: `ItemSection`
/** Display title for the section (e.g., "Guide", "API Reference") */
title: string
/** Path expression for the section's index page, if it exists (e.g., "guide") */
pathExp: string
/** Whether this section also acts as a link (true if the directory has an index page) */
isLinkToo: boolean
/** Child navigation links within this section */
links: ItemLink[]
}
/**
* A mapping of route paths to their corresponding sidebar structures.
* Used to store different sidebars for different sections of a site.
*
* @example
* ```ts
* const sidebarIndex: SidebarIndex = {
* '/guide': { items: [...] }, // Sidebar for /guide section
* '/api': { items: [...] }, // Sidebar for /api section
* '/reference': { items: [...] } // Sidebar for /reference section
* }
* ```
*/
export type SidebarIndex = Record<string, Sidebar>
/**
* Builds sidebars for all top-level directories that contain both an index page and nested content.
*
* This function analyzes a scan result to identify which directories should have sidebars.
* A directory gets a sidebar if it meets these criteria:
* 1. It's a top-level directory (e.g., /guide, /api, /docs)
* 2. It has an index page (e.g., /guide/index.md)
* 3. It has nested pages (e.g., /guide/getting-started.md, /guide/configuration.md)
*
* @param scanResult - The result of scanning pages, containing both a flat list and tree structure
* @returns A mapping of route paths to sidebar structures
*
* @example
* ```ts
* const scanResult = await Content.scan({ dir: './pages' })
* const sidebars = buildSidebarIndex(scanResult)
* // Returns: {
* // '/guide': { items: [...] },
* // '/api': { items: [...] }
* // }
* ```
*/
export const buildSidebarIndex = (scanResult: ScanResult): SidebarIndex => {
const sidebarIndex: SidebarIndex = {}
// Group pages by their top-level directory
const pagesByTopLevelDir = new Map<string, Page[]>()
for (const page of scanResult.list) {
const topLevelDir = page.route.logical.path[0]
// Skip pages that are not in a directory or are hidden
if (!topLevelDir || page.metadata.hidden) continue
if (!pagesByTopLevelDir.has(topLevelDir)) {
pagesByTopLevelDir.set(topLevelDir, [])
}
pagesByTopLevelDir.get(topLevelDir)!.push(page)
}
// Build sidebar for each directory that has an index page
for (const [topLevelDir, pages] of pagesByTopLevelDir) {
const hasIndexPage = pages.some(page =>
page.route.logical.path.length === 1
&& FileRouter.routeIsFromIndexFile(page.route)
)
// Skip directories without index pages
if (!hasIndexPage) continue
const pathExp = `/${topLevelDir}`
const sidebar = buildSidebarForDirectory(topLevelDir, pages)
if (sidebar.items.length > 0) {
sidebarIndex[pathExp] = sidebar
}
}
return sidebarIndex
}
/**
* Builds a sidebar for a specific directory from its pages
*/
const buildSidebarForDirectory = (topLevelDir: string, pages: Page[]): Sidebar => {
const items: Item[] = []
// Group pages by their immediate parent path
const pagesByParent = new Map<string, Page[]>()
for (const page of pages) {
// Skip the index page at the top level directory
if (page.route.logical.path.length === 1 && FileRouter.routeIsFromIndexFile(page.route)) {
continue
}
// Get the immediate parent path (e.g., for ['guide', 'advanced', 'tips'], parent is ['guide', 'advanced'])
const parentPath = page.route.logical.path.slice(0, -1).join(`/`)
if (!pagesByParent.has(parentPath)) {
pagesByParent.set(parentPath, [])
}
pagesByParent.get(parentPath)!.push(page)
}
// Process top-level pages (direct children of the directory)
const topLevelPages = pagesByParent.get(topLevelDir) ?? []
// Sort pages by their directory order (extracted from file path)
const sortedTopLevelPages = [...topLevelPages].sort((a, b) => {
// For sections, we need to look at the directory name in the file path
const dirA = a.route.file.path.relative.dir.split(`/`).pop() ?? ``
const dirB = b.route.file.path.relative.dir.split(`/`).pop() ?? ``
// Extract order from directory names like "10_b", "20_c"
const orderMatchA = /^(\d+)[_-]/.exec(dirA)
const orderMatchB = /^(\d+)[_-]/.exec(dirB)
const orderA = orderMatchA ? parseInt(orderMatchA[1]!, 10) : Number.MAX_SAFE_INTEGER
const orderB = orderMatchB ? parseInt(orderMatchB[1]!, 10) : Number.MAX_SAFE_INTEGER
if (orderA !== orderB) return orderA - orderB
// Fall back to alphabetical order
return dirA.localeCompare(dirB)
})
for (const page of sortedTopLevelPages) {
const pageName = page.route.logical.path[page.route.logical.path.length - 1]!
const childPath = page.route.logical.path.join(`/`)
const childPages = pagesByParent.get(childPath) ?? []
if (childPages.length > 0 || FileRouter.routeIsFromIndexFile(page.route)) {
// This is a section (has children or is an index page for a subdirectory)
const hasIndex = FileRouter.routeIsFromIndexFile(page.route)
const section: ItemSection = {
type: `ItemSection`,
title: Str.titlizeSlug(pageName),
pathExp: childPath,
isLinkToo: hasIndex,
links: [],
}
// Add direct children as links (sorted by order)
const sortedChildPages = [...childPages].sort((a, b) => {
const orderA = a.route.logical.order ?? Number.MAX_SAFE_INTEGER
const orderB = b.route.logical.order ?? Number.MAX_SAFE_INTEGER
return orderA - orderB
})
for (const childPage of sortedChildPages) {
if (!FileRouter.routeIsFromIndexFile(childPage.route)) {
section.links.push({
type: `ItemLink`,
title: Str.titlizeSlug(childPage.route.logical.path[childPage.route.logical.path.length - 1]!),
pathExp: childPage.route.logical.path.join(`/`),
})
}
}
// Also add any deeper descendants as flat links
const allDescendants: Page[] = []
for (const [parentPath, pagesInParent] of pagesByParent) {
// Check if this path is a descendant (but not direct child)
if (parentPath.startsWith(childPath + `/`)) {
for (const descendantPage of pagesInParent) {
if (!FileRouter.routeIsFromIndexFile(descendantPage.route)) {
allDescendants.push(descendantPage)
}
}
}
}
// Sort all descendants by their full path order
allDescendants.sort((a, b) => {
// Compare paths segment by segment, considering order at each level
const pathA = a.route.logical.path
const pathB = b.route.logical.path
const minLength = Math.min(pathA.length, pathB.length)
for (let i = 0; i < minLength; i++) {
const segmentCompare = pathA[i]!.localeCompare(pathB[i]!)
if (segmentCompare !== 0) return segmentCompare
}
return pathA.length - pathB.length
})
for (const descendantPage of allDescendants) {
section.links.push({
type: `ItemLink`,
title: Str.titlizeSlug(
descendantPage.route.logical.path[descendantPage.route.logical.path.length - 1]!,
),
pathExp: descendantPage.route.logical.path.join(`/`),
})
}
if (section.links.length > 0 || section.isLinkToo) {
items.push(section)
}
} else {
// This is a simple link
items.push({
type: `ItemLink`,
title: Str.titlizeSlug(pageName),
pathExp: page.route.logical.path.join(`/`),
})
}
}
return { items }
}