diff --git a/src/frontend/scripts/write-git-env.mjs b/src/frontend/scripts/write-git-env.mjs index 19df87fbb..3302b36d2 100644 --- a/src/frontend/scripts/write-git-env.mjs +++ b/src/frontend/scripts/write-git-env.mjs @@ -20,18 +20,24 @@ function tryExec(cmd) { } } +function getExplicitCommit() { + return ( + process.env.PUBLIC_GIT_COMMIT_ID || + process.env.GIT_COMMIT_ID || + process.env.SOURCE_COMMIT || + process.env.GITHUB_SHA || + process.env.VERCEL_GIT_COMMIT_SHA || + null + ); +} + function getCommit() { /** - * We need to get the commit hash from the public repo's main branch. - * In CI/CD, there may be other intermediary commits from the deploy branch. - * This ensures we always get the correct commit hash. - * Try origin/main first, then fall back to upstream/main for fork workflows. + * Prefer an explicit commit provided by CI, otherwise fall back to the + * currently checked out commit. Avoid remote-ref-dependent resolution so + * local builds remain hermetic and deterministic. */ - return ( - tryExec('git merge-base origin/main HEAD') ?? - tryExec('git merge-base upstream/main HEAD') ?? - '' - ); + return getExplicitCommit() ?? tryExec('git rev-parse HEAD') ?? ''; } function getRepoUrl() { @@ -54,25 +60,27 @@ const repo = getRepoUrl().replace(/\/$/, ''); const lines = [`PUBLIC_GIT_COMMIT_ID=${commit}`, `PUBLIC_REPO_URL=${repo}`]; const envPath = join(process.cwd(), '.env.local'); -let updated = false; -let content = ''; -if (existsSync(envPath)) { - content = readFileSync(envPath, 'utf8'); +const fileExists = existsSync(envPath); +const existingContent = fileExists ? readFileSync(envPath, 'utf8') : ''; + +let content = fileExists ? existingContent : lines.join('\n') + '\n'; + +if (fileExists) { lines.forEach((line) => { const [key] = line.split('='); const regex = new RegExp(`^${key}=.*$`, 'm'); if (regex.test(content)) { content = content.replace(regex, line); - updated = true; } else { content += (content.endsWith('\n') ? '' : '\n') + line + '\n'; - updated = true; } }); +} + +const updated = content !== existingContent; + +if (updated) { writeFileSync(envPath, content); -} else { - writeFileSync(envPath, lines.join('\n') + '\n'); - updated = true; } if (process.env.CI) { diff --git a/src/frontend/src/components/Include.astro b/src/frontend/src/components/Include.astro index 16f3b02d2..1898134d4 100644 --- a/src/frontend/src/components/Include.astro +++ b/src/frontend/src/components/Include.astro @@ -1,6 +1,8 @@ --- // Include a Markdown/MDX file from the docs collection inline. -// Usage in MDX: +// Usage in MDX: + +import { getEntry, render } from 'astro:content'; interface Props { /** Path relative to /src/content/docs (may start with or without a leading slash). */ @@ -9,22 +11,17 @@ interface Props { const { relativePath } = Astro.props as Props; -// Eagerly import all .md and .mdx under the docs collection so we can render by path. -const mdModules = { - ...(import.meta.glob('/src/content/docs/**/*.md', { eager: true }) as Record), - ...(import.meta.glob('/src/content/docs/**/*.mdx', { eager: true }) as Record), -}; - -// Normalize the provided path to match the glob keys (absolute from project root). -const normalized = `/src/content/docs/${relativePath.replace(/^\/+/, '')}`; -const mod = mdModules[normalized] as any; +// Resolve the include through the docs collection instead of eagerly globbing +// the entire docs tree into every page that uses . +const normalizedPath = relativePath.replace(/^\/+/, ''); +const entryId = normalizedPath.replace(/\.(md|mdx)$/i, ''); +const entry = await getEntry('docs', entryId); -if (!mod) { - throw new Error(`Include.astro: File not found: ${normalized}`); +if (!entry) { + throw new Error(`Include.astro: File not found: ${normalizedPath} (entry id: ${entryId})`); } -// MD/MDX modules may export either `Content` or a default component. -const ResolvedContent = (mod?.Content ?? mod?.default) as any; +const { Content: ResolvedContent } = await render(entry); --- {ResolvedContent ? : null} diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro index 5408028d5..6bf895d74 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/[memberKind].astro @@ -67,7 +67,7 @@ export async function getStaticPaths() { const { pkg, type, allTypes, kind } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const apiSidebar = await getApiReferenceSidebar(); +const apiSidebar = await getApiReferenceSidebar({ packageName: pkg.package.name }); const filteredMembers = (type.members ?? []).filter((m: any) => m.kind === kind); const kindLabel = memberKindLabels[kind] ?? kind; diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro index 31c429c4d..9d8c4edc4 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/[type]/index.astro @@ -56,7 +56,7 @@ export async function getStaticPaths() { const { pkg, type, allTypes } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const apiSidebar = await getApiReferenceSidebar(); +const apiSidebar = await getApiReferenceSidebar({ packageName: pkg.package.name }); /** Flatten doc nodes to a plain-text string (for meta description). */ function flattenToText(nodes: any): string { diff --git a/src/frontend/src/pages/reference/api/csharp/[package]/index.astro b/src/frontend/src/pages/reference/api/csharp/[package]/index.astro index ce9db5305..42ee37e83 100644 --- a/src/frontend/src/pages/reference/api/csharp/[package]/index.astro +++ b/src/frontend/src/pages/reference/api/csharp/[package]/index.astro @@ -29,7 +29,7 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, ''); const validTypes = pkg.types.filter((t: any) => t.name); // skip types with empty names const isOfficial = pkg.package.name.startsWith('Aspire.'); const namespaceGroups = groupTypesByNamespace(validTypes); -const apiSidebar = await getApiReferenceSidebar(); +const apiSidebar = await getApiReferenceSidebar({ packageName: pkg.package.name }); const headings = [...namespaceGroups.keys()].map((ns) => ({ depth: 2 as const, diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro index 03bf15e9f..423d759aa 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/[member]/index.astro @@ -48,7 +48,7 @@ export async function getStaticPaths() { const { pkg, parentType, method } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar(); +const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); const pkgSlug = tsModuleSlug(pkg.package.name); const parentSlug = tsSlugify(parentType.name); diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro index 6eeb16556..82b0bea9b 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/[item]/index.astro @@ -67,7 +67,7 @@ export async function getStaticPaths() { const { pkg, item, itemKind } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar(); +const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); const pkgSlug = tsModuleSlug(pkg.package.name); /* ── Handle type: group capabilities into properties & methods ── */ diff --git a/src/frontend/src/pages/reference/api/typescript/[module]/index.astro b/src/frontend/src/pages/reference/api/typescript/[module]/index.astro index 5f68a761c..5da4ea4bb 100644 --- a/src/frontend/src/pages/reference/api/typescript/[module]/index.astro +++ b/src/frontend/src/pages/reference/api/typescript/[module]/index.astro @@ -22,7 +22,7 @@ export async function getStaticPaths() { const { pkg } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); -const tsSidebar = await getTsApiReferenceSidebar(); +const tsSidebar = await getTsApiReferenceSidebar({ packageName: pkg.package.name }); const pkgSlug = tsModuleSlug(pkg.package.name); const isOfficial = pkg.package.name.startsWith('Aspire.'); diff --git a/src/frontend/src/utils/api-sidebar.ts b/src/frontend/src/utils/api-sidebar.ts index 17d1b5cfa..aa5770e8f 100644 --- a/src/frontend/src/utils/api-sidebar.ts +++ b/src/frontend/src/utils/api-sidebar.ts @@ -5,10 +5,26 @@ /* passed as the `sidebar` prop to ``. */ /* ------------------------------------------------------------------ */ -import { slugify, genericArity, typeDisplayName, packageSlug, groupTypesByNamespace, memberKindOrder, memberKindLabels, memberKindSlugs, getPackages } from './packages'; +import { + slugify, + genericArity, + typeDisplayName, + packageSlug, + groupTypesByNamespace, + memberKindOrder, + memberKindLabels, + memberKindSlugs, + getPackages, +} from './packages'; /** Member kinds that get their own sidebar sub-item under a type. */ const sidebarMemberKinds = memberKindOrder; +const apiSidebarCache = new Map>(); +const shouldCacheApiSidebar = import.meta.env.PROD; + +interface ApiSidebarOptions { + packageName?: string; +} /** * Determine whether a type is a simple marker interface (no members at all). @@ -26,18 +42,20 @@ function isMarkerInterface(type: any): boolean { * Returns an array of { label, link } entries for each member-kind group * that has at least one member (e.g. Constructors, Properties, Methods). */ -function buildTypeSidebarItems( - packageName: string, - type: any, -): { label: string; link: string }[] { +function buildTypeSidebarItems(packageName: string, type: any): { label: string; link: string }[] { const base = `/reference/api/csharp/${packageSlug(packageName)}/${slugify(type.name, genericArity(type))}`; - const items: { label: string; link: string }[] = [ - { label: 'Overview', link: `${base}/` }, - ]; + const items: { label: string; link: string }[] = [{ label: 'Overview', link: `${base}/` }]; const members: any[] = type.members ?? []; + const memberKindsPresent = new Set(); + for (const member of members) { + if (member.kind) { + memberKindsPresent.add(member.kind); + } + } + for (const kind of sidebarMemberKinds) { - if (members.some((m: any) => m.kind === kind)) { + if (memberKindsPresent.has(kind)) { items.push({ label: memberKindLabels[kind] ?? kind, link: `${base}/${memberKindSlugs[kind] ?? kind}/`, @@ -49,46 +67,71 @@ function buildTypeSidebarItems( } /** - * Generate a sidebar configuration array for API reference pages. - * Each package becomes a collapsible group. Within each package, - * types are grouped by namespace. + * Build the sidebar group for a single C# package. */ -export async function getApiReferenceSidebar() { +function buildPackageSidebarEntry(pkg: any, collapsed: boolean = true) { + const validTypes = pkg.types.filter((t: any) => t.name); + const nsGroups = groupTypesByNamespace(validTypes); + + // If only one namespace, skip the namespace nesting level + const hasMultipleNamespaces = nsGroups.size > 1; + + const typeItems = hasMultipleNamespaces + ? [...nsGroups.entries()].map(([ns, types]) => ({ + label: ns, + collapsed: true, + items: types.map((t: any) => buildTypeSidebarEntry(pkg.package.name, t)), + })) + : [...nsGroups.values()].flat().map((t: any) => buildTypeSidebarEntry(pkg.package.name, t)); + + return { + label: pkg.package.name, + collapsed, + items: [ + { label: 'Overview', link: `/reference/api/csharp/${packageSlug(pkg.package.name)}/` }, + ...typeItems, + ], + }; +} + +async function buildApiReferenceSidebar(options: ApiSidebarOptions = {}) { const packages = await getPackages(); + const sidebarRoot = { label: 'Search APIs', link: '/reference/api/csharp/' }; + + if (options.packageName) { + const currentPackage = packages.find( + (pkg) => pkg.data.package.name === options.packageName + )?.data; + return currentPackage + ? [sidebarRoot, buildPackageSidebarEntry(currentPackage, false)] + : [sidebarRoot]; + } + const sorted = packages .map((p) => p.data) .sort((a, b) => a.package.name.localeCompare(b.package.name)); - return [ - { label: 'Search APIs', link: '/reference/api/csharp/' }, - ...sorted.map((pkg) => { - const validTypes = pkg.types.filter((t: any) => t.name); - const nsGroups = groupTypesByNamespace(validTypes); - - // If only one namespace, skip the namespace nesting level - const hasMultipleNamespaces = nsGroups.size > 1; - - const typeItems = hasMultipleNamespaces - ? [...nsGroups.entries()].map(([ns, types]) => ({ - label: ns, - collapsed: true, - items: types.map((t: any) => buildTypeSidebarEntry(pkg.package.name, t)), - })) - : [...nsGroups.values()] - .flat() - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - .map((t: any) => buildTypeSidebarEntry(pkg.package.name, t)); - - return { - label: pkg.package.name, - collapsed: true, - items: [ - { label: 'Overview', link: `/reference/api/csharp/${packageSlug(pkg.package.name)}/` }, - ...typeItems, - ], - }; - }), - ]; + return [sidebarRoot, ...sorted.map((pkg) => buildPackageSidebarEntry(pkg))]; +} + +/** + * Generate a sidebar configuration array for API reference pages. + * Results are memoized so repeated page renders do not rebuild the same tree. + */ +export function getApiReferenceSidebar(options: ApiSidebarOptions = {}) { + if (!shouldCacheApiSidebar) { + return buildApiReferenceSidebar(options); + } + + const cacheKey = options.packageName ? `package:${options.packageName}` : 'all'; + const cachedSidebar = apiSidebarCache.get(cacheKey); + if (cachedSidebar) { + return cachedSidebar; + } + + const sidebar = buildApiReferenceSidebar(options); + apiSidebarCache.set(cacheKey, sidebar); + return sidebar; } /** Build a single sidebar entry for a type (flat link or nested group). */ diff --git a/src/frontend/src/utils/packages.ts b/src/frontend/src/utils/packages.ts index 932fcfb31..6818cf3fb 100644 --- a/src/frontend/src/utils/packages.ts +++ b/src/frontend/src/utils/packages.ts @@ -4,11 +4,20 @@ import { getCollection } from 'astro:content'; +let packagesPromise: Promise | undefined; +const shouldCachePackages = import.meta.env.PROD; + /** * Fetch all package entries from the content collection. + * Memoized so Astro's many API routes reuse a single collection load. */ -export async function getPackages() { - return await getCollection('packages'); +export function getPackages() { + if (!shouldCachePackages) { + return getCollection('packages'); + } + + packagesPromise ??= getCollection('packages'); + return packagesPromise; } /** Count generic type parameters on a type object. */ @@ -17,9 +26,13 @@ export function genericArity(type: { genericParameters?: any[] }): number { } /** Display name including generic type parameters (e.g. `InteractionResult`). */ -export function typeDisplayName(type: { name: string; isGeneric?: boolean; genericParameters?: { name: string }[] }): string { +export function typeDisplayName(type: { + name: string; + isGeneric?: boolean; + genericParameters?: { name: string }[]; +}): string { return type.isGeneric && type.genericParameters?.length - ? `${type.name}<${type.genericParameters.map(g => g.name).join(', ')}>` + ? `${type.name}<${type.genericParameters.map((g) => g.name).join(', ')}>` : type.name; } @@ -98,7 +111,7 @@ export function groupTypesByKind(types: any[]): Map { if (matching.length > 0) { groups.set( kind, - matching.sort((a: any, b: any) => a.name.localeCompare(b.name)), + matching.sort((a: any, b: any) => a.name.localeCompare(b.name)) ); } } @@ -121,10 +134,7 @@ export function groupTypesByNamespace(types: any[]): Map { const sorted = new Map( [...nsMap.entries()] .sort(([a], [b]) => a.localeCompare(b)) - .map(([ns, ts]) => [ - ns, - ts.sort((a: any, b: any) => a.name.localeCompare(b.name)), - ]), + .map(([ns, ts]) => [ns, ts.sort((a: any, b: any) => a.name.localeCompare(b.name))]) ); return sorted; } @@ -134,16 +144,23 @@ export function groupTypesByNamespace(types: any[]): Map { * Includes parameter types for indexers, methods, and constructors to * disambiguate overloads. */ -export function memberSlug(member: { name: string; kind?: string; parameters?: { type: string }[] }): string { +export function memberSlug(member: { + name: string; + kind?: string; + parameters?: { type: string }[]; +}): string { let base = member.name === '.ctor' ? 'constructor' : member.name; if (member.kind === 'indexer' && member.parameters?.length) { - const paramTypes = member.parameters.map(p => shortTypeName(p.type)).join(', '); + const paramTypes = member.parameters.map((p) => shortTypeName(p.type)).join(', '); base = `this[${paramTypes}]`; } else if ((member.kind === 'method' || member.kind === 'constructor') && member.parameters) { - const paramTypes = member.parameters.map(p => shortTypeName(p.type)).join(', '); + const paramTypes = member.parameters.map((p) => shortTypeName(p.type)).join(', '); base = `${base}(${paramTypes})`; } - return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return base + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); } /** @@ -151,13 +168,17 @@ export function memberSlug(member: { name: string; kind?: string; parameters?: { * methods, and constructors. * e.g. `this[string]`, `Add(string, int)`, `Constructor(ILogger)`. */ -export function memberDisplayName(member: { name: string; kind?: string; parameters?: { type: string }[] }): string { +export function memberDisplayName(member: { + name: string; + kind?: string; + parameters?: { type: string }[]; +}): string { if (member.kind === 'indexer' && member.parameters?.length) { - const paramTypes = member.parameters.map(p => shortTypeName(p.type)).join(', '); + const paramTypes = member.parameters.map((p) => shortTypeName(p.type)).join(', '); return `this[${paramTypes}]`; } if ((member.kind === 'method' || member.kind === 'constructor') && member.parameters) { - const paramTypes = member.parameters.map(p => shortTypeName(p.type)).join(', '); + const paramTypes = member.parameters.map((p) => shortTypeName(p.type)).join(', '); return `${member.name}(${paramTypes})`; } return member.name; @@ -185,7 +206,7 @@ export function typeHref( base: string, packageName: string, typeName: string, - arity: number = 0, + arity: number = 0 ): string { const b = base.replace(/\/$/, ''); return `${b}/reference/api/csharp/${packageSlug(packageName)}/${slugify(typeName, arity)}/`; @@ -205,7 +226,7 @@ export function resolveTypeLink( raw: string, types: any[], base: string, - packageName: string, + packageName: string ): { href: string; label: string } | null { // Strip nullable markers and collection wrappers for matching. const clean = raw @@ -213,9 +234,7 @@ export function resolveTypeLink( .replace(/^System\.Threading\.Tasks\.Task<(.+)>$/, '$1') .replace(/^System\.Collections\.Generic\.\w+<(.+)>$/, '$1'); - const match = types.find( - (t: any) => t.fullName === clean || t.fullName === raw, - ); + const match = types.find((t: any) => t.fullName === clean || t.fullName === raw); if (match) { return { href: typeHref(base, packageName, match.name, genericArity(match)), @@ -235,7 +254,10 @@ export function resolveTypeLink( export function shortTypeName(fullName: string): string { let firstAngle = -1; for (let i = 0; i < fullName.length; i++) { - if (fullName[i] === '<') { firstAngle = i; break; } + if (fullName[i] === '<') { + firstAngle = i; + break; + } } if (firstAngle < 0) { @@ -261,7 +283,7 @@ export function shortTypeName(fullName: string): string { } if (current.trim()) args.push(current.trim()); - const shortArgs = args.map(a => shortTypeName(a)); + const shortArgs = args.map((a) => shortTypeName(a)); return `${outerShort}<${shortArgs.join(', ')}>`; } @@ -354,10 +376,17 @@ export function formatSignature(sig: string): string { if (params.length === 0) return sig; const indent = ' '; - return prefix + '\n' + params.map((p, i) => { - const sep = i < params.length - 1 ? ',' : ''; - return indent + p.trim() + sep; - }).join('\n') + suffix; + return ( + prefix + + '\n' + + params + .map((p, i) => { + const sep = i < params.length - 1 ? ',' : ''; + return indent + p.trim() + sep; + }) + .join('\n') + + suffix + ); } /** @@ -398,7 +427,7 @@ export interface ParentTypeInfo { export function buildClassBodySignature( rawSig: string, parentType: ParentTypeInfo, - memberKind: string, + memberKind: string ): string | null { if (!rawSig) return null; @@ -416,7 +445,7 @@ export function buildClassBodySignature( let typeName = parentType.name; if (parentType.isGeneric && parentType.genericParameters?.length) { - typeName += `<${parentType.genericParameters.map(g => g.name).join(', ')}>`; + typeName += `<${parentType.genericParameters.map((g) => g.name).join(', ')}>`; } const typeDecl = mods.join(' ') + ' ' + typeName; @@ -444,11 +473,16 @@ export function buildClassBodySignature( memberLine = ' ' + cleaned; } else { memberLine = - ' ' + pre + '\n' + - params.map((p, i) => { - const sep = i < params.length - 1 ? ',' : ''; - return ' ' + p.trim() + sep; - }).join('\n') + suf; + ' ' + + pre + + '\n' + + params + .map((p, i) => { + const sep = i < params.length - 1 ? ',' : ''; + return ' ' + p.trim() + sep; + }) + .join('\n') + + suf; } } } @@ -507,7 +541,7 @@ export function dedent(code: string): string { } if (minIndent === 0 || minIndent === Infinity) return code; return lines - .map(line => { + .map((line) => { if (line.trim().length === 0) return ''; return line.startsWith(' '.repeat(minIndent)) ? line.slice(minIndent) : line; }) diff --git a/src/frontend/src/utils/ts-api-sidebar.ts b/src/frontend/src/utils/ts-api-sidebar.ts index bc4cbea11..bde0d0cc7 100644 --- a/src/frontend/src/utils/ts-api-sidebar.ts +++ b/src/frontend/src/utils/ts-api-sidebar.ts @@ -2,84 +2,120 @@ /* Build sidebar configuration for TypeScript API reference pages. */ /* ------------------------------------------------------------------ */ -import { - tsModuleSlug, - tsSlugify, - getTsModules, -} from './ts-modules'; +import { tsModuleSlug, tsSlugify, getTsModules } from './ts-modules'; + +const tsApiSidebarCache = new Map>(); +const shouldCacheTsApiSidebar = import.meta.env.PROD; + +interface TsApiSidebarOptions { + packageName?: string; +} + +function buildModuleSidebarEntry(mod: any, collapsed: boolean = true) { + const modSlug = tsModuleSlug(mod.package.name); + const items: any[] = [{ label: 'Overview', link: `/reference/api/typescript/${modSlug}/` }]; + + // Types (handles + DTOs merged into one group) + const allTypes: any[] = []; + for (const type of mod.handleTypes ?? []) { + if (type.name) { + allTypes.push(type); + } + } + for (const type of mod.dtoTypes ?? []) { + if (type.name) { + allTypes.push(type); + } + } + allTypes.sort((a: any, b: any) => a.name.localeCompare(b.name)); + + if (allTypes.length > 0) { + items.push({ + label: 'Types', + collapsed: true, + items: allTypes.map((t: any) => ({ + label: t.name, + link: `/reference/api/typescript/${modSlug}/${tsSlugify(t.name)}/`, + })), + }); + } + + // Functions — individual pages + const functions = (mod.functions ?? []).filter( + (f: any) => f.name && (!f.qualifiedName || !f.qualifiedName.includes('.')) + ); + if (functions.length > 0) { + items.push({ + label: 'Functions', + collapsed: true, + items: functions + .sort((a: any, b: any) => a.name.localeCompare(b.name)) + .map((f: any) => ({ + label: f.name, + link: `/reference/api/typescript/${modSlug}/${tsSlugify(f.name)}/`, + })), + }); + } + + // Enum types + const enums = (mod.enumTypes ?? []).filter((t: any) => t.name); + if (enums.length > 0) { + items.push({ + label: 'Enums', + collapsed: true, + items: enums + .sort((a: any, b: any) => a.name.localeCompare(b.name)) + .map((e: any) => ({ + label: e.name, + link: `/reference/api/typescript/${modSlug}/${tsSlugify(e.name)}/`, + })), + }); + } + + return { + label: mod.package.name, + collapsed, + items, + }; +} /** * Generate a sidebar configuration array for TypeScript API reference pages. * Each module becomes a collapsible group. Within each module, items are * organized by type: handle types, functions, DTOs, and enums. */ -export async function getTsApiReferenceSidebar() { +async function buildTsApiReferenceSidebar(options: TsApiSidebarOptions = {}) { const modules = await getTsModules(); + const sidebarRoot = { label: 'Search TypeScript APIs', link: '/reference/api/typescript/' }; + + if (options.packageName) { + const currentModule = modules.find( + (mod) => mod.data.package.name === options.packageName + )?.data; + return currentModule + ? [sidebarRoot, buildModuleSidebarEntry(currentModule, false)] + : [sidebarRoot]; + } + const sorted = modules .map((p) => p.data) .sort((a, b) => a.package.name.localeCompare(b.package.name)); - return [ - { label: 'Search TypeScript APIs', link: '/reference/api/typescript/' }, - ...sorted.map((mod) => { - const modSlug = tsModuleSlug(mod.package.name); - const items: any[] = [ - { label: 'Overview', link: `/reference/api/typescript/${modSlug}/` }, - ]; - - // Types (handles + DTOs merged into one group) - const allTypes = [ - ...(mod.handleTypes ?? []).filter((t: any) => t.name), - ...(mod.dtoTypes ?? []).filter((t: any) => t.name), - ].sort((a: any, b: any) => a.name.localeCompare(b.name)); - - if (allTypes.length > 0) { - items.push({ - label: 'Types', - collapsed: true, - items: allTypes.map((t: any) => ({ - label: t.name, - link: `/reference/api/typescript/${modSlug}/${tsSlugify(t.name)}/`, - })), - }); - } - - // Functions — individual pages - const functions = (mod.functions ?? []) - .filter((f: any) => f.name && (!f.qualifiedName || !f.qualifiedName.includes('.'))); - if (functions.length > 0) { - items.push({ - label: 'Functions', - collapsed: true, - items: functions - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - .map((f: any) => ({ - label: f.name, - link: `/reference/api/typescript/${modSlug}/${tsSlugify(f.name)}/`, - })), - }); - } - - // Enum types - const enums = (mod.enumTypes ?? []).filter((t: any) => t.name); - if (enums.length > 0) { - items.push({ - label: 'Enums', - collapsed: true, - items: enums - .sort((a: any, b: any) => a.name.localeCompare(b.name)) - .map((e: any) => ({ - label: e.name, - link: `/reference/api/typescript/${modSlug}/${tsSlugify(e.name)}/`, - })), - }); - } - - return { - label: mod.package.name, - collapsed: true, - items, - }; - }), - ]; + return [sidebarRoot, ...sorted.map((mod) => buildModuleSidebarEntry(mod))]; +} + +export function getTsApiReferenceSidebar(options: TsApiSidebarOptions = {}) { + if (!shouldCacheTsApiSidebar) { + return buildTsApiReferenceSidebar(options); + } + + const cacheKey = options.packageName ? `package:${options.packageName}` : 'all'; + const cachedSidebar = tsApiSidebarCache.get(cacheKey); + if (cachedSidebar) { + return cachedSidebar; + } + + const sidebar = buildTsApiReferenceSidebar(options); + tsApiSidebarCache.set(cacheKey, sidebar); + return sidebar; } diff --git a/src/frontend/src/utils/ts-modules.ts b/src/frontend/src/utils/ts-modules.ts index 184a6a491..e735aaa53 100644 --- a/src/frontend/src/utils/ts-modules.ts +++ b/src/frontend/src/utils/ts-modules.ts @@ -4,11 +4,20 @@ import { getCollection } from 'astro:content'; +let tsModulesPromise: Promise | undefined; +const shouldCacheTsModules = import.meta.env.PROD; + /** * Fetch all TypeScript module entries from the content collection. + * Memoized so Astro's many API routes reuse a single collection load. */ -export async function getTsModules() { - return await getCollection('tsModules'); +export function getTsModules() { + if (!shouldCacheTsModules) { + return getCollection('tsModules'); + } + + tsModulesPromise ??= getCollection('tsModules'); + return tsModulesPromise; } /** Normalize a module name for use in a URL path segment. */ @@ -21,7 +30,10 @@ export function tsModuleSlug(name: string): string { * Converts PascalCase/camelCase to lowercase. */ export function tsSlugify(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); } /* ---- Capability kind helpers ---------------------------------------- */ @@ -61,7 +73,10 @@ export function groupFunctionsByKind(functions: any[]): Map { for (const kind of capabilityKindOrder) { const matching = functions.filter((f: any) => f.kind === kind); if (matching.length > 0) { - groups.set(kind, matching.sort((a: any, b: any) => a.name.localeCompare(b.name))); + groups.set( + kind, + matching.sort((a: any, b: any) => a.name.localeCompare(b.name)) + ); } } return groups; @@ -132,7 +147,10 @@ export function formatTsSignature(sig: string): string { for (let i = openIdx; i < sig.length; i++) { if (sig[i] === '(' || sig[i] === '<') depth++; else if (sig[i] === ')' || sig[i] === '>') depth--; - if (sig[i] === ')' && depth === 0) { closeIdx = i; break; } + if (sig[i] === ')' && depth === 0) { + closeIdx = i; + break; + } } if (closeIdx <= openIdx) return sig; @@ -161,10 +179,17 @@ export function formatTsSignature(sig: string): string { // Multi-param — wrap each on its own line const indent = ' '; - return prefix + '\n' + params.map((p, i) => { - const sep = i < params.length - 1 ? ',' : ''; - return indent + p + sep; - }).join('\n') + suffix; + return ( + prefix + + '\n' + + params + .map((p, i) => { + const sep = i < params.length - 1 ? ',' : ''; + return indent + p + sep; + }) + .join('\n') + + suffix + ); } /** @@ -178,10 +203,7 @@ export function simplifyType(typeRef: string): string { // Clean assembly metadata from generic type arguments: // System.IEquatable`1[[TypeName, Assembly, Version=..., ...]] → System.IEquatable`1[[TypeName]] - stripped = stripped.replace( - /\[\[([^\],]+),\s*[^\]]*\]\]/g, - '[[$1]]', - ); + stripped = stripped.replace(/\[\[([^\],]+),\s*[^\]]*\]\]/g, '[[$1]]'); // For generic types with angle brackets, simplify the outer name only if (stripped.includes('<')) {