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