From bb66666ced7fb4cd28c07823abd6db33c31a78e8 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 13 Oct 2025 17:10:15 +0530 Subject: [PATCH] feat(metadata): Enhance metadata generation for repository browsing feat(utils): Add parseRepoPath function to extract repository name and revision from URL path --- .../app/[domain]/browse/[...path]/page.tsx | 30 +++++++++++ packages/web/src/app/layout.tsx | 12 +++-- packages/web/src/lib/utils.ts | 52 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 84c87912..bf16cc6a 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -3,6 +3,36 @@ import { getBrowseParamsFromPathParam } from "../hooks/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; +import { Metadata } from "next"; +import { parseRepoPath } from "@/lib/utils"; + +type Props = { + params: { + domain: string; + path: string[]; + }; +}; + +export async function generateMetadata({ params }: Props): Promise { + let title = 'Browse'; // Current Default + + try { + const parsedInfo = parseRepoPath(params.path); + + if (parsedInfo) { + const { fullRepoName, revision } = parsedInfo; + title = `${fullRepoName}${revision ? ` @ ${revision}` : ''}`; + } + } catch (error) { + // Log the error for debugging, but don't crash the page render. + console.error("Failed to generate metadata title from path:", params.path, error); + } + + return { + title, // e.g., "sourcebot-dev/sourcebot @ HEAD" + }; +} + interface BrowsePageProps { params: Promise<{ diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index dc0d4b8c..441e5e58 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -11,9 +11,15 @@ import { PlanProvider } from "@/features/entitlements/planProvider"; import { getEntitlements } from "@sourcebot/shared"; export const metadata: Metadata = { - title: "Sourcebot", - description: "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", - manifest: "/manifest.json", + // Using the title.template will allow child pages to set the title + // while keeping a consistent suffix. + title: { + default: "Sourcebot", + template: "%s | Sourcebot", + }, + description: + "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", + manifest: "/manifest.json", }; export default function RootLayout({ diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 38b52cfe..e13077bc 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -486,4 +486,56 @@ export const isHttpError = (error: unknown, status: number): boolean => { && typeof error === 'object' && 'status' in error && error.status === status; +} + + +/** + * Parses a URL path array to extract the full repository name and revision. + * This function assumes a URL structure like: + * `.../[hostname]/[owner]/[repo@revision]/-/tree/...` + * Or for nested groups (like GitLab): + * `.../[hostname]/[group]/[subgroup]/[repo@revision]/-/tree/...` + * + * @param path The array of path segments from Next.js params. + * @returns An object with fullRepoName and revision, or null if parsing fails. + */ +export const parseRepoPath = (path: string[]): { fullRepoName: string; revision: string } | null => { + if (path.length < 2) { + return null; // Not enough path segments to parse. + } + + // Find the index of the `-` delimiter which separates the repo info from the file tree info. + const delimiterIndex = path.indexOf('-'); + + // If no delimiter is found, we can't reliably parse the path. + if (delimiterIndex === -1) { + return null; + } + + // The repository parts are between the hostname (index 0) and the delimiter. + // e.g., ["github.com", "sourcebot-dev", "sourcebot"] -> slice will be ["sourcebot-dev", "sourcebot"] + const repoParts = path.slice(1, delimiterIndex); + + if (repoParts.length === 0) { + return null; + } + + // The last part of the repo segment potentially contains the revision. + const lastPart = repoParts[repoParts.length - 1]; + + // URL segments are encoded. Decode it to handle characters like '@' (%40). + const decodedLastPart = decodeURIComponent(lastPart); + + const [repoNamePart, revision = ''] = decodedLastPart.split('@'); + + // The preceding parts form the owner/group path. + // e.g., ["sourcebot"] or ["my-group", "my-subgroup"] + const ownerParts = repoParts.slice(0, repoParts.length - 1); + + // Reconstruct the full repository name. + // e.g., "sourcebot-dev" + "/" + "sourcebot" + // e.g., "my-group/my-subgroup" + "/" + "my-repo" + const fullRepoName = [...ownerParts, repoNamePart].join('/'); + + return { fullRepoName, revision }; } \ No newline at end of file