From bdea1783fe592087b6cbdeff9e30763b389fdbbd Mon Sep 17 00:00:00 2001 From: Kegongteng Date: Thu, 19 Mar 2026 21:09:26 +0800 Subject: [PATCH] [feat] heading sub-order --- .vitepress/sidebar.mts | 230 +++++++++++++++++++++++++++++++---------- 1 file changed, 176 insertions(+), 54 deletions(-) diff --git a/.vitepress/sidebar.mts b/.vitepress/sidebar.mts index 449f8ea..5a90d4f 100644 --- a/.vitepress/sidebar.mts +++ b/.vitepress/sidebar.mts @@ -22,17 +22,31 @@ export type SidebarGroup = { items: SidebarItem[] } -type OrderedLinkItem = { +type OrderedGroup = { + order: number + text: string + group: SidebarGroup +} + +type SidebarDocMeta = { order: number text: string link: string } -type NestedSection = { +type OrderedSidebarItem = { order: number text: string - items: SidebarItem[] - collapsed: boolean + item: SidebarItem + subOrder?: number + source: 'doc' | 'subheading' +} + +type HeadingBucket = { + order: number + text: string + key: string + entries: OrderedSidebarItem[] } function sortByOrderAndText(a: T, b: T) { @@ -46,7 +60,6 @@ function normalizeLink(link: string) { } type ResolvedPath = { - scopePath: string scopeKey: string absDir: string isRoot: boolean @@ -70,7 +83,6 @@ function resolvePathConfig(rawPath: string): ResolvedPath { const relative = scopePath === '/' ? '' : scopePath.slice(1) return { - scopePath, scopeKey, absDir: path.resolve(docsRoot, relative), isRoot: scopePath === '/' @@ -85,23 +97,29 @@ function readFrontmatterAndTitle(filePath: string) { const raw = fs.readFileSync(filePath, 'utf8') const fmMatch = raw.match(/^---\s*([\s\S]*?)\s*---/) let order = Number.POSITIVE_INFINITY + let heading: string | undefined let subheading: string | undefined + let subOrder: number | undefined if (fmMatch) { const fm = fmMatch[1] const orderMatch = fm.match(/^\s*order\s*:\s*([-+]?\d+(?:\.\d+)?)/m) if (orderMatch) order = Number(orderMatch[1]) - const subheadingMatch = fm.match(/^\s*Subheading\s*:\s*(.+)$/m) - if (subheadingMatch) { - subheading = subheadingMatch[1].trim() - } + const headingMatch = fm.match(/^\s*heading\s*:\s*(.+)$/m) + if (headingMatch) heading = headingMatch[1].trim() + + const subheadingMatch = fm.match(/^\s*subheading\s*:\s*(.+)$/m) + if (subheadingMatch) subheading = subheadingMatch[1].trim() + + const subOrderMatch = fm.match(/^\s*sub-order\s*:\s*([-+]?\d+(?:\.\d+)?)/m) + if (subOrderMatch) subOrder = Number(subOrderMatch[1]) } const h1 = raw.match(/^#\s+(.+)$/m) const title = h1 ? h1[1].trim() : path.basename(filePath, '.md') - return { order, title, subheading } + return { order, title, heading, subheading, subOrder } } function listMarkdownFiles(absDir: string, includeIndex: boolean) { @@ -132,32 +150,54 @@ function listMarkdownItems(absDir: string, relativeDir: string, includeIndex: bo .sort(sortByOrderAndText) } -function buildNestedSection(absDir: string, relativeDir: string, dirName: string): NestedSection | null { - const childAbsDir = path.join(absDir, dirName) - const childRelativeDir = relativeDir ? `${relativeDir}/${dirName}` : dirName - const indexPath = path.join(childAbsDir, 'index.md') - const sectionMeta = fs.existsSync(indexPath) - ? readFrontmatterAndTitle(indexPath) - : null - - if (!sectionMeta?.subheading) return null - - const sectionItems = listMarkdownItems(childAbsDir, childRelativeDir, true) - if (!sectionItems.length) return null - - return { - order: sectionMeta.order, - text: sectionMeta.subheading, - items: sectionItems.map(({ text, link }): SidebarItem => ({ text, link })), - collapsed: false - } +function toSidebarLinkItems(items: SidebarDocMeta[]): SidebarItem[] { + return items.map(({ text, link }): SidebarItem => ({ text, link })) } -function generateSidebarGroup(entry: SidebarAutoItem): SidebarGroup { +function toOrderedSidebarItems(items: SidebarDocMeta[]): OrderedSidebarItem[] { + return items.map((doc) => ({ + order: doc.order, + text: doc.text, + item: { text: doc.text, link: doc.link }, + source: 'doc' + })) +} + +function sortBySubOrderAndText(a: T, b: T) { + const aOrder = a.subOrder ?? Number.POSITIVE_INFINITY + const bOrder = b.subOrder ?? Number.POSITIVE_INFINITY + if (aOrder === bOrder) return a.text.localeCompare(b.text) + return aOrder - bOrder +} + +function buildOrderedItems(entries: OrderedSidebarItem[]): SidebarItem[] { + const pinned = entries + .filter((entry) => entry.source === 'subheading' && entry.subOrder !== undefined) + .sort(sortBySubOrderAndText) + const floating = entries + .filter((entry) => entry.source !== 'subheading' || entry.subOrder === undefined) + .sort(sortByOrderAndText) + + const arranged = [...floating] + + for (const pin of pinned) { + const index = Math.max(0, (pin.subOrder as number) - 1) + if (index >= arranged.length) { + arranged.push(pin) + continue + } + + arranged.splice(index, 0, pin) + } + + return arranged.map(({ item }) => item) +} + +function generateSidebarGroups(entry: SidebarAutoItem): OrderedGroup[] { const { absDir, isRoot } = resolvePathConfig(entry.path) if (!fs.existsSync(absDir)) { - return { text: entry.text, items: [] } + return [{ order: Number.NEGATIVE_INFINITY, text: entry.text, group: { text: entry.text, items: [] } }] } const relativeDir = path.relative(docsRoot, absDir).split(path.sep).join('/') @@ -169,31 +209,113 @@ function generateSidebarGroup(entry: SidebarAutoItem): SidebarGroup { !isRoot ) - const nestedSections = fs.readdirSync(absDir, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => buildNestedSection(absDir, relativeDir, dirent.name)) - .filter((item): item is NestedSection => Boolean(item)) + const rootEntries: OrderedSidebarItem[] = toOrderedSidebarItems(topLevelItems) + const headingBuckets = new Map() - const items = [ - ...topLevelItems.map((item) => ({ kind: 'link' as const, ...item })), - ...nestedSections.map((item) => ({ kind: 'section' as const, ...item })) - ] - .sort(sortByOrderAndText) + const ensureHeadingBucket = (key: string, order: number, text: string, docs: SidebarDocMeta[]) => { + const existing = headingBuckets.get(key) + const entries = toOrderedSidebarItems(docs) - return { - text: entry.text, - items: items.map((item): SidebarItem => { - if (item.kind === 'link') { - return { text: item.text, link: item.link } - } + if (existing) { + existing.entries.push(...entries) + return + } - return { - text: item.text, - items: item.items, - collapsed: item.collapsed - } + headingBuckets.set(key, { + key, + order, + text, + entries }) } + + const addSubheadingToTarget = ( + targetHeadingKey: string | undefined, + label: string, + order: number, + subOrder: number | undefined, + items: SidebarItem[] + ) => { + const entry: OrderedSidebarItem = { + order, + text: label, + subOrder, + source: 'subheading', + item: { + text: label, + collapsed: true, + items + } + } + + if (targetHeadingKey && headingBuckets.has(targetHeadingKey)) { + headingBuckets.get(targetHeadingKey)!.entries.push(entry) + return + } + + rootEntries.push(entry) + } + + const walkDirectories = (parentAbsDir: string, parentRelativeDir: string, activeHeadingKey?: string) => { + const childDirs = fs.readdirSync(parentAbsDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + + for (const dirent of childDirs) { + const childAbsDir = path.join(parentAbsDir, dirent.name) + const childRelativeDir = parentRelativeDir ? `${parentRelativeDir}/${dirent.name}` : dirent.name + const childItems = listMarkdownItems(childAbsDir, childRelativeDir, true) + const indexPath = path.join(childAbsDir, 'index.md') + const meta = fs.existsSync(indexPath) + ? readFrontmatterAndTitle(indexPath) + : undefined + + let nextActiveHeadingKey = activeHeadingKey + + if (meta?.heading) { + const headingKey = childRelativeDir + ensureHeadingBucket(headingKey, meta.order, meta.heading, childItems) + nextActiveHeadingKey = headingKey + } + + if (meta?.subheading && childItems.length) { + const subOrder = meta.subOrder ?? meta.order + const parentHeadingKey = meta?.heading ? nextActiveHeadingKey : activeHeadingKey + addSubheadingToTarget(parentHeadingKey, meta.subheading, subOrder, meta.subOrder, toSidebarLinkItems(childItems)) + } + + walkDirectories(childAbsDir, childRelativeDir, nextActiveHeadingKey) + } + } + + walkDirectories(absDir, relativeDir) + + const childGroups = Array.from(headingBuckets.values()) + .map((bucket): OrderedGroup => { + const groupItems = buildOrderedItems(bucket.entries) + + return { + order: bucket.order, + text: bucket.text, + group: { + text: bucket.text, + items: groupItems + } + } + }) + .sort(sortByOrderAndText) + + const rootItems = buildOrderedItems(rootEntries) + + const rootGroup: OrderedGroup = { + order: Number.NEGATIVE_INFINITY, + text: entry.text, + group: { + text: entry.text, + items: rootItems + } + } + + return [rootGroup, ...childGroups] } export function generateSidebarByPath(sidebarAuto: SidebarAutoItem[]) { @@ -201,13 +323,13 @@ export function generateSidebarByPath(sidebarAuto: SidebarAutoItem[]) { for (const entry of sidebarAuto) { const { scopeKey } = resolvePathConfig(entry.path) - const group = generateSidebarGroup(entry) + const groups = generateSidebarGroups(entry) if (!sidebarConfig[scopeKey]) { sidebarConfig[scopeKey] = [] } - sidebarConfig[scopeKey].push(group) + sidebarConfig[scopeKey].push(...groups.map(({ group }) => group)) } return sidebarConfig