website
git clone https://git.pyrossh.dev/website
木 Personal website of pyrossh. Built with astrojs, shiki, vite.
src/_helpers/gitRepos.js
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import site from "../_data/site.js";
const execFileAsync = promisify(execFile);
const { repos: REPOS } = site;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
// Repos are real git checkouts that live as siblings of this project, e.g.
// ../config, ../rust-embed — not nested inside this repo.
const REPOS_DIR = path.resolve(PROJECT_ROOT, "..");
export const BINARY_EXTENSIONS = new Set(["apk", "dex", "ap_", "jar", "fnt"]);
export const IMAGE_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "icns",
]);
export const LARGE_FILE_SIZE = 1024 * 512;
const MAX_BUFFER = 1024 * 1024 * 64;
const SEP = "\x1f";
const git = (repoDir, args) =>
execFileSync("git", args, { cwd: repoDir, encoding: "utf8", maxBuffer: MAX_BUFFER });
const gitAsync = async (repoDir, args) =>
(await execFileAsync("git", args, { cwd: repoDir, encoding: "utf8", maxBuffer: MAX_BUFFER })).stdout;
// A repo's own-git-repo-ness can't change mid-build, but this used to be
// re-checked (spawning its own `git rev-parse` subprocess) on every single
// commit/file call — doubling the subprocess count across a build with
// hundreds of commits and files. One check per repoDir is enough.
const ownRepoCache = new Map();
const isOwnGitRepo = (repoDir) => {
if (ownRepoCache.has(repoDir)) return ownRepoCache.get(repoDir);
let result;
try {
const top = git(repoDir, ["rev-parse", "--show-toplevel"]).trim();
result = fs.realpathSync(top) === fs.realpathSync(repoDir);
} catch {
result = false;
}
ownRepoCache.set(repoDir, result);
return result;
};
// Hard safety net independent of git tracking state: never walk into these,
// even if something odd got committed (e.g. node_modules checked in before
// a .gitignore existed — removing it from .gitignore doesn't retroactively
// untrack already-committed files).
const EXCLUDED_DIR_NAMES = new Set([
".git", "node_modules", "dist", "vendor", "target", "build",
"__pycache__", ".venv", ".next", ".nuxt", ".wrangler", "zig-out", ".zig-cache",
]);
const hasExcludedSegment = (relPath) => relPath.split("/").some((seg) => EXCLUDED_DIR_NAMES.has(seg));
const sortNodes = (nodes) => {
nodes.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) if (node.children) sortNodes(node.children);
return nodes;
};
const buildFileTree = (files) => {
const root = [];
for (const file of files) {
const parts = file.path.split("/");
let level = root;
for (let i = 0; i < parts.length; i++) {
const isLast = i === parts.length - 1;
const currentPath = parts.slice(0, i + 1).join("/");
let node = level.find((n) => n.name === parts[i]);
if (!node) {
node = {
name: parts[i],
path: currentPath,
isDirectory: !isLast,
size: file.size,
ext: file.ext,
absolutePath: file.absolutePath,
};
if (!isLast) node.children = [];
level.push(node);
}
if (!isLast) level = node.children;
}
}
return sortNodes(root);
};
const toFileNode = (dir, relPath) => {
const ext = path.extname(relPath).slice(1).toLowerCase();
const { size } = fs.statSync(path.join(dir, relPath));
return { name: relPath, path: relPath, ext, size, absolutePath: relPath, isDirectory: false };
};
const walkFilesRaw = (dir, base) => {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (EXCLUDED_DIR_NAMES.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkFilesRaw(full, base));
} else if (entry.isFile()) {
out.push(toFileNode(base, path.relative(base, full)));
}
}
return out;
};
// Use `git ls-tree -r HEAD` — the committed tree, not the working-directory
// index — so uncommitted local changes (including deletions) don't leak in
// or out. A file listed at HEAD that's missing on disk (e.g. an uncommitted
// delete) is skipped individually rather than aborting the whole listing.
const listTrackedFiles = (repoDir) => {
const out = git(repoDir, ["ls-tree", "-r", "--name-only", "HEAD"]);
return out
.split("\n")
.filter(Boolean)
.filter((relPath) => !hasExcludedSegment(relPath))
.map((relPath) => {
try {
return toFileNode(repoDir, relPath);
} catch {
return null;
}
})
.filter(Boolean);
};
const listFiles = (repoDir) => {
if (isOwnGitRepo(repoDir)) {
try {
return listTrackedFiles(repoDir);
} catch {
// no HEAD yet (freshly initialized, empty repo) — fall through to raw walk
}
}
return walkFilesRaw(repoDir, repoDir).filter((f) => !hasExcludedSegment(f.path));
};
const parseLogFormat = (out) => {
if (!out.trim()) return [];
return out
.split("\n")
.filter(Boolean)
.map((line) => {
const [hash, author, date, message] = line.split(SEP);
return { hash, shortHash: hash.slice(0, 7), author, date, message };
});
};
const getCommits = (repoDir) => {
// repoDir must be a git repo root in its own right — otherwise `git log`
// would silently walk up and return a *parent* repo's history instead.
if (!isOwnGitRepo(repoDir)) return [];
try {
const out = git(repoDir, [
"log",
"--date=iso-strict",
`--pretty=format:%H${SEP}%an${SEP}%ad${SEP}%s`,
]);
return parseLogFormat(out);
} catch {
return [];
}
};
export const getFileHistory = async (repoDir, filePath) => {
if (!isOwnGitRepo(repoDir)) return [];
try {
const out = await gitAsync(repoDir, [
"log",
"--follow",
"--date=iso-strict",
`--pretty=format:%H${SEP}%an${SEP}%ad${SEP}%s`,
"--",
filePath,
]);
return parseLogFormat(out);
} catch {
return [];
}
};
export const getCommitDiff = async (repoDir, hash) => {
if (!isOwnGitRepo(repoDir)) return "";
try {
return await gitAsync(repoDir, ["show", "--no-color", "-p", "--stat", hash]);
} catch {
return "";
}
};
export const getBlame = async (repoDir, filePath) => {
if (!isOwnGitRepo(repoDir)) return [];
try {
const out = await gitAsync(repoDir, ["blame", "--porcelain", "--", filePath]);
const lines = out.split("\n");
const commitInfo = new Map();
const result = [];
let i = 0;
while (i < lines.length) {
const header = lines[i]?.match(/^([0-9a-f]{40,64}) (\d+) (\d+)(?: (\d+))?$/);
if (!header) {
i++;
continue;
}
const hash = header[1];
const finalLine = parseInt(header[3], 10);
if (!commitInfo.has(hash)) commitInfo.set(hash, {});
const info = commitInfo.get(hash);
i++;
while (i < lines.length && !lines[i].startsWith("\t")) {
const line = lines[i];
if (line.startsWith("author ")) info.author = line.slice(7);
else if (line.startsWith("author-time ")) info.time = line.slice(12);
i++;
}
const content = lines[i] !== undefined ? lines[i].slice(1) : "";
result.push({
hash,
shortHash: hash.slice(0, 7),
author: info.author,
date: info.time ? new Date(parseInt(info.time, 10) * 1000).toISOString() : "",
line: finalLine,
content,
});
i++;
}
return result;
} catch {
return [];
}
};
// Cache key material for dataCache.js: the per-commit/per-file data
// (diffs, history, blame, highlighting) computed in repoCommits.js/
// repoFiles.js only depends on the repo's committed state, so it's safe
// to skip recomputing it whenever HEAD hasn't moved — but only when the
// working tree is also clean, since file content there is read straight
// off disk rather than out of git's object store.
export const getHeadSha = (repoDir) => {
if (!isOwnGitRepo(repoDir)) return null;
try {
return git(repoDir, ["rev-parse", "HEAD"]).trim();
} catch {
return null;
}
};
export const isDirty = (repoDir) => {
if (!isOwnGitRepo(repoDir)) return true;
try {
return git(repoDir, ["status", "--porcelain"]).trim().length > 0;
} catch {
return true;
}
};
const readReadme = (repoDir) => {
for (const name of ["README.md", "readme.md", "Readme.md"]) {
const p = path.join(repoDir, name);
if (fs.existsSync(p)) return fs.readFileSync(p, "utf8");
}
return undefined;
};
export const toDataUri = (buf, ext) => {
const mime = ext === "svg" ? "image/svg+xml" : `image/${ext}`;
return `data:${mime};base64,${buf.toString("base64")}`;
};
const isAbsoluteSrc = (src) => /^([a-z][a-z0-9+.-]*:)?\/\//i.test(src) || src.startsWith("data:");
const IMG_SRC_PATTERNS = [
/!\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g,
/<img\b[^>]*?\bsrc\s*=\s*"([^"]*)"/gi,
];
const isLocalRepoImage = (repoDir, src) => {
if (!src || isAbsoluteSrc(src)) return false;
const full = path.resolve(repoDir, src);
if (!(full + path.sep).startsWith(path.resolve(repoDir) + path.sep)) return false;
const ext = path.extname(src).slice(1).toLowerCase();
return IMAGE_EXTENSIONS.has(ext) && fs.existsSync(full);
};
// READMEs commonly reference screenshots with a path relative to the repo
// root (`shots/shot1.png`) rather than a URL — that only renders on GitHub
// because GitHub serves the file itself. The paths this finds get passed to
// eleventy.config.js, which copies just those files to a `raw/` route
// alongside the repo, so resolveReadmeImages can point the README at a real
// URL instead of inlining the bytes as a data URI (which would bloat every
// page load of the README with base64).
export const findReadmeImagePaths = (readme, repoDir) => {
if (!readme) return [];
const found = new Set();
for (const re of IMG_SRC_PATTERNS) {
for (const match of readme.matchAll(re)) {
const src = match[1];
if (isLocalRepoImage(repoDir, src)) found.add(path.posix.normalize(src));
}
}
return [...found];
};
export const resolveReadmeImages = (html, repoId, repoDir) =>
html.replace(/(<img\b[^>]*?\bsrc\s*=\s*")([^"]*)(")/gi, (match, pre, src, post) => {
if (!isLocalRepoImage(repoDir, src)) return match;
return `${pre}/repos/${repoId}/raw/${path.posix.normalize(src)}${post}`;
});
const readCargoVersion = (repoDir) => {
const p = path.join(repoDir, "Cargo.toml");
if (!fs.existsSync(p)) return undefined;
return fs.readFileSync(p, "utf8").match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1];
};
const readPackageJsonVersion = (repoDir) => {
const p = path.join(repoDir, "package.json");
if (!fs.existsSync(p)) return undefined;
try {
return JSON.parse(fs.readFileSync(p, "utf8")).version;
} catch {
return undefined;
}
};
// go.mod has no dedicated version field — Go modules version via git tags,
// not file content — so the only version signal it ever encodes is the
// major-version suffix a module path gets once it reaches v2+ (e.g.
// `module example.com/foo/v3`). That's a major version only, not a full
// semver, but it's the one real thing there is to surface here.
const readGoModVersion = (repoDir) => {
const p = path.join(repoDir, "go.mod");
if (!fs.existsSync(p)) return undefined;
const match = fs.readFileSync(p, "utf8").match(/^module\s+\S+\/v(\d+)\s*$/m);
return match ? `${match[1]}.0.0` : undefined;
};
// pubspec version is `x.y.z` or `x.y.z+buildNumber` (Flutter/Dart) — shown
// as-is, build number included, since that's the literal file content.
const readPubspecVersion = (repoDir) => {
const p = path.join(repoDir, "pubspec.yaml");
if (!fs.existsSync(p)) return undefined;
return fs.readFileSync(p, "utf8").match(/^version:\s*(\S+)/m)?.[1];
};
// Checked rarest-manifest-first: a pubspec.yaml or go.mod only exists
// because the repo IS a Flutter or Go project, but a Cargo.toml or
// package.json can just as easily be secondary tooling living alongside
// the real manifest (e.g. only-bible-app is a Flutter app with its own
// Cargo.toml for a native plugin and a package.json for build scripts —
// neither's version is the app's version).
const readVersion = (repoDir) =>
readPubspecVersion(repoDir) ??
readGoModVersion(repoDir) ??
readCargoVersion(repoDir) ??
readPackageJsonVersion(repoDir);
let cache;
export const discoverLocalRepos = () => {
if (cache) return cache;
if (!fs.existsSync(REPOS_DIR)) {
cache = [];
return cache;
}
const localDirs = new Set(
fs
.readdirSync(REPOS_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name),
);
cache = REPOS.filter((repo) => localDirs.has(repo.title)).map((repo) => {
const dir = path.join(REPOS_DIR, repo.title);
const files = listFiles(dir);
return {
id: repo.title,
data: repo,
dir,
files,
tree: buildFileTree(files),
commits: getCommits(dir),
readme: readReadme(dir),
version: readVersion(dir),
};
});
return cache;
};