diff --git a/app/[[...path]]/page.tsx b/app/[[...path]]/page.tsx index 70872af034763..d6a191513040a 100644 --- a/app/[[...path]]/page.tsx +++ b/app/[[...path]]/page.tsx @@ -131,6 +131,21 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) } const {mdxSource, frontMatter} = doc; + // Fetch git metadata on-demand for this page only (faster in dev mode) + let gitMetadata = pageNode.frontmatter.gitMetadata; + if (!gitMetadata && pageNode.frontmatter.sourcePath?.startsWith('develop-docs/')) { + // In dev mode or if not cached, fetch git metadata for current page only + const {getGitMetadata} = await import('sentry-docs/utils/getGitMetadata'); + const metadata = getGitMetadata(pageNode.frontmatter.sourcePath); + gitMetadata = metadata ?? undefined; + } + + // Merge gitMetadata into frontMatter + const frontMatterWithGit = { + ...frontMatter, + gitMetadata, + }; + // pass frontmatter tree into sidebar, rendered page + fm into middle, headers into toc const pageType = (params.path?.[0] as PageType) || 'unknown'; return ( @@ -138,7 +153,7 @@ export default async function Page(props: {params: Promise<{path?: string[]}>}) diff --git a/src/components/docPage/index.tsx b/src/components/docPage/index.tsx index 9390875a78d01..3e9171a864f26 100644 --- a/src/components/docPage/index.tsx +++ b/src/components/docPage/index.tsx @@ -16,6 +16,7 @@ import {CopyMarkdownButton} from '../copyMarkdownButton'; import {DocFeedback} from '../docFeedback'; import {GitHubCTA} from '../githubCTA'; import {Header} from '../header'; +import {LastUpdated} from '../lastUpdated'; import Mermaid from '../mermaid'; import {PaginationNav} from '../paginationNav'; import {PlatformSdkDetail} from '../platformSdkDetail'; @@ -94,6 +95,10 @@ export function DocPage({

{frontMatter.title}

+ {/* Show last updated info for develop-docs pages */} + {frontMatter.gitMetadata && ( + + )}

{frontMatter.description}

