← back to Ga4 Fleet

inject-gtag.mjs

109 lines

#!/usr/bin/env node
// gtag injector — inserts the GA4 gtag snippet into a site's <head>, idempotently.
// Half two of the fleet pipeline: once provision.mjs fills measurement-ids.json with
// domain -> G-id, this wires each site's code.
//
// SAFE BY DESIGN:
//   - Static-HTML sites (public/*.html): injects after the first <head> in each page,
//     skips any page that already carries the id. Pure string insert, reversible.
//   - JS-template/Express sites (a <head> living inside a `...` template literal): does
//     NOT auto-edit — auto-inserting into a template literal risks a syntax break (the
//     assetv-style 502). It FLAGS them for a targeted per-repo edit instead.
//
// USAGE:
//   node inject-gtag.mjs --repo <path> --domain <d> --id G-XXXX            # DRY RUN
//   node inject-gtag.mjs --repo <path> --domain <d> --id G-XXXX --commit
//   node inject-gtag.mjs --all                                             # iterate registry via repo-map.json

import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

const DIR = '/Users/macstudio3/Projects/ga4-fleet';
const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const val = (f, d) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : d; };
const COMMIT = has('--commit');

const snippet = (id) =>
  `<!-- Google tag (gtag.js) — GA4 ${id} -->\n` +
  `<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>\n` +
  `<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${id}');</script>`;

function walk(dir, out = [], depth = 0) {
  if (depth > 4 || !existsSync(dir)) return out;
  for (const f of readdirSync(dir)) {
    if (['node_modules', '.git', 'dist', 'build', '.next', 'tmp'].includes(f)) continue;
    const p = join(dir, f);
    let s; try { s = statSync(p); } catch { continue; }
    if (s.isDirectory()) walk(p, out, depth + 1);
    else if (/\.html?$/.test(f)) out.push(p);
  }
  return out;
}

// Inject into every static HTML page's <head>. Returns {changed, skipped, files}.
function injectStatic(repo, id) {
  const pages = walk(repo).filter((p) => !/curator|admin|seam|debug|ghost|internal|\/tests?\//i.test(p));
  const changed = [], skipped = [];
  for (const p of pages) {
    let html; try { html = readFileSync(p, 'utf8'); } catch { continue; }
    if (!/<head[\s>]/i.test(html)) continue;            // not a full page
    if (html.includes(id)) { skipped.push(p); continue; } // already has THIS id
    if (/gtag\/js\?id=G-/.test(html)) { skipped.push(p + ' (has a DIFFERENT gtag — left as-is)'); continue; }
    const injected = html.replace(/(<head[^>]*>)/i, `$1\n${snippet(id)}`);
    if (COMMIT) writeFileSync(p, injected);
    changed.push(p);
  }
  return { changed, skipped };
}

// Detect a server-rendered <head> inside a JS template literal (Express layout). Flag, don't edit.
function detectTemplate(repo) {
  const hits = [];
  for (const p of walk(repo.replace(/\/public$/, '')).length ? [] : []) {} // (html handled above)
  const jsFiles = [];
  (function jw(d, depth = 0) {
    if (depth > 3 || !existsSync(d)) return;
    for (const f of readdirSync(d)) {
      if (['node_modules', '.git'].includes(f)) continue;
      const pp = join(d, f); let s; try { s = statSync(pp); } catch { continue; }
      if (s.isDirectory()) jw(pp, depth + 1);
      else if (/\.(js|mjs|cjs)$/.test(f)) jsFiles.push(pp);
    }
  })(repo);
  for (const p of jsFiles) {
    let t; try { t = readFileSync(p, 'utf8'); } catch { continue; }
    if (/`[^`]*<head[\s>]/i.test(t)) hits.push(p);       // <head> inside a backtick template
  }
  return hits;
}

function runOne(repo, domain, id) {
  if (!existsSync(repo)) return console.log(`MISS  ${domain} — repo not found: ${repo}`);
  const st = injectStatic(repo, id);
  const tmpl = detectTemplate(repo);
  console.log(`\n== ${domain} (${id}) — ${repo}`);
  if (st.changed.length) console.log(`  ${COMMIT ? 'INJECTED' : 'would inject'} into ${st.changed.length} page(s):\n    ${st.changed.slice(0, 8).map((p) => p.replace(repo, '.')).join('\n    ')}${st.changed.length > 8 ? '\n    …' : ''}`);
  if (st.skipped.length) console.log(`  skipped ${st.skipped.length} (already tagged / different id)`);
  if (tmpl.length) console.log(`  ⚠ FLAG: ${tmpl.length} JS-template head(s) — needs targeted edit (not auto-injected):\n    ${tmpl.slice(0, 5).map((p) => p.replace(repo, '.')).join('\n    ')}`);
  if (!st.changed.length && !st.skipped.length && !tmpl.length) console.log('  no <head> found (SPA shell or non-standard) — manual review');
}

// ---- main ----
console.log(COMMIT ? '=== COMMIT ===' : '=== DRY RUN (add --commit to write) ===');
if (has('--all')) {
  const reg = existsSync(`${DIR}/measurement-ids.json`) ? JSON.parse(readFileSync(`${DIR}/measurement-ids.json`, 'utf8')) : {};
  const map = existsSync(`${DIR}/repo-map.json`) ? JSON.parse(readFileSync(`${DIR}/repo-map.json`, 'utf8')) : {};
  const domains = Object.keys(reg);
  if (!domains.length) { console.log('measurement-ids.json is empty — run provision.mjs first.'); process.exit(0); }
  for (const d of domains) {
    const repo = map[d];
    if (!repo) { console.log(`MAP?  ${d} -> ${reg[d]} — no repo in repo-map.json (add it)`); continue; }
    runOne(repo, d, reg[d]);
  }
} else {
  const repo = val('--repo'), domain = val('--domain'), id = val('--id');
  if (!repo || !domain || !id) { console.log('usage: --repo <path> --domain <d> --id G-XXXX [--commit]  |  --all'); process.exit(1); }
  runOne(repo, domain, id);
}