is-generated.mjs

 1/**
 2 * Decide whether a given file is "generated" (regenerated by a build step,
 3 * unsafe to write variants into) or "source" (safe to edit, changes persist).
 4 *
 5 * Why this matters: when the user picks an element on a page whose underlying
 6 * file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
 7 * rewriting `public/docs/*.html`), writing variants or accepted changes into
 8 * that file is silent data loss — the next build wipes them.
 9 *
10 * Signals, in order of reliability:
11 *   1. Git check-ignore: gitignored files are assumed generated.
12 *   2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
13 *      within the first ~300 characters — catches non-git projects.
14 */
15
16import { execSync } from 'node:child_process';
17import fs from 'node:fs';
18import path from 'node:path';
19
20const HEADER_SCAN_BYTES = 300;
21const HEADER_MARKERS = [
22  /@generated\b/i,
23  /\bGENERATED\s+FILE\b/,
24  /\bAUTO-?GENERATED\b/i,
25  /\bDO\s+NOT\s+EDIT\b/i,
26];
27
28/**
29 * @param {string} filePath - absolute or cwd-relative path
30 * @param {object} [options]
31 * @param {string} [options.cwd] - project root (defaults to process.cwd())
32 */
33export function isGeneratedFile(filePath, options = {}) {
34  const cwd = options.cwd || process.cwd();
35  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
36
37  if (isGitIgnored(absPath, cwd)) return true;
38  if (hasGeneratedHeader(absPath)) return true;
39  return false;
40}
41
42function isGitIgnored(absPath, cwd) {
43  try {
44    execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
45      cwd,
46      stdio: 'ignore',
47    });
48    return true; // exit 0 = ignored
49  } catch (err) {
50    // Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
51    // In both cases, treat as "not known to be ignored."
52    return false;
53  }
54}
55
56function hasGeneratedHeader(absPath) {
57  let fd;
58  try {
59    fd = fs.openSync(absPath, 'r');
60    const buf = Buffer.alloc(HEADER_SCAN_BYTES);
61    const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
62    const head = buf.slice(0, bytesRead).toString('utf-8');
63    return HEADER_MARKERS.some((re) => re.test(head));
64  } catch {
65    return false;
66  } finally {
67    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
68  }
69}