/** * Shared grouping / sort for the docs layout. * * Sidebar and the docs longread MUST use the same order — otherwise * scrollspy highlights jump around as the user scrolls (the sidebar's * ordered list doesn't match the visual order of sections in the docs). * This module is the single source of truth for that ordering. */ import { groupBy, orderBy, partition, sortBy } from 'lodash-es'; import type { ApiEndpoint, SchemaSource } from '../../types'; import { longestCommonPrefix } from './sidebarLabel'; export type EndpointGroup = { category: string; endpoints: ApiEndpoint[]; /** Longest ``/``-aligned prefix shared by every endpoint in this * group. Used by the sidebar to strip the redundant group prefix * from fallback labels. */ commonPrefix: string; }; /** A schema's worth of categorised endpoints. The outer level of the * ``sections`` sidebar iterates over these. */ export type SchemaSection = { source: SchemaSource; groups: EndpointGroup[]; }; const METHOD_ORDER: Record = { GET: 0, HEAD: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5, OPTIONS: 6, TRACE: 7, }; const methodRank = (ep: ApiEndpoint) => METHOD_ORDER[ep.method] ?? 99; /** * Stable, deterministic ordering so two different renders with the * same endpoint list always produce the same visual sequence. * * Groups: alphabetical by tag/category, with ``Other`` pinned to the * bottom (spec-less endpoints should not steal the top slot). * * Within a group: endpoints sorted by path first (so related resources * cluster), then by HTTP method (read → write → delete). */ export function groupEndpoints(list: ApiEndpoint[]): EndpointGroup[] { // Explicit cast: lodash's `groupBy` overload with a string key returns // `Dictionary` but the type narrows to `unknown` under strict TS in // some setups (subpath consumers compiling raw src). Cast preserves the // runtime behaviour and keeps `.map(e => e.path)` typed correctly. const byCategory = groupBy(list, 'category') as Record; const all: EndpointGroup[] = Object.entries(byCategory).map(([category, endpoints]) => ({ category, endpoints: orderBy(endpoints, ['path', methodRank], ['asc', 'asc']), commonPrefix: longestCommonPrefix(endpoints.map((e) => e.path)), })); // "Other" sinks to the bottom regardless of alphabet. const [other, named] = partition(all, (g) => g.category === 'Other'); return [...sortBy(named, (g) => g.category.toLowerCase()), ...other]; } /** Flatten grouped endpoints back into a linear list that preserves * group order + within-group order. This is the canonical order for * both the sidebar and the docs longread. */ export function flattenGrouped(groups: EndpointGroup[]): ApiEndpoint[] { return groups.flatMap((g) => g.endpoints); } /** Build per-schema sections in the same order as the original * ``schemas`` array. Schemas with zero endpoints are kept so users see * an empty-state placeholder instead of "the section silently vanished". */ export function buildSchemaSections( sources: SchemaSource[], endpointsBySchema: Record, ): SchemaSection[] { return sources.map((source) => ({ source, groups: groupEndpoints(endpointsBySchema[source.id] ?? []), })); } /** Flatten schema-sections into a linear endpoint list. Used by scrollspy * and by the docs longread to render endpoints in the exact same order * as the sidebar. */ export function flattenSchemaSections(sections: SchemaSection[]): ApiEndpoint[] { return sections.flatMap((s) => flattenGrouped(s.groups)); }