chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/* ── Wrapper ─────────────────────────────────────────────────────── */
|
||||
.git-last-updated {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.git-last-updated.has-history { cursor: default; }
|
||||
.git-last-updated.has-history:hover,
|
||||
.git-last-updated.has-history:focus-within { color: var(--link-color); }
|
||||
|
||||
/* ── Icon / Label / Time ─────────────────────────────────────────── */
|
||||
.git-icon, .git-label, .git-time { pointer-events: none; }
|
||||
.git-icon { width: 1em; height: 1em; flex-shrink: 0; }
|
||||
.git-label { opacity: 0.8; }
|
||||
.git-time { font-weight: 500; }
|
||||
|
||||
/* ── Spacer ──────────────────────────────────────────────────────── */
|
||||
.git-spacer { flex: 1; }
|
||||
|
||||
/* ── Tooltip ─────────────────────────────────────────────────────── */
|
||||
.git-commit-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 0.5rem);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.75rem;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0.15s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Bridge fills gap between tooltip and text - no visual appearance */
|
||||
.git-commit-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 14px;
|
||||
background: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Show via CSS :hover */
|
||||
.git-last-updated.has-history:hover .git-commit-tooltip,
|
||||
.git-last-updated.has-history:focus-within .git-commit-tooltip,
|
||||
.git-commit-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Arrow points downward */
|
||||
.git-commit-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 1.5rem;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: var(--border-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Tooltip Header ───────────────────────────────────────────────── */
|
||||
.git-tooltip-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 0.5rem;
|
||||
/* padding-bottom: 0.5rem; */
|
||||
/* border-bottom: 1px solid var(--border-color); */
|
||||
}
|
||||
|
||||
/* ── Commit List ─────────────────────────────────────────────────── */
|
||||
.git-commit-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.git-commit-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.git-commit-avatar {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color, #3b82f6);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.git-commit-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.git-commit-details { flex: 1; min-width: 0; }
|
||||
|
||||
.git-commit-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.git-commit-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.git-commit-author { font-weight: 500; }
|
||||
.git-commit-date {font-size: .8em;color: var(--sidebar-text);}
|
||||
|
||||
/* ── Dark mode ───────────────────────────────────────────────────── */
|
||||
[data-theme="dark"] .git-commit-tooltip { box-shadow: var(--shadow-lg); }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.git-commit-tooltip {
|
||||
min-width: 240px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
left: -0.5rem;
|
||||
}
|
||||
.git-label { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const i18n = window.__git_i18n || {};
|
||||
|
||||
/**
|
||||
* Format a Unix ms timestamp into a human-readable relative or absolute string.
|
||||
*/
|
||||
function formatTime(ts) {
|
||||
const config = window.__git_config || {};
|
||||
const format = config.dateFormat || 'relative';
|
||||
const dateObj = new Date(ts);
|
||||
|
||||
if (format === 'iso') {
|
||||
return dateObj.toISOString();
|
||||
}
|
||||
|
||||
if (format === 'locale-aware') {
|
||||
return dateObj.toLocaleString(document.documentElement.lang || 'en');
|
||||
}
|
||||
|
||||
const diff = Date.now() - ts;
|
||||
const s = Math.floor(diff / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const d = Math.floor(h / 24);
|
||||
|
||||
if (d < 1) {
|
||||
if (h >= 1) return (i18n.hoursAgo || '{n}h ago').replace('{n}', h);
|
||||
if (m >= 1) return (i18n.minutesAgo || '{n}m ago').replace('{n}', m);
|
||||
return i18n.justNow || 'just now';
|
||||
}
|
||||
if (d < 7) return (i18n.daysAgo || '{n}d ago').replace('{n}', d);
|
||||
|
||||
return dateObj.toLocaleDateString(
|
||||
document.documentElement.lang || 'en',
|
||||
{ year: 'numeric', month: 'short', day: 'numeric' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate all git [data-timestamp] elements with formatted relative time.
|
||||
* Scoped to .git-last-updated and .git-commit-list to avoid collisions.
|
||||
*/
|
||||
function hydrateTimes() {
|
||||
document.querySelectorAll('.git-last-updated [data-timestamp], .git-commit-list [data-timestamp]').forEach(function (el) {
|
||||
const ts = parseInt(el.getAttribute('data-timestamp'), 10);
|
||||
if (!ts || isNaN(ts)) return;
|
||||
el.textContent = formatTime(ts);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up keyboard accessibility on .git-last-updated elements.
|
||||
* Tooltip visibility is handled by CSS :hover/:focus-within.
|
||||
* JS only adds tabindex and aria attributes for keyboard users.
|
||||
*/
|
||||
function wireTooltips() {
|
||||
document.querySelectorAll('.git-last-updated').forEach(function (wrapper) {
|
||||
const tooltip = wrapper.querySelector('.git-commit-tooltip');
|
||||
if (!tooltip) return;
|
||||
|
||||
wrapper.classList.add('has-history');
|
||||
wrapper.setAttribute('tabindex', '0');
|
||||
wrapper.setAttribute('role', 'button');
|
||||
wrapper.setAttribute('aria-label', i18n.viewCommitHistory || 'View commit history');
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
hydrateTimes();
|
||||
wireTooltips();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Re-run after SPA navigation — the SPA router swaps .page-footer-actions
|
||||
// innerHTML on every page change, so we must re-hydrate timestamps and
|
||||
// re-wire tooltip accessibility on the freshly injected elements.
|
||||
document.addEventListener('docmd:page-mounted', init);
|
||||
})();
|
||||
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------
|
||||
* docmd : the zero-config documentation engine.
|
||||
*
|
||||
* @package @docmd/core (and ecosystem)
|
||||
* @website https://docmd.io
|
||||
* @repository https://github.com/docmd-io/docmd
|
||||
* @license MIT
|
||||
* @copyright Copyright (c) 2025-present docmd.io
|
||||
*
|
||||
* [docmd-source] - Please do not remove this header.
|
||||
* --------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { PluginDescriptor, PageContext, Engine } from '@docmd/api';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _engine: Engine | null = null;
|
||||
let _engineLoadAttempted = false;
|
||||
let _configuredEngine: string = 'rust'; // default: prefer Rust, respected from config
|
||||
|
||||
/**
|
||||
* Try to load an engine based on config.engine key.
|
||||
* Falls back to JS engine, then to execFile if neither is available.
|
||||
*/
|
||||
async function getEngine(): Promise<Engine | null> {
|
||||
if (_engineLoadAttempted) return _engine;
|
||||
_engineLoadAttempted = true;
|
||||
|
||||
try {
|
||||
const { loadEngine } = await import('@docmd/api');
|
||||
if (_configuredEngine === 'js') {
|
||||
_engine = await loadEngine('js');
|
||||
} else {
|
||||
// Default: try Rust, fall back to JS
|
||||
_engine = await loadEngine('rust').catch(() => loadEngine('js'));
|
||||
}
|
||||
return _engine;
|
||||
} catch {
|
||||
// No engine available — will use execFile fallback
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git log for multiple files using the engine (batch operation).
|
||||
* Returns a Map from file path to commits array.
|
||||
*/
|
||||
async function getGitLogViaEngine(
|
||||
filePaths: string[],
|
||||
maxCommits: number
|
||||
): Promise<Map<string, any[]> | null> {
|
||||
const engine = await getEngine();
|
||||
if (!engine) return null;
|
||||
|
||||
try {
|
||||
const result = await engine.run<Record<string, any[]>>({
|
||||
type: 'git:log',
|
||||
payload: { filePaths, maxCommits }
|
||||
});
|
||||
|
||||
if (!result.success) return null;
|
||||
return new Map(Object.entries(result.data || {}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Original Implementation (fallback when engine is not available)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const gitRootCache = new Map<string, string | null>();
|
||||
const gitCache = new Map<string, GitFileInfo>();
|
||||
|
||||
// Persistent disk cache for git histories
|
||||
let _diskCache: Record<string, { mtimeMs: number, info: GitFileInfo }> | null = null;
|
||||
let _diskCachePath: string | null = null;
|
||||
let _cacheDirty = false;
|
||||
|
||||
// Per-build deduplication: git indexing only needs to run once per full build.
|
||||
// The in-memory gitCache stores results for all subsequent locale/version passes.
|
||||
let _gitBuildId = '';
|
||||
let _gitIndexingDone = false;
|
||||
|
||||
/**
|
||||
* Resolve the cache directory anchored to the git root (not process.cwd()).
|
||||
* This fixes multi-project builds where process.chdir() shifts per-project,
|
||||
* which caused the cache to land in unpredictable subdirectories.
|
||||
*/
|
||||
let _configuredTmpDir: string | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the cache directory base path.
|
||||
* If config.tmp is configured, stores temporary files inside that specified path.
|
||||
* Otherwise, moves them to the device's actual OS temporary folder nested by project hash
|
||||
* so multiple documentations on the same device remain perfectly isolated.
|
||||
*/
|
||||
function resolveCacheDir(): string {
|
||||
if (_configuredTmpDir) {
|
||||
return path.join(path.resolve(process.cwd(), _configuredTmpDir), 'cache');
|
||||
}
|
||||
|
||||
// Walk up from cwd to find the project git root, ensuring stable path resolution
|
||||
let dir = process.cwd();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
// Derive a consistent project identifier (remote origin URL -> directory inode -> absolute path)
|
||||
// Ensures cache directory resolution remains stable across local folder moves/renames.
|
||||
let identifier = dir;
|
||||
try {
|
||||
const gitConfigPath = path.join(dir, '.git', 'config');
|
||||
let urlFound = false;
|
||||
if (fs.existsSync(gitConfigPath)) {
|
||||
const content = fs.readFileSync(gitConfigPath, 'utf8');
|
||||
const match = content.match(/url\s*=\s*([^\r\n]+)/);
|
||||
if (match && match[1]) {
|
||||
identifier = match[1].trim();
|
||||
urlFound = true;
|
||||
}
|
||||
}
|
||||
if (!urlFound) {
|
||||
const stat = fs.statSync(dir);
|
||||
if (stat && stat.ino) {
|
||||
identifier = `ino:${stat.ino}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const stat = fs.statSync(dir);
|
||||
if (stat && stat.ino) identifier = `ino:${stat.ino}`;
|
||||
} catch { /* keep absolute path fallback */ }
|
||||
}
|
||||
|
||||
// Generate a unique hash for this stable project identifier
|
||||
const hash = crypto.createHash('md5').update(identifier).digest('hex').slice(0, 12);
|
||||
const baseSlug = path.basename(dir).replace(/[^a-zA-Z0-9-_]/g, '');
|
||||
return path.join(os.tmpdir(), `docmd-${baseSlug}-${hash}`, 'cache');
|
||||
}
|
||||
|
||||
function initDiskCache() {
|
||||
if (_diskCache !== null) return;
|
||||
const cacheDir = resolveCacheDir();
|
||||
_diskCachePath = path.join(cacheDir, 'git-history.json');
|
||||
try {
|
||||
if (fs.existsSync(_diskCachePath)) {
|
||||
_diskCache = JSON.parse(fs.readFileSync(_diskCachePath, 'utf8'));
|
||||
} else {
|
||||
_diskCache = {};
|
||||
}
|
||||
} catch {
|
||||
_diskCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveDiskCache() {
|
||||
if (!_cacheDirty || !_diskCachePath || !_diskCache) return;
|
||||
try {
|
||||
// Prune entries for files that no longer exist on disk
|
||||
const keys = Object.keys(_diskCache);
|
||||
for (const key of keys) {
|
||||
try {
|
||||
if (!fs.existsSync(key)) delete _diskCache[key];
|
||||
} catch { /* ignore stat errors */ }
|
||||
}
|
||||
|
||||
const cacheDir = path.dirname(_diskCachePath);
|
||||
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(_diskCachePath, JSON.stringify(_diskCache), 'utf8');
|
||||
_cacheDirty = false;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
async function resolveGitRoot(dir: string): Promise<string | null> {
|
||||
if (gitRootCache.has(dir)) return gitRootCache.get(dir)!;
|
||||
try {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: realDir
|
||||
});
|
||||
const result = stdout.trim();
|
||||
gitRootCache.set(dir, result);
|
||||
return result;
|
||||
} catch {
|
||||
gitRootCache.set(dir, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getGitFileInfo(filePath: string, maxCommits: number = 6): Promise<GitFileInfo | null> {
|
||||
if (gitCache.has(filePath)) return gitCache.get(filePath)!;
|
||||
|
||||
initDiskCache();
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
if (_diskCache && _diskCache[filePath] && _diskCache[filePath].mtimeMs === fileStat.mtimeMs) {
|
||||
const cachedInfo = _diskCache[filePath].info;
|
||||
gitCache.set(filePath, cachedInfo);
|
||||
return cachedInfo;
|
||||
}
|
||||
|
||||
const fileDir = path.dirname(filePath);
|
||||
const gitRoot = await resolveGitRoot(fileDir);
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const normalizedRoot = fs.realpathSync(gitRoot);
|
||||
const normalizedFile = fs.realpathSync(filePath);
|
||||
|
||||
const relPath = path.relative(normalizedRoot, normalizedFile).replace(/\\/g, '/');
|
||||
|
||||
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) return null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', [
|
||||
'log',
|
||||
'--follow',
|
||||
'-n', maxCommits.toString(),
|
||||
'--format=%H|%h|%an|%ae|%at|%s',
|
||||
'--',
|
||||
relPath
|
||||
], {
|
||||
cwd: normalizedRoot
|
||||
});
|
||||
|
||||
const logOutput = stdout.trim();
|
||||
if (!logOutput) return null;
|
||||
|
||||
const commits: GitCommit[] = logOutput.split('\n').filter(Boolean).map((line: string) => {
|
||||
const [hash, shortHash, author, email, timestamp, ...messageParts] = line.split('|');
|
||||
const ts = parseInt(timestamp, 10) * 1000;
|
||||
const hashEmail = crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
|
||||
return {
|
||||
hash,
|
||||
shortHash,
|
||||
author,
|
||||
email,
|
||||
date: new Date(ts).toISOString(),
|
||||
timestamp: ts,
|
||||
message: messageParts.join('|'),
|
||||
avatarUrl: `https://www.gravatar.com/avatar/${hashEmail}?d=mp&s=64`
|
||||
};
|
||||
});
|
||||
|
||||
if (commits.length === 0) return null;
|
||||
|
||||
const info: GitFileInfo = {
|
||||
lastUpdated: commits[0]?.date || '',
|
||||
lastUpdatedTimestamp: commits[0]?.timestamp || 0,
|
||||
commits
|
||||
};
|
||||
|
||||
gitCache.set(filePath, info);
|
||||
if (_diskCache) {
|
||||
_diskCache[filePath] = { mtimeMs: fileStat.mtimeMs, info };
|
||||
_cacheDirty = true;
|
||||
}
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin: PluginDescriptor = {
|
||||
name: 'git',
|
||||
version: '0.8.12',
|
||||
capabilities: ['build', 'body', 'assets', 'translations', 'init', 'post-build']
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const i18nDir = path.resolve(__dirname, '..', 'i18n');
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
author: string;
|
||||
email: string;
|
||||
date: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface GitFileInfo {
|
||||
lastUpdated: string;
|
||||
lastUpdatedTimestamp: number;
|
||||
commits: GitCommit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp for display.
|
||||
* Uses relative time for recent updates, absolute for older ones.
|
||||
*/
|
||||
function formatLastUpdated(timestamp: number, locale: string = 'en'): string {
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
// For very recent updates, show relative time
|
||||
if (days < 1) {
|
||||
if (hours >= 1) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
if (minutes >= 1) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
// For older updates, show date
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translation strings for a given locale.
|
||||
*/
|
||||
function loadPluginStrings(localeId: string): Record<string, string> {
|
||||
try {
|
||||
const localePath = path.join(i18nDir, `${localeId}.json`);
|
||||
if (fs.existsSync(localePath)) {
|
||||
return JSON.parse(fs.readFileSync(localePath, 'utf8'));
|
||||
}
|
||||
} catch { /* fallback below */ }
|
||||
try {
|
||||
const enPath = path.join(i18nDir, 'en.json');
|
||||
if (fs.existsSync(enPath)) {
|
||||
return JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin translations hook.
|
||||
*/
|
||||
export function translations(localeId: string): Record<string, string> {
|
||||
return loadPluginStrings(localeId || 'en');
|
||||
}
|
||||
|
||||
let pluginOptions: any = {};
|
||||
|
||||
export function onConfigResolved(config: any): void {
|
||||
gitCache.clear();
|
||||
gitRootCache.clear();
|
||||
_gitIndexingDone = false;
|
||||
_engineLoadAttempted = false; // Reset engine state for fresh builds
|
||||
_engine = null;
|
||||
_diskCache = null; // Reset disk cache so it re-reads from the correct path
|
||||
_diskCachePath = null;
|
||||
_cacheDirty = false;
|
||||
_configuredEngine = config.engine || 'rust'; // Respect the config.engine key
|
||||
_configuredTmpDir = config.tmp || null; // Respect the config.tmp custom path
|
||||
pluginOptions = config.plugins?.git || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* onBeforeParse: stub for future use (per-page processing).
|
||||
*/
|
||||
export function onBeforeParse(_ctx: any): void {
|
||||
// Logic removed (moved to onConfigResolved for better performance)
|
||||
}
|
||||
|
||||
export async function onBeforeBuild(ctx: any): Promise<void> {
|
||||
const { pages, tui, options } = ctx;
|
||||
if (!pages || pages.length === 0) return;
|
||||
|
||||
// Deduplicate per build: only the FIRST locale/version pass runs git log.
|
||||
// Subsequent passes pull from the in-memory gitCache, which is already warm.
|
||||
const buildId = (options as any)?._buildId || '';
|
||||
if (buildId !== _gitBuildId) {
|
||||
_gitBuildId = buildId;
|
||||
_gitIndexingDone = false;
|
||||
}
|
||||
|
||||
const commitHistory = pluginOptions?.commitHistory !== false;
|
||||
const maxCommits = commitHistory ? (pluginOptions?.maxCommits || 5) : 1;
|
||||
const showTui = tui && !options?.quiet && !_gitIndexingDone;
|
||||
|
||||
if (_gitIndexingDone) {
|
||||
// Still populate frontmatter from in-memory cache for this locale's pages
|
||||
for (const page of pages) {
|
||||
if (page?.sourcePath && page.frontmatter) {
|
||||
const cached = gitCache.get(page.sourcePath);
|
||||
if (cached) {
|
||||
if (!commitHistory) cached.commits = [];
|
||||
page.frontmatter._git = cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_gitIndexingDone = true;
|
||||
const total = pages.length;
|
||||
|
||||
if (showTui) tui.step(`Syncing git metadata`, 'WAIT');
|
||||
|
||||
// Collect all source paths that need git info.
|
||||
// Pre-warm in-memory cache from disk first so the engine is only called
|
||||
// for uncached or modified files (warm builds benefit from persistence).
|
||||
const pathsToFetch: string[] = [];
|
||||
const pagesByPath = new Map<string, any[]>();
|
||||
|
||||
initDiskCache();
|
||||
for (const page of pages) {
|
||||
const sourcePath = page?.sourcePath;
|
||||
if (sourcePath && page.frontmatter && !gitCache.has(sourcePath)) {
|
||||
let fileStat: any;
|
||||
try { fileStat = fs.statSync(sourcePath); } catch { /* skip */ }
|
||||
// Hit disk cache if file hasn't changed since last build
|
||||
if (fileStat && _diskCache && _diskCache[sourcePath] && _diskCache[sourcePath].mtimeMs === fileStat.mtimeMs) {
|
||||
gitCache.set(sourcePath, _diskCache[sourcePath].info);
|
||||
if (page.frontmatter) page.frontmatter._git = _diskCache[sourcePath].info;
|
||||
} else {
|
||||
pathsToFetch.push(sourcePath);
|
||||
if (!pagesByPath.has(sourcePath)) pagesByPath.set(sourcePath, []);
|
||||
pagesByPath.get(sourcePath)!.push(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to use engine for batch processing (much faster with Rust)
|
||||
const engineResult = await getGitLogViaEngine(pathsToFetch, maxCommits);
|
||||
|
||||
|
||||
if (engineResult && engineResult.size > 0) {
|
||||
// Engine succeeded — process and persist results
|
||||
for (const [filePath, commits] of engineResult) {
|
||||
|
||||
if (commits && commits.length > 0) {
|
||||
let fileStat: any;
|
||||
try { fileStat = fs.statSync(filePath); } catch { /* skip */ }
|
||||
|
||||
const info: GitFileInfo = {
|
||||
lastUpdated: new Date(commits[0].timestamp).toISOString(),
|
||||
lastUpdatedTimestamp: commits[0].timestamp,
|
||||
commits: commits.map((c: any) => {
|
||||
const hashEmail = crypto.createHash('md5').update((c.email || '').trim().toLowerCase()).digest('hex');
|
||||
return {
|
||||
hash: c.hash,
|
||||
shortHash: c.shortHash,
|
||||
author: c.author,
|
||||
email: c.email,
|
||||
date: new Date(c.timestamp).toISOString(),
|
||||
timestamp: c.timestamp,
|
||||
message: c.message,
|
||||
avatarUrl: `https://www.gravatar.com/avatar/${hashEmail}?d=mp&s=64`
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
if (!commitHistory) info.commits = [];
|
||||
gitCache.set(filePath, info);
|
||||
|
||||
// Persist to disk cache so subsequent builds are fast (warm start)
|
||||
if (_diskCache && fileStat) {
|
||||
_diskCache[filePath] = { mtimeMs: fileStat.mtimeMs, info };
|
||||
_cacheDirty = true;
|
||||
}
|
||||
|
||||
// Update all pages with this source path
|
||||
const pagesForPath = pagesByPath.get(filePath) || [];
|
||||
for (const page of pagesForPath) {
|
||||
if (page.frontmatter) page.frontmatter._git = info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showTui) tui.step(`Syncing git metadata`, 'DONE');
|
||||
} else {
|
||||
// Fallback to original execFile-based implementation
|
||||
let processed = 0;
|
||||
const CONCURRENCY = 10;
|
||||
|
||||
for (let i = 0; i < total; i += CONCURRENCY) {
|
||||
const batch = pages.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(batch.map(async (page: any) => {
|
||||
const sourcePath = page?.sourcePath;
|
||||
if (sourcePath && page.frontmatter) {
|
||||
const gitInfo = await getGitFileInfo(sourcePath, maxCommits);
|
||||
if (gitInfo) {
|
||||
if (!commitHistory) gitInfo.commits = [];
|
||||
page.frontmatter._git = gitInfo;
|
||||
}
|
||||
}
|
||||
}));
|
||||
processed += batch.length;
|
||||
if (showTui) tui.step(`Syncing git metadata ${processed}/${total}`, 'WAIT');
|
||||
}
|
||||
|
||||
if (showTui) tui.step(`Syncing git metadata`, 'DONE');
|
||||
}
|
||||
|
||||
// Also populate pages that were already in cache
|
||||
for (const page of pages) {
|
||||
if (page?.sourcePath && page.frontmatter && !page.frontmatter._git) {
|
||||
const cached = gitCache.get(page.sourcePath);
|
||||
if (cached) {
|
||||
if (!commitHistory) cached.commits = [];
|
||||
page.frontmatter._git = cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onPostBuild(): void {
|
||||
saveDiskCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate scripts to inject git i18n strings for the client widget.
|
||||
*/
|
||||
export function generateScripts(config: any, options?: any): { headScriptsHtml: string; bodyScriptsHtml: string } {
|
||||
const gitConfig = {
|
||||
repo: options?.repo || config.editLink?.baseUrl || null,
|
||||
branch: options?.branch || 'main',
|
||||
editLink: options?.editLink !== false && !!(options?.repo || config.editLink?.baseUrl),
|
||||
lastUpdated: options?.lastUpdated !== false,
|
||||
commitHistory: options?.commitHistory !== false,
|
||||
maxCommits: options?.maxCommits || 5,
|
||||
dateFormat: options?.dateFormat || 'relative'
|
||||
};
|
||||
|
||||
const localeId = config._activeLocale?.id || 'en';
|
||||
const i18nStrings = JSON.stringify(loadPluginStrings(localeId));
|
||||
|
||||
return {
|
||||
headScriptsHtml: '',
|
||||
bodyScriptsHtml: `<script>window.__git_config=${JSON.stringify(gitConfig)};window.__git_i18n=${i18nStrings}</script>`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide client-side assets.
|
||||
* Always returns assets - client-side JS handles graceful degradation.
|
||||
*/
|
||||
export function getAssets(_options?: any): any[] {
|
||||
// Always include assets - client-side JS handles visibility based on git status
|
||||
const distDir = path.resolve(__dirname, '..', 'dist', 'client');
|
||||
return [
|
||||
{
|
||||
src: path.join(distDir, 'git-widget.js'),
|
||||
dest: 'assets/js/docmd-git.js',
|
||||
type: 'js',
|
||||
location: 'body',
|
||||
attributes: { type: 'module' }
|
||||
},
|
||||
{
|
||||
src: path.join(distDir, 'git-widget.css'),
|
||||
dest: 'assets/css/docmd-git.css',
|
||||
type: 'css',
|
||||
location: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export { getGitFileInfo, formatLastUpdated };
|
||||
Reference in New Issue
Block a user