← back to Dw Fleet Registry
fill-gap.mjs
121 lines
#!/usr/bin/env node
// Gap-filler for the fanout's niche-starved SKIPs (Steve 2026-06-01: "close the
// 23-site gap first, then deploy"). The main fanout.mjs only assigns a hero when
// the catalog pool has a NICHE-MATCHING high-res image; ~19 microsites have niches
// the pool can't satisfy (cork/silk/suede/string/embroidered + meta/calendar sites).
// This script gives each a UNIQUE high-res hero anyway: prefer a niche match, else
// fall back to ANY unused pool image — still deduped fleet-wide against hero-ledger.json.
// Same treatment as fanout (dw-header.js + corner-nav-off + accent + hero-bg.jpg) on
// the same style-rebuild-pilot branch. LOCAL ONLY. Ledger writes re-read from disk
// per-site so a concurrent fanout can't be clobbered.
//
// node fill-gap.mjs # default = the 19 known niche-starved microsites
// node fill-gap.mjs slugA slugB # explicit 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 CATALOG = JSON.parse(fs.readFileSync(path.join(SELF, 'catalog-hires.json'), 'utf8')).pool;
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());
const readLedger = () => fs.existsSync(LEDGER_PATH) ? JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')) : { usedUrls: {}, sites: {} };
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 }; }
}
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);
}
// Prefer a niche match; otherwise ANY unused pool image. Deterministic by slug so it's stable.
function pickHero(slug, used) {
const niche = nicheKeywords(slug).map(k => k.toLowerCase());
const free = CATALOG.filter(im => !used[im.src]);
const pickFrom = arr => { const idx = [...slug].reduce((a,c)=>a+c.charCodeAt(0),0) % arr.length; const im = arr[idx]; return { url: im.src, base: im.src.split('?')[0], tier: arr===nicheCand?'niche':'any' }; };
const nicheCand = free.filter(im => niche.some(k => k && im.meta.includes(k)));
if (nicheCand.length) return pickFrom(nicheCand);
if (free.length) return pickFrom(free);
return null;
}
const DEFAULT = ['1900swallpaper','blockprintedwallpaper','corkwallcovering','embroideredwallpaper','handcraftedwallpaper','museumwallpaper','recycledwallpaper','screenprintedwallpaper','silkwallpaper','stringwallpaper','suedewallpaper','textiletuesday','wallpaperdefinitions','wallpaperdictionary','wallpaperdistributors','wallpaperestimator','wallpaperglossary','wallpaperhistory','wallpaperwednesday'];
const targets = process.argv.slice(2).length ? process.argv.slice(2) : DEFAULT;
const results = [];
for (const slug of targets) {
const r = { slug, steps: [] };
try {
let ledger = readLedger();
if (ledger.sites[slug]) { r.status = 'SKIP'; r.why = 'already has hero'; results.push(r); console.log(`SKIP ${slug.padEnd(22)} already has hero`); continue; }
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); console.log(`SKIP ${slug.padEnd(22)} no public/index.html`); continue; }
const hero = pickHero(slug, ledger.usedUrls);
if (!hero) { r.status = 'SKIP'; r.why = 'pool exhausted'; results.push(r); continue; }
sh(`git checkout -B style-rebuild-pilot`, dir);
fs.copyFileSync(COMPONENT, path.join(dir, 'public', 'dw-header.js'));
r.steps.push('header-component');
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');
}
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);
r.steps.push(html !== before ? `accent ${a.actualAccent}->${a.expected}` : 'accent-skip (pattern not found)');
} else { r.steps.push('accent-skip (mono/neutral or no drift)'); }
fs.writeFileSync(idxPath, html);
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'));
if (dims.w < 1200 || dims.h < 1200) { throw new Error(`hero too small ${dims.w}x${dims.h}`); }
r.hero = `${dims.w}x${dims.h}`; r.heroTier = hero.tier; r.steps.push(`hero-${hero.tier}`);
sh(`git add -A`, dir);
sh(`git -c user.email="steve@designerwallcoverings.com" -c user.name="Steve Abrams" commit -q -m "fleet rebuild (gap-fill): dw-header (hamburgers UL + centered name) + unique high-res hero + accent — local branch"`, dir);
r.commit = sh(`git rev-parse --short HEAD`, dir);
// merge-safe ledger write: re-read from disk, add ours, write back.
ledger = readLedger();
ledger.usedUrls[hero.url] = slug;
ledger.sites[slug] = { url: hero.url, dims, tier: hero.tier };
fs.writeFileSync(LEDGER_PATH, JSON.stringify(ledger, null, 2) + '\n');
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.heroTier||''} ${r.commit||''} ${r.why||''}`);
}
fs.writeFileSync(path.join(SELF, 'fill-gap-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} gap sites filled · report: fill-gap-report.json`);