Skip to main content
Glama
freshness-tracker.ts9.35 kB
/** * Documentation Freshness Tracking Utilities * * Tracks when documentation files were last updated and validated, * supporting both short-term (minutes/hours) and long-term (days) staleness detection. */ import fs from "fs/promises"; import path from "path"; import matter from "gray-matter"; /** * Time unit for staleness threshold */ export type TimeUnit = "minutes" | "hours" | "days"; /** * Staleness threshold configuration */ export interface StalenessThreshold { value: number; unit: TimeUnit; } /** * Predefined staleness levels */ export const STALENESS_PRESETS = { realtime: { value: 30, unit: "minutes" as TimeUnit }, active: { value: 1, unit: "hours" as TimeUnit }, recent: { value: 24, unit: "hours" as TimeUnit }, weekly: { value: 7, unit: "days" as TimeUnit }, monthly: { value: 30, unit: "days" as TimeUnit }, quarterly: { value: 90, unit: "days" as TimeUnit }, } as const; /** * Documentation metadata tracked in frontmatter */ export interface DocFreshnessMetadata { last_updated?: string; // ISO 8601 timestamp last_validated?: string; // ISO 8601 timestamp validated_against_commit?: string; auto_updated?: boolean; staleness_threshold?: StalenessThreshold; update_frequency?: keyof typeof STALENESS_PRESETS; } /** * Full frontmatter structure */ export interface DocFrontmatter { title?: string; description?: string; documcp?: DocFreshnessMetadata; [key: string]: unknown; } /** * File freshness status */ export interface FileFreshnessStatus { filePath: string; relativePath: string; hasMetadata: boolean; metadata?: DocFreshnessMetadata; lastUpdated?: Date; lastValidated?: Date; ageInMs?: number; ageFormatted?: string; isStale: boolean; stalenessLevel: "fresh" | "warning" | "stale" | "critical" | "unknown"; staleDays?: number; } /** * Freshness scan report */ export interface FreshnessScanReport { scannedAt: string; docsPath: string; totalFiles: number; filesWithMetadata: number; filesWithoutMetadata: number; freshFiles: number; warningFiles: number; staleFiles: number; criticalFiles: number; files: FileFreshnessStatus[]; thresholds: { warning: StalenessThreshold; stale: StalenessThreshold; critical: StalenessThreshold; }; } /** * Convert time threshold to milliseconds */ export function thresholdToMs(threshold: StalenessThreshold): number { const { value, unit } = threshold; switch (unit) { case "minutes": return value * 60 * 1000; case "hours": return value * 60 * 60 * 1000; case "days": return value * 24 * 60 * 60 * 1000; default: throw new Error(`Unknown time unit: ${unit}`); } } /** * Format age in human-readable format */ export function formatAge(ageMs: number): string { const seconds = Math.floor(ageMs / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days} day${days !== 1 ? "s" : ""}`; } else if (hours > 0) { return `${hours} hour${hours !== 1 ? "s" : ""}`; } else if (minutes > 0) { return `${minutes} minute${minutes !== 1 ? "s" : ""}`; } else { return `${seconds} second${seconds !== 1 ? "s" : ""}`; } } /** * Parse frontmatter from markdown file */ export async function parseDocFrontmatter( filePath: string, ): Promise<DocFrontmatter> { try { const content = await fs.readFile(filePath, "utf-8"); const { data } = matter(content); return data as DocFrontmatter; } catch (error) { return {}; } } /** * Update frontmatter in markdown file */ export async function updateDocFrontmatter( filePath: string, metadata: Partial<DocFreshnessMetadata>, ): Promise<void> { const content = await fs.readFile(filePath, "utf-8"); const { data, content: body } = matter(content); const existingDocuMCP = (data.documcp as DocFreshnessMetadata) || {}; const updatedData = { ...data, documcp: { ...existingDocuMCP, ...metadata, }, }; const newContent = matter.stringify(body, updatedData); await fs.writeFile(filePath, newContent, "utf-8"); } /** * Calculate file freshness status */ export function calculateFreshnessStatus( filePath: string, relativePath: string, frontmatter: DocFrontmatter, thresholds: { warning: StalenessThreshold; stale: StalenessThreshold; critical: StalenessThreshold; }, ): FileFreshnessStatus { const metadata = frontmatter.documcp; const hasMetadata = !!metadata?.last_updated; if (!hasMetadata) { return { filePath, relativePath, hasMetadata: false, isStale: true, stalenessLevel: "unknown", }; } const lastUpdated = new Date(metadata.last_updated!); const lastValidated = metadata.last_validated ? new Date(metadata.last_validated) : undefined; const now = new Date(); const ageInMs = now.getTime() - lastUpdated.getTime(); const ageFormatted = formatAge(ageInMs); const staleDays = Math.floor(ageInMs / (24 * 60 * 60 * 1000)); // Determine staleness level let stalenessLevel: FileFreshnessStatus["stalenessLevel"]; let isStale: boolean; const warningMs = thresholdToMs(thresholds.warning); const staleMs = thresholdToMs(thresholds.stale); const criticalMs = thresholdToMs(thresholds.critical); if (ageInMs >= criticalMs) { stalenessLevel = "critical"; isStale = true; } else if (ageInMs >= staleMs) { stalenessLevel = "stale"; isStale = true; } else if (ageInMs >= warningMs) { stalenessLevel = "warning"; isStale = false; } else { stalenessLevel = "fresh"; isStale = false; } return { filePath, relativePath, hasMetadata: true, metadata, lastUpdated, lastValidated, ageInMs, ageFormatted, isStale, stalenessLevel, staleDays, }; } /** * Find all markdown files in directory recursively */ export async function findMarkdownFiles(dir: string): Promise<string[]> { const files: string[] = []; async function scan(currentDir: string): Promise<void> { const entries = await fs.readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); // Skip common directories if (entry.isDirectory()) { if ( !["node_modules", ".git", "dist", "build", ".documcp"].includes( entry.name, ) ) { await scan(fullPath); } continue; } // Include markdown files if (entry.isFile() && /\.(md|mdx)$/i.test(entry.name)) { files.push(fullPath); } } } await scan(dir); return files; } /** * Scan directory for documentation freshness */ export async function scanDocumentationFreshness( docsPath: string, thresholds: { warning?: StalenessThreshold; stale?: StalenessThreshold; critical?: StalenessThreshold; } = {}, ): Promise<FreshnessScanReport> { // Default thresholds const finalThresholds = { warning: thresholds.warning || STALENESS_PRESETS.weekly, stale: thresholds.stale || STALENESS_PRESETS.monthly, critical: thresholds.critical || STALENESS_PRESETS.quarterly, }; // Find all markdown files const markdownFiles = await findMarkdownFiles(docsPath); // Analyze each file const files: FileFreshnessStatus[] = []; for (const filePath of markdownFiles) { const relativePath = path.relative(docsPath, filePath); const frontmatter = await parseDocFrontmatter(filePath); const status = calculateFreshnessStatus( filePath, relativePath, frontmatter, finalThresholds, ); files.push(status); } // Calculate summary statistics const totalFiles = files.length; const filesWithMetadata = files.filter((f) => f.hasMetadata).length; const filesWithoutMetadata = totalFiles - filesWithMetadata; const freshFiles = files.filter((f) => f.stalenessLevel === "fresh").length; const warningFiles = files.filter( (f) => f.stalenessLevel === "warning", ).length; const staleFiles = files.filter((f) => f.stalenessLevel === "stale").length; const criticalFiles = files.filter( (f) => f.stalenessLevel === "critical", ).length; return { scannedAt: new Date().toISOString(), docsPath, totalFiles, filesWithMetadata, filesWithoutMetadata, freshFiles, warningFiles, staleFiles, criticalFiles, files, thresholds: finalThresholds, }; } /** * Initialize frontmatter for files without metadata */ export async function initializeFreshnessMetadata( filePath: string, options: { updateFrequency?: keyof typeof STALENESS_PRESETS; autoUpdated?: boolean; } = {}, ): Promise<void> { const frontmatter = await parseDocFrontmatter(filePath); if (!frontmatter.documcp?.last_updated) { const metadata: DocFreshnessMetadata = { last_updated: new Date().toISOString(), last_validated: new Date().toISOString(), auto_updated: options.autoUpdated ?? false, update_frequency: options.updateFrequency || "monthly", }; if (options.updateFrequency) { metadata.staleness_threshold = STALENESS_PRESETS[options.updateFrequency]; } await updateDocFrontmatter(filePath, metadata); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tosin2013/documcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server