chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# @docmd/plugin-git
Adds Git-based metadata to your docmd site: last-updated timestamps, contributor history, and edit links derived directly from your repository. Core plugin, already included in the core.
```bash
docmd add git
```
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
## Documentation
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
## License
MIT
+39
View File
@@ -0,0 +1,39 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcClientDir = path.resolve(__dirname, 'src', 'client');
const distDir = path.resolve(__dirname, 'dist', 'client');
// Ensure dist/client directory exists
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Copy client files
const files = ['git-widget.js', 'git-widget.css'];
for (const file of files) {
const src = path.join(srcClientDir, file);
const dest = path.join(distDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
console.log(` ${file} → dist/client/`);
}
}
console.log('✓ Client assets built');
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "Zuletzt aktualisiert",
"recentCommits": "Letzte Commits",
"viewCommitHistory": "Commit-Verlauf anzeigen",
"editOnGitHub": "Auf GitHub bearbeiten",
"editOnGitLab": "Auf GitLab bearbeiten",
"editOnBitbucket": "Auf Bitbucket bearbeiten",
"editThisPage": "Seite bearbeiten",
"justNow": "gerade eben",
"minutesAgo": "vor {n} Min.",
"hoursAgo": "vor {n} Std.",
"daysAgo": "vor {n} T."
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "Last updated",
"recentCommits": "Recent commits",
"viewCommitHistory": "View commit history",
"editOnGitHub": "Edit on GitHub",
"editOnGitLab": "Edit on GitLab",
"editOnBitbucket": "Edit on Bitbucket",
"editThisPage": "Edit this page",
"justNow": "just now",
"minutesAgo": "{n}m ago",
"hoursAgo": "{n}h ago",
"daysAgo": "{n}d ago"
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "마지막 업데이트",
"recentCommits": "최근 커밋",
"viewCommitHistory": "커밋 기록 보기",
"editOnGitHub": "GitHub에서 편집",
"editOnGitLab": "GitLab에서 편집",
"editOnBitbucket": "Bitbucket에서 편집",
"editThisPage": "이 페이지 편집",
"justNow": "방금 전",
"minutesAgo": "{n}분 전",
"hoursAgo": "{n}시간 전",
"daysAgo": "{n}일 전"
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "最后更新",
"recentCommits": "最近提交",
"viewCommitHistory": "查看提交历史",
"editOnGitHub": "在 GitHub 上编辑",
"editOnGitLab": "在 GitLab 上编辑",
"editOnBitbucket": "在 Bitbucket 上编辑",
"editThisPage": "编辑此页",
"justNow": "刚刚",
"minutesAgo": "{n}分钟前",
"hoursAgo": "{n}小时前",
"daysAgo": "{n}天前"
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@docmd/plugin-git",
"version": "0.8.12",
"description": "Git integration plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"docmd": {
"key": "git",
"kind": "plugin",
"displayName": "Git",
"tagline": "Git integration with last-updated timestamps and commit history.",
"capabilities": [
"init",
"build",
"body",
"assets",
"post-build",
"translations"
]
},
"scripts": {
"build": "tsc && node build.js"
},
"files": [
"dist",
"i18n"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"git",
"github",
"gitlab",
"last-updated",
"contributors",
"edit-link",
"markdown",
"documentation",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
@@ -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);
})();
+596
View File
@@ -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 };
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}