website

#astro#js#html#css

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;
};