← back to Dw Fleet Registry

fanout.mjs

160 lines

#!/usr/bin/env node
// Fleet rebuild fan-out (Steve 2026-06-01: "unique sites for every site").
// Per site, on a branch, LOCAL ONLY (prod frozen):
//   1. drop in dw-header.js (hamburgers UL + centered name) + per-site config
//   2. disable the upper-right corner-nav
//   3. accent → assigned-template colour (clear COLOR-template drift cases only)
//   4. unique HIGH-RES (>=1500px) niche hero from the site's own catalog slice,
//      deduped against a fleet-wide ledger so NO image repeats
// Defensive: any per-site failure is caught, flagged, and the site is skipped —
// commits happen only on success. Resumable via hero-ledger.json.
//
//   node fanout.mjs                 # default batch = the audit's drift sites
//   node fanout.mjs slugA slugB ... # explicit site list

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';

const PROJECTS = path.join(os.homedir(), 'Projects');
const SELF = path.dirname(new URL(import.meta.url).pathname);
const COMPONENT = path.join(SELF, 'component', 'dw-header.js');
const LEDGER_PATH = path.join(SELF, 'hero-ledger.json');
const audit = JSON.parse(fs.readFileSync(path.join(SELF, 'template-audit.json'), 'utf8'));
const auditBySlug = Object.fromEntries(audit.sites.map(s => [s.slug, s]));

const DEFAULT = ['metallicwallpaper','mylarwallpaper','fabricwallpaper','apartmentwallpaper','selfadhesivewallpaper','wallpapersback'];
const HEADER_ONLY = process.argv.includes('--header-only');   // header+accent only, no hero (for niche-starved / reference sites)
const argSlugs = process.argv.slice(2).filter(a => !a.startsWith('--'));
const targets = argSlugs.length ? argSlugs : DEFAULT;

const ledger = fs.existsSync(LEDGER_PATH) ? JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')) : { usedUrls: {}, sites: {} };
const sh = (cmd, cwd) => execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
const titleCase = s => s.replace(/(wallcoverings?|wallpapers?|walls?|fabric|covering|paper|cover)/gi, ' $1').replace(/\s+/g,' ').trim().replace(/\b\w/g, c => c.toUpperCase());

function probeDims(file) {
  try { const o = execSync(`sips -g pixelWidth -g pixelHeight "${file}" 2>/dev/null`).toString();
    return { w:+(o.match(/pixelWidth: (\d+)/)||[])[1]||0, h:+(o.match(/pixelHeight: (\d+)/)||[])[1]||0 }; }
  catch { return { w:0, h:0 }; }
}

// niche keywords for a site: from its server.js NICHE_POS, else derived from the slug.
let CATALOG = null;
function catalogPool() { if (CATALOG) return CATALOG; try { CATALOG = JSON.parse(fs.readFileSync(path.join(SELF, 'catalog-hires.json'), 'utf8')).pool; } catch { CATALOG = []; } return CATALOG; }
function nicheKeywords(slug) {
  try { const sv = fs.readFileSync(path.join(PROJECTS, slug, 'server.js'), 'utf8');
    const m = sv.match(/NICHE_POS\s*=\s*\[([^\]]*)\]/);
    if (m) { const ks = m[1].split(',').map(x => x.replace(/["'\s]/g, '')).filter(Boolean); if (ks.length) return ks; } } catch {}
  return slug.toLowerCase().replace(/(wallcoverings?|wallpapers?|walls?|covering)/g, ' ').trim().split(/\s+/).filter(w => w.length > 2);
}

// pick a UNIQUE truly-high-res hero. Priority: (1) niche-matched ROOM setting,
// (2) niche-matched high-res pattern, (3) ANY unused high-res image (so every site
// gets a real photo — no gradients). Rooms are scarce so most sites land on (2)/(3).
// catalog-hires.json now classifies each image kind: room | pattern | swatch.
// Priority: niche ROOM → niche pattern → ANY room (Steve prefers rooms) → niche/any pattern.
function pickHero(slug) {
  const niche = nicheKeywords(slug).map(k => k.toLowerCase());
  const pool = catalogPool();
  const rooms = pool.filter(im => im.kind === 'room');
  const patterns = pool.filter(im => im.kind === 'pattern');   // swatches excluded — too small/flat for a hero
  const unused = im => !ledger.usedUrls[im.src];
  const nmatch = im => niche.some(k => k && im.meta.includes(k));
  let cand, kind;
  cand = rooms.filter(im => unused(im) && nmatch(im));                                  // 1. niche room
  kind = 'room';
  if (!cand.length) { cand = patterns.filter(im => unused(im) && nmatch(im)); kind = 'pattern'; }   // 2. niche pattern
  if (!cand.length) { cand = rooms.filter(unused); kind = 'room'; }                     // 3. ANY room (room > off-niche pattern)
  if (!cand.length) { cand = patterns.filter(unused); kind = 'pattern'; }               // 4. any high-res pattern
  if (cand.length) {
    const idx = [...slug].reduce((a, c) => a + c.charCodeAt(0), 0) % cand.length;
    const im = cand[idx];
    return { url: im.src, base: im.src.split('?')[0], poolDims: { w: im.w, h: im.h }, kind };
  }
  return null;
}

const results = [];
for (const slug of targets) {
  const r = { slug, steps: [] };
  if (ledger.sites[slug]) { r.status = 'SKIP'; r.why = 'already rebuilt'; r.hero = (ledger.sites[slug].dims||{}).w + 'x' + (ledger.sites[slug].dims||{}).h; results.push(r); console.log(`SKIP  ${slug.padEnd(22)} already rebuilt`); continue; }
  try {
    const dir = path.join(PROJECTS, slug);
    const idxPath = path.join(dir, 'public', 'index.html');
    if (!fs.existsSync(idxPath)) { r.status = 'SKIP'; r.why = 'no public/index.html'; results.push(r); continue; }

    // hero FIRST (so we don't branch/commit a site we can't give a unique hero).
    // In --header-only mode we skip the hero gate entirely (header is hero-independent).
    let hero = null;
    if (!HEADER_ONLY) {
      hero = pickHero(slug);
      if (!hero) { r.status = 'SKIP'; r.why = 'no unique high-res hero candidate in catalog'; results.push(r); continue; }
    }

    sh(`git checkout -B style-rebuild-pilot`, dir);

    // 1. component
    fs.copyFileSync(COMPONENT, path.join(dir, 'public', 'dw-header.js'));
    r.steps.push('header-component');

    // 2. inject config + script (additive, idempotent) + disable corner-nav
    let html = fs.readFileSync(idxPath, 'utf8');
    const a = auditBySlug[slug] || {};
    const accent = (a.expected && String(a.expected).startsWith('#')) ? a.expected : '#9B7B4F';
    const name = titleCase(slug);
    if (!html.includes('/dw-header.js')) {
      const cfg = `<script>window.DwHeaderConfig={siteName:${JSON.stringify(name)},accent:${JSON.stringify(accent)},`
        + `navLinks:[{name:'Home',url:'/'},{name:'Collections',url:'#shop'},{name:'About',url:'/about'},{name:'Care',url:'/care.html'},{name:'History',url:'/history.html'}],`
        + `adminLinks:[{name:'Shopify admin',url:'https://admin.shopify.com',external:true},{name:'Edit hero',url:'#'},{name:'Analytics',url:'#'}]};</script>\n`
        + `<script src="/dw-header.js" defer></script>\n`;
      html = html.replace('</body>', cfg + '</body>');
      r.steps.push('header-injected');
    }
    if (html.includes('src="/corner-nav.js"')) {
      html = html.replace(/<script src="\/corner-nav\.js"[^>]*><\/script>/g, '<!-- corner-nav disabled (UL-hamburger rebuild) -->');
      r.steps.push('corner-nav-off');
    }

    // 3. accent — only for clear COLOR-template drift (audit DRIFT + hex expected + hex actual)
    if (a.status === 'DRIFT' && String(a.expected).startsWith('#') && a.actualAccent && a.actualAccent.startsWith('#')) {
      const re = new RegExp('(--accent\\s*:\\s*)' + a.actualAccent.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi');
      const before = html;
      html = html.replace(re, '$1' + a.expected);
      if (html !== before) r.steps.push(`accent ${a.actualAccent}->${a.expected}`);
      else r.steps.push('accent-skip (pattern not found)');
    } else {
      r.steps.push('accent-skip (mono/neutral or no drift)');
    }
    fs.writeFileSync(idxPath, html);

    // 4. unique hero (skipped in header-only mode)
    if (!HEADER_ONLY && hero) {
      execSync(`curl -s --max-time 25 "${hero.base}?width=2400" -o "${path.join(dir,'public','hero-bg.jpg')}"`, { stdio:'ignore' });
      const dims = probeDims(path.join(dir,'public','hero-bg.jpg'));
      ledger.usedUrls[hero.url] = slug;
      ledger.sites[slug] = { url: hero.url, dims, kind: hero.kind };
      r.hero = `${dims.w}x${dims.h} ${hero.kind}`;
      r.steps.push('hero-unique');
    } else { r.steps.push('header-only (no hero)'); }

    // commit
    sh(`git add -A`, dir);
    const msg = HEADER_ONLY ? 'fleet rebuild (header-only): dw-header (hamburgers UL + centered name) + accent — local branch'
                            : 'fleet rebuild: dw-header (hamburgers UL + centered name) + unique high-res hero + accent — local branch';
    sh(`git -c user.email="steve@designerwallcoverings.com" -c user.name="Steve Abrams" commit -q -m "${msg}"`, dir);
    r.commit = sh(`git rev-parse --short HEAD`, dir);
    r.status = 'DONE';
  } catch (e) {
    r.status = 'FAIL';
    r.why = String(e.message || e).split('\n')[0].slice(0, 160);
  }
  results.push(r);
  console.log(`${(r.status||'?').padEnd(5)} ${slug.padEnd(22)} ${r.hero||''} ${r.commit||''} ${r.why||''}`);
}

fs.writeFileSync(LEDGER_PATH, JSON.stringify(ledger, null, 2) + '\n');
fs.writeFileSync(path.join(SELF, 'fanout-report.json'), JSON.stringify({ ranAt: new Date().toISOString(), results }, null, 2) + '\n');
const done = results.filter(r=>r.status==='DONE').length;
console.log(`\n${done}/${results.length} sites rebuilt · ledger has ${Object.keys(ledger.usedUrls).length} unique heroes · report: fanout-report.json`);