[feat] heading sub-order
This commit is contained in:
@@ -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<T extends { order: number; text: string }>(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<T extends { subOrder?: number; text: string }>(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<string, HeadingBucket>()
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user