Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion app/[[...path]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,29 @@ 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 (
<Fragment>
<PageLoadMetrics pageType={pageType} attributes={{is_developer_docs: true}} />
<MDXLayoutRenderer
mdxSource={mdxSource}
frontMatter={frontMatter}
frontMatter={frontMatterWithGit}
nextPage={nextPage}
previousPage={previousPage}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/components/docPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,6 +95,10 @@ export function DocPage({
<div>
<hgroup>
<h1>{frontMatter.title}</h1>
{/* Show last updated info for develop-docs pages */}
{frontMatter.gitMetadata && (
<LastUpdated gitMetadata={frontMatter.gitMetadata} />
)}
<h2>{frontMatter.description}</h2>
</hgroup>
{/* This exact id is important for Algolia indexing */}
Expand Down
101 changes: 101 additions & 0 deletions src/components/lastUpdated/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap items-center gap-1 text-xs text-[var(--foreground-secondary)] mt-1 mb-4">
{/* Text content */}
<span className="flex flex-wrap items-center gap-1">
<span>updated by</span>
<span className="font-medium">{author}</span>
{/* Relative time with tooltip */}
<span title={fullDate} className="cursor-help">
{relativeTime}
</span>
</span>

{/* Commit link */}
<span className="flex items-center gap-1">
<span className="text-[var(--foreground-secondary)]">•</span>
<Link
href={commitUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--accent-purple)] underline"
>
#{abbreviatedHash}
</Link>
</span>
</div>
);
}
23 changes: 22 additions & 1 deletion src/mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,31 @@ export async function getDevDocsFrontMatterUncached(): Promise<FrontMatter[]> {

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}
Expand Down
11 changes: 10 additions & 1 deletion src/types/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand Down
70 changes: 70 additions & 0 deletions src/utils/getGitMetadata.ts
Original file line number Diff line number Diff line change
@@ -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<string, GitMetadata | null>();

/**
* 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;
}
}
Loading