{/* This exact id is important for Algolia indexing */} diff --git a/src/components/lastUpdated/index.tsx b/src/components/lastUpdated/index.tsx new file mode 100644 index 0000000000000..e076aa679377b --- /dev/null +++ b/src/components/lastUpdated/index.tsx @@ -0,0 +1,101 @@ +'use client'; + +import Link from 'next/link'; + +interface GitMetadata { + author: string; + commitHash: string; + timestamp: number; +} + +interface LastUpdatedProps { + gitMetadata: GitMetadata; +} + +/** + * Format a timestamp as a relative time string (e.g., "2 days ago") + */ +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp * 1000; // timestamp is in seconds + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (years > 0) { + return years === 1 ? '1 year ago' : `${years} years ago`; + } + if (months > 0) { + return months === 1 ? '1 month ago' : `${months} months ago`; + } + if (days > 0) { + return days === 1 ? '1 day ago' : `${days} days ago`; + } + if (hours > 0) { + return hours === 1 ? '1 hour ago' : `${hours} hours ago`; + } + if (minutes > 0) { + return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`; + } + return 'just now'; +} + +/** + * Format a timestamp as a full date string for tooltip + */ +function formatFullDate(timestamp: number): string { + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +/** + * Abbreviate a commit hash to first 7 characters + */ +function abbreviateHash(hash: string): string { + return hash.substring(0, 7); +} + +export function LastUpdated({gitMetadata}: LastUpdatedProps) { + const {commitHash, author, timestamp} = gitMetadata; + const relativeTime = formatRelativeTime(timestamp); + const fullDate = formatFullDate(timestamp); + const abbreviatedHash = abbreviateHash(commitHash); + const commitUrl = `https://github.com/getsentry/sentry-docs/commit/${commitHash}`; + + return ( +
+ {/* Text content */} + + updated by + {author} + {/* Relative time with tooltip */} + + {relativeTime} + + + + {/* Commit link */} + + + + #{abbreviatedHash} + + +
+ ); +} diff --git a/src/mdx.ts b/src/mdx.ts index 611e2f212ead1..8f738926daa6f 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -252,10 +252,31 @@ export async function getDevDocsFrontMatterUncached(): Promise { const source = await readFile(file, 'utf8'); const {data: frontmatter} = matter(source); + const sourcePath = path.join(folder, fileName); + + // In production builds, fetch git metadata for develop-docs pages only + // In development, skip this and fetch on-demand per page (faster dev server startup) + let gitMetadata: typeof frontmatter.gitMetadata = undefined; + if ( + process.env.NODE_ENV !== 'development' && + sourcePath.startsWith('develop-docs/') + ) { + const {getGitMetadata} = await import('./utils/getGitMetadata'); + const metadata = getGitMetadata(sourcePath); + // Ensure we create a completely new object to avoid any reference sharing + gitMetadata = metadata ? {...metadata} : undefined; + + // Log during build to debug Vercel issues + if (process.env.CI || process.env.VERCEL) { + console.log(`[BUILD] Git metadata for ${sourcePath}:`, gitMetadata); + } + } + return { ...(frontmatter as FrontMatter), slug: fileName.replace(/\/index.mdx?$/, '').replace(/\.mdx?$/, ''), - sourcePath: path.join(folder, fileName), + sourcePath, + gitMetadata, }; }, {concurrency: FILE_CONCURRENCY_LIMIT} diff --git a/src/types/frontmatter.ts b/src/types/frontmatter.ts index a336bcefefe48..0791839148e89 100644 --- a/src/types/frontmatter.ts +++ b/src/types/frontmatter.ts @@ -35,15 +35,23 @@ export interface FrontMatter { */ fullWidth?: boolean; + /** + * Git metadata for the last commit & author that modified this file + */ + gitMetadata?: { + author: string; + commitHash: string; + timestamp: number; + }; /** * A list of keywords for indexing with search. */ keywords?: string[]; + /** * Set this to true to show a "new" badge next to the title in the sidebar */ new?: boolean; - /** * The next page in the bottom pagination navigation. */ @@ -53,6 +61,7 @@ export interface FrontMatter { * takes precedence over children when present */ next_steps?: string[]; + /** * Set this to true to disable indexing (robots, algolia) of this content. */ diff --git a/src/utils/getGitMetadata.ts b/src/utils/getGitMetadata.ts new file mode 100644 index 0000000000000..07b3688227be4 --- /dev/null +++ b/src/utils/getGitMetadata.ts @@ -0,0 +1,70 @@ +import {execSync} from 'child_process'; +import path from 'path'; + +export interface GitMetadata { + author: string; + commitHash: string; + timestamp: number; +} + +// Cache to avoid repeated git calls during build +const gitMetadataCache = new Map(); + +/** + * Get git metadata for a file + * @param filePath - Path to the file relative to the repository root + * @returns Git metadata or null if unavailable + */ +export function getGitMetadata(filePath: string): GitMetadata | null { + // Check cache first + if (gitMetadataCache.has(filePath)) { + const cached = gitMetadataCache.get(filePath); + // Return a NEW copy to avoid reference sharing + return cached ? {...cached} : null; + } + + try { + // Get commit hash, author name, and timestamp + const logOutput = execSync(`git log -1 --format="%H|%an|%at" -- "${filePath}"`, { + encoding: 'utf8', + cwd: path.resolve(process.cwd()), + stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr + }).trim(); + + // Log for debugging on Vercel + if (process.env.CI || process.env.VERCEL) { + console.log(`[getGitMetadata] File: ${filePath} -> Output: ${logOutput}`); + } + + if (!logOutput) { + // No commits found for this file + gitMetadataCache.set(filePath, null); + return null; + } + + const [commitHash, author, timestampStr] = logOutput.split('|'); + const timestamp = parseInt(timestampStr, 10); + + // Create a fresh object for each call to avoid reference sharing + const metadata: GitMetadata = { + commitHash, + author, + timestamp, + }; + + // Cache the metadata + gitMetadataCache.set(filePath, metadata); + + // IMPORTANT: Return a NEW object, not the cached one + // This prevents all pages from sharing the same object reference + return { + commitHash: metadata.commitHash, + author: metadata.author, + timestamp: metadata.timestamp, + }; + } catch (error) { + // Git command failed or file doesn't exist in git + gitMetadataCache.set(filePath, null); + return null; + } +}