← 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`);