diff --git a/src/node/build/generateSitemap.ts b/src/node/build/generateSitemap.ts index 4f9bff7b..9d210f05 100644 --- a/src/node/build/generateSitemap.ts +++ b/src/node/build/generateSitemap.ts @@ -9,6 +9,7 @@ import { type NewsItem } from 'sitemap' import type { SiteConfig } from '../config' +import { slash } from '../shared' import { getGitTimestamp } from '../utils/getGitTimestamp' import { task } from '../utils/task' @@ -29,7 +30,7 @@ export async function generateSitemap(siteConfig: SiteConfig) { if (data.lastUpdated === false) return undefined if (data.lastUpdated instanceof Date) return +data.lastUpdated - return (await getGitTimestamp(file)) || undefined + return (await getGitTimestamp(slash(file))) || undefined } await task('generating sitemap', async () => { diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 26d32fb6..159e068c 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -31,6 +31,7 @@ import { staticDataPlugin } from './plugins/staticDataPlugin' import { webFontsPlugin } from './plugins/webFontsPlugin' import { slash, type PageDataPayload } from './shared' import { deserializeFunctions, serializeFunctions } from './utils/fnSerialize' +import { cacheAllGitTimestamps } from './utils/getGitTimestamp' declare module 'vite' { interface UserConfig { @@ -113,6 +114,8 @@ export async function createVitePressPlugin( async configResolved(resolvedConfig) { config = resolvedConfig + // pre-resolve git timestamps + if (lastUpdated) await cacheAllGitTimestamps(srcDir) markdownToVue = await createMarkdownToVueRenderFn( srcDir, markdown, diff --git a/src/node/utils/getGitTimestamp.ts b/src/node/utils/getGitTimestamp.ts index 268f7655..fd6d3177 100644 --- a/src/node/utils/getGitTimestamp.ts +++ b/src/node/utils/getGitTimestamp.ts @@ -1,29 +1,121 @@ -import { spawn } from 'cross-spawn' -import fs from 'fs-extra' -import { basename, dirname } from 'node:path' +import { spawn, sync } from 'cross-spawn' +import _debug from 'debug' +import fs from 'node:fs' +import path from 'node:path' +import { slash } from '../shared' +const debug = _debug('vitepress:git') const cache = new Map() -export function getGitTimestamp(file: string) { +const RS = 0x1e +const NUL = 0x00 + +export async function cacheAllGitTimestamps( + root: string, + pathspec: string[] = ['*.md'] +): Promise { + const cp = sync('git', ['rev-parse', '--show-toplevel'], { cwd: root }) + if (cp.error) throw cp.error + const gitRoot = cp.stdout.toString('utf8').trim() + + const args = [ + 'log', + '--pretty=format:%x1e%at%x00', // RS + epoch + NUL + '--name-only', + '-z', + '--', + ...pathspec + ] + + return new Promise((resolve, reject) => { + const out = new Map() + const child = spawn('git', args, { cwd: root }) + + let buf = Buffer.alloc(0) + child.stdout.on('data', (chunk: Buffer) => { + buf = buf.length ? Buffer.concat([buf, chunk]) : chunk + + let scanFrom = 0 + let ts = 0 + + while (true) { + if (ts === 0) { + const rs = buf.indexOf(RS, scanFrom) + if (rs === -1) break + scanFrom = rs + 1 + + const nul = buf.indexOf(NUL, scanFrom) + if (nul === -1) break + scanFrom = nul + 2 // skip LF after NUL + + const tsSec = buf.toString('utf8', rs + 1, nul) + ts = Number.parseInt(tsSec, 10) * 1000 + } + + let nextNul + while (true) { + nextNul = buf.indexOf(NUL, scanFrom) + if (nextNul === -1) break + + // double NUL, move to next record + if (nextNul === scanFrom) { + scanFrom += 1 + ts = 0 + break + } + + const file = buf.toString('utf8', scanFrom, nextNul) + if (file && !out.has(file)) out.set(file, ts) + scanFrom = nextNul + 1 + } + + if (nextNul === -1) break + } + + if (scanFrom > 0) buf = buf.subarray(scanFrom) + }) + + child.on('close', async () => { + cache.clear() + + for (const [file, ts] of out) { + const abs = path.resolve(gitRoot, file) + if (fs.existsSync(abs)) cache.set(slash(abs), ts) + } + + out.clear() + resolve() + }) + + child.on('error', reject) + }) +} + +export async function getGitTimestamp(file: string): Promise { const cached = cache.get(file) if (cached) return cached + // most likely will never happen except for recently added files in dev + debug(`[cache miss] ${file}`) + if (!fs.existsSync(file)) return 0 - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const child = spawn( 'git', - ['log', '-1', '--pretty="%ai"', basename(file)], - { cwd: dirname(file) } + ['log', '-1', '--pretty=%at', path.basename(file)], + { cwd: path.dirname(file) } ) let output = '' child.stdout.on('data', (d) => (output += String(d))) child.on('close', () => { - const timestamp = +new Date(output) - cache.set(file, timestamp) - resolve(timestamp) + const ts = Number.parseInt(output.trim(), 10) * 1000 + if (!(ts > 0)) return resolve(0) + + cache.set(file, ts) + resolve(ts) }) child.on('error', reject)