[object Object]

← back to Ga4 Fleet

inject-gtag.mjs: idempotent static-HTML gtag injector, flags JS-template heads (tested on wallpapersback)

a99fb18399c289f4e4c6c4ea6756ad2c67638bb1 · 2026-08-03 13:10:23 -0700 · Steve

Files touched

Diff

commit a99fb18399c289f4e4c6c4ea6756ad2c67638bb1
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Aug 3 13:10:23 2026 -0700

    inject-gtag.mjs: idempotent static-HTML gtag injector, flags JS-template heads (tested on wallpapersback)
---
 inject-gtag.mjs | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 108 insertions(+)

diff --git a/inject-gtag.mjs b/inject-gtag.mjs
new file mode 100644
index 0000000..a91f570
--- /dev/null
+++ b/inject-gtag.mjs
@@ -0,0 +1,108 @@
+#!/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);
+}

← 9c3e86b provision.mjs: explicit GA4_SA_KEY only — remove secret-scan  ·  back to Ga4 Fleet  ·  GA4 fleet: repo-map.json (104/402 domains -> local repos; fi a8c9727 →