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('<')) {