← back to Microsite Engine

server.js

368 lines

/**
 * microsite-engine — config-generator dashboard that DRIVES the dw-domain-fleet.
 *
 * It never reinvents the fleet's render/sort/catalog logic and never writes into
 * the live fleet or deploys. It:
 *   - loads the fleet's REAL data/catalog.json + shared/{catalog,sort}.js so the
 *     preview is byte-identical to what production would render,
 *   - previews a niche live (match count + sortable/dense product grid),
 *   - emits a deploy-ready sites/<slug>.json in the fleet's exact schema into
 *     THIS project's own output/ dir (gated: Steve copies it to the fleet).
 *
 * Read-only against the fleet. $0 (local).
 */
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');

const PORT         = parseInt(process.env.PORT || '9770', 10);
const FLEET        = process.env.FLEET_DIR || path.join(process.env.HOME, 'Projects', 'dw-domain-fleet');
const OUTPUT       = path.join(__dirname, 'output');
const DRAFTS       = path.join(__dirname, 'data', 'drafts.json');
const PORT_BASE    = 10001;  // first candidate port for new fleet sites
const PREVIEW_MAX  = 4000;   // nicheSlice ceiling for preview
const PREVIEW_GRID = 60;     // max cards rendered in the preview grid
fs.mkdirSync(OUTPUT, { recursive: true });
fs.mkdirSync(path.dirname(DRAFTS), { recursive: true });

// ---- load the fleet's REAL modules + catalog (identical logic to production) ----
let catalog, sortMod, FLEET_OK = true, FLEET_ERR = '';
try {
  catalog = require(path.join(FLEET, 'shared', 'catalog.js'));
  sortMod = require(path.join(FLEET, 'shared', 'sort.js'));
} catch (e) {
  FLEET_OK = false;
  FLEET_ERR = e.message;
  console.error('[engine] could not load fleet shared modules:', e.message);
  // graceful stubs so the dashboard still boots and reports the problem
  catalog = { CLEAN: [], nicheSlice: () => [], normType: (t) => t || 'Other', count: 0 };
  sortMod = { sortProducts: (l) => l, dominantHex: () => '#9a9a9a' };
}

// ---- derive real facets (product types + top tags) once ----
function buildFacets() {
  const types = {}, tags = {};
  for (const p of catalog.CLEAN) {
    const t = catalog.normType(p.product_type);
    types[t] = (types[t] || 0) + 1;
    for (const tag of (p.tags || [])) {
      const k = String(tag).trim();
      if (!k || /^AI-Analyzed/i.test(k) || k.length > 28) continue;
      tags[k] = (tags[k] || 0) + 1;
    }
  }
  const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, n }));
  const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).slice(0, 120).map(([name, n]) => ({ name, n }));
  return { typeList, tagList, total: catalog.CLEAN.length };
}
const FACETS = buildFacets();

// ---- owned-domain portfolio (READ-ONLY from CNCP) + computed site ideas ----
// The point of this tool is pairing a domain Steve ALREADY OWNS with a catalog
// niche. So the slug/domain fields get a dropdown of owned, non-expired domains
// not yet used by the live fleet, and an "ideas" dropdown pairs each such domain
// with the catalog tags its name contains (real product counts, $0 local).
const CNCP_CONFIG = process.env.CNCP_CONFIG || path.join(process.env.HOME, 'cncp-starter', 'cncp-config.json');
function ownedDomains() {
  try {
    const c = JSON.parse(fs.readFileSync(CNCP_CONFIG, 'utf8'));
    // CNCP entries use `name` (older) or `domain` (newer) as the key
    return (c.domains || [])
      .filter(d => (d.name || d.domain) && d.status !== 'expired')
      .map(d => (d.name || d.domain).toLowerCase());
  } catch { return []; }
}
// greedy longest-match segmentation of a domain base ("corkwallcovering" →
// ["cork","wallcovering"]) against the catalog vocabulary + trade filler words
const FILLER_WORDS = ['wallcoverings', 'wallcovering', 'wallpapers', 'wallpaper', 'designer', 'design',
  'walls', 'wall', 'the', 'shop', 'store', 'and', 'sales', 'reps', 'representatives', 'agents',
  'brokers', 'domain', 'domains', 'news', 'live', 'demand', 'online', 'home', 'decor', 'group', 'losangeles'];
// compound words that segment as one token but display as several
const DISPLAY_FIX = { losangeles: 'Los Angeles' };
// DP segmentation: maximize recognized-character coverage (tie: fewer words), so
// "hospitalitysalesreps" prefers hospitality+sales+reps over the plural variant
// "hospitalitys" + garbage. Unmatched runs survive as their own word.
function segmentBase(base, vocab) {
  const n = base.length;
  const best = new Array(n + 1);
  best[n] = { cov: 0, cnt: 0, next: n, word: null };
  for (let i = n - 1; i >= 0; i--) {
    let b = { cov: best[i + 1].cov, cnt: best[i + 1].cnt + 1, next: i + 1, word: null };
    for (const w of vocab) {
      if (!base.startsWith(w, i)) continue;
      const nxt = best[i + w.length];
      const cov = nxt.cov + w.length, cnt = nxt.cnt + 1;
      if (cov > b.cov || (cov === b.cov && cnt < b.cnt)) b = { cov, cnt, next: i + w.length, word: w };
    }
    best[i] = b;
  }
  const words = [];
  let i = 0, unk = '';
  while (i < n) {
    const s = best[i];
    if (s.word) { if (unk) { words.push(unk); unk = ''; } words.push(s.word); }
    else unk += base[i];
    i = s.next;
  }
  if (unk) words.push(unk);
  return words;
}
function domainIdeas() {
  const taken = new Set(liveSites().map(s => (s.domain || '').toLowerCase()));
  const owned = ownedDomains();
  const free = owned.filter(d => !taken.has(d));
  const tagByLower = new Map();
  for (const t of FACETS.tagList) {
    const k = t.name.toLowerCase();
    tagByLower.set(k, t);
    if (!k.endsWith('s')) tagByLower.set(k + 's', t); // "fabrics" → tag "Fabric"
  }
  const vocab = [...new Set([...tagByLower.keys(), ...FILLER_WORDS])]
    .filter(w => w.length >= 3).sort((a, b) => b.length - a.length);
  // EVERY owned domain is selectable to start a process; ones whose name
  // contains a real niche tag rank first (with product counts), the rest
  // follow alphabetically, and fleet-taken domains sit last, labeled.
  const matched = [], plain = [];
  for (const dom of owned) {
    const base = dom.split('.')[0].replace(/[^a-z0-9]/g, '');
    const words = segmentBase(base, vocab);
    // filler words (wallpaper/wallcovering/designer/…) help segmentation but are
    // not a niche — a ranked idea needs at least one REAL niche tag
    const tags = words.filter(w => !FILLER_WORDS.includes(w.replace(/s$/, '')) && !FILLER_WORDS.includes(w))
      .map(w => tagByLower.get(w)).filter(Boolean);
    const idea = {
      domain: dom,
      slug: base,
      siteName: words.map(w => DISPLAY_FIX[w] || (w[0].toUpperCase() + w.slice(1))).join(' '),
      keywords: tags.map(t => t.name),
      count: tags.length ? Math.max(...tags.map(t => t.n)) : 0,
      inFleet: taken.has(dom),
    };
    if (tags.length && !idea.inFleet) matched.push(idea); else plain.push(idea);
  }
  matched.sort((a, b) => b.count - a.count);
  plain.sort((a, b) => (a.inFleet - b.inFleet) || a.domain.localeCompare(b.domain));
  return { domains: free, ideas: [...matched, ...plain] };
}

// ---- read the LIVE fleet's existing sites (READ-ONLY) for collision guard ----
function liveSites() {
  const dir = path.join(FLEET, 'sites');
  const out = [];
  try {
    for (const f of fs.readdirSync(dir)) {
      if (!f.endsWith('.json')) continue;
      try {
        const c = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
        out.push({ slug: c.slug, domain: c.domain, port: c.port });
      } catch { /* skip */ }
    }
  } catch { /* no fleet */ }
  return out;
}
function nextFreePort(live, drafts) {
  const used = new Set([...live, ...drafts].map(s => s.port).filter(Boolean));
  let p = PORT_BASE;
  while (used.has(p)) p++;
  return p;
}

// ---- drafts store (this project only) ----
function loadDrafts() { try { return JSON.parse(fs.readFileSync(DRAFTS, 'utf8')); } catch { return []; } }
function saveDrafts(d) { fs.writeFileSync(DRAFTS, JSON.stringify(d, null, 2)); }

// ---- the fleet's config schema (mirrors sites/<slug>.json exactly) ----
const PALETTES = {
  ivory:   { accent:'#7d3c00', bg:'#14110d', bgLight:'#faf7f2', ink:'#f3efe8', inkLight:'#1a1714', rule:'#d8cec0' },
  sage:    { accent:'#5a6f3e', bg:'#10120c', bgLight:'#f3f4ee', ink:'#eef0e6', inkLight:'#1c1f17', rule:'#cfd4c2' },
  slate:   { accent:'#3a5fb8', bg:'#0d1017', bgLight:'#eef2f8', ink:'#e7ecf5', inkLight:'#141922', rule:'#c4cede' },
  rose:    { accent:'#a83a5a', bg:'#160e11', bgLight:'#faf0f2', ink:'#f3e6ea', inkLight:'#1c1216', rule:'#e2c9d1' },
  charcoal:{ accent:'#8a6d3a', bg:'#0e0e0e', bgLight:'#f5f4f1', ink:'#efece6', inkLight:'#161514', rule:'#d6d2c8' },
};
const FONTS = {
  cormorant: { serif:"'Cormorant Garamond','Playfair Display',Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
  playfair:  { serif:"'Playfair Display',Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
  didot:     { serif:"'GFS Didot',Didot,Georgia,serif", sans:"-apple-system,'Inter',system-ui,sans-serif" },
};

function cleanList(v) {
  if (Array.isArray(v)) return v.map(s => String(s).trim()).filter(Boolean);
  if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
  return [];
}

function buildConfig(body, port) {
  const slug = String(body.slug || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
  const pal = PALETTES[body.palette] || PALETTES.ivory;
  const fnt = FONTS[body.font] || FONTS.cormorant;
  const name = body.siteName || slug;
  const domain = body.domain || (slug + '.com');
  const tagline = body.tagline || 'Designer wallcoverings for every room.';
  return {
    slug,
    domain,
    siteName: name,
    port,
    siteEmail: 'info@' + domain,
    tagline,
    heroHeadline: body.heroHeadline || name,
    heroSub: body.heroSub || 'Explore the collection',
    metaDesc: `${name} — ${tagline} Free memo samples, expert trade service, and hand-holding on every order from Designer Wallcoverings.`,
    nicheLabel: body.nicheLabel || name.toLowerCase(),
    aboutCopy: `${name} curates ${body.nicheLabel || 'designer wallcoverings'} for designers, architects, and discerning homeowners. ${tagline} Every selection is backed by Designer Wallcoverings' decades of trade experience.`,
    niche: {
      pos: cleanList(body.pos),
      neg: cleanList(body.neg),
      types: cleanList(body.types),
    },
    theme: {
      accent: pal.accent, bg: pal.bg, bgLight: pal.bgLight, ink: pal.ink, inkLight: pal.inkLight, rule: pal.rule,
      serif: fnt.serif, sans: fnt.sans,
      palette: body.palette || 'ivory', font: body.font || 'cormorant',
    },
  };
}

// ---- preview: run the REAL nicheSlice + sortProducts ----
function preview(body) {
  const niche = { pos: cleanList(body.pos), neg: cleanList(body.neg), types: cleanList(body.types) };
  const all = catalog.nicheSlice({ ...niche, limit: PREVIEW_MAX });
  const sorted = sortMod.sortProducts(all, body.sort || 'newest');
  const cards = sorted.slice(0, PREVIEW_GRID).map(p => ({
    title: p.title,
    image_url: p.image_url,
    product_type: p.product_type,
    price: p.price,
    hex: sortMod.dominantHex(p),
  }));
  return { count: all.length, shown: cards.length, cards, niche, heroes: heroPick(all) };
}

// ---- hero allocation: mirror the fleet's "niche top-N" hero fallback ----
// The fleet's render.js pulls heroes from data/hero-allocation.json[slug], falling
// back to the niche's top products. We pick the newest matched products with a real
// image, deduped, capped — the deploy-ready hero list for this microsite.
function heroPick(all, n = 8) {
  const seen = new Set();
  const out = [];
  for (const p of sortMod.sortProducts(all, 'newest')) {
    const u = (p.image_url || '').trim();
    if (!u || seen.has(u)) continue;
    seen.add(u);
    out.push(u);
    if (out.length >= n) break;
  }
  return out;
}

// ---- basic auth (house rule for the preview surface) ----
const AUTH = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
function authed(req) {
  if (process.env.NO_AUTH === '1') return true;
  return (req.headers['authorization'] || '') === AUTH;
}

// tiny on-brand favicon (serif "M" monogram in the dashboard's accent) — served
// inline so there's no binary asset to ship. SVG favicons render in modern Chrome/Safari.
const FAVICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
  + '<rect width="32" height="32" rx="6" fill="#12100d"/>'
  + '<text x="16" y="23" text-anchor="middle" font-family="Georgia,serif" font-size="20" font-weight="600" fill="#c98a3a">M</text></svg>';

function send(res, code, type, body) { res.writeHead(code, { 'Content-Type': type }); res.end(body); }
function json(res, code, obj) { send(res, code, 'application/json', JSON.stringify(obj)); }

function readBody(req) {
  return new Promise((resolve) => {
    let b = '';
    req.on('data', c => { b += c; if (b.length > 1e6) { req.destroy(); resolve({}); } });
    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
    req.on('error', () => resolve({}));
  });
}

const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, 'http://localhost');
  const p = u.pathname;

  // favicon — served pre-auth (not sensitive) so browsers stop 404'ing on it
  if (p === '/favicon.ico' || p === '/favicon.svg') {
    res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
    return res.end(FAVICON);
  }

  if (!authed(req)) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="microsite-engine"' });
    return res.end('auth required');
  }

  try {
    if (p === '/' || p === '/index.html') {
      return send(res, 200, 'text/html; charset=utf-8', fs.readFileSync(path.join(__dirname, 'public', 'index.html')));
    }
    if (p === '/api/facets') {
      return json(res, 200, { ...FACETS, ...domainIdeas(), fleetOk: FLEET_OK, fleetErr: FLEET_ERR, palettes: Object.keys(PALETTES), fonts: Object.keys(FONTS) });
    }
    if (p === '/api/preview' && req.method === 'POST') {
      return json(res, 200, preview(await readBody(req)));
    }
    if (p === '/api/live-sites') {
      return json(res, 200, { sites: liveSites() });
    }
    if (p === '/api/drafts') {
      return json(res, 200, { drafts: loadDrafts() });
    }
    if (p === '/api/generate' && req.method === 'POST') {
      const body = await readBody(req);
      const slug = String(body.slug || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
      if (!slug) return json(res, 400, { error: 'slug required' });
      const live = liveSites();
      const drafts = loadDrafts();
      const clashes = [];
      if (live.some(s => s.slug === slug)) clashes.push('slug exists in live fleet');
      if (body.domain && live.some(s => s.domain === body.domain)) clashes.push('domain exists in live fleet');
      const port = nextFreePort(live, drafts.filter(d => d.slug !== slug));
      const cfg = buildConfig(body, port);
      const pv = preview(body);
      // write ONLY to this project's output/ (never the live fleet)
      const outPath = path.join(OUTPUT, slug + '.json');
      fs.writeFileSync(outPath, JSON.stringify(cfg, null, 2));
      // companion heroes file in the fleet's hero-allocation shape { "<slug>": [urls] }.
      // Kept SEPARATE from the config so sites/<slug>.json stays schema-pure.
      const heroPath = path.join(OUTPUT, slug + '.heroes.json');
      fs.writeFileSync(heroPath, JSON.stringify({ [slug]: pv.heroes }, null, 2));
      const draft = {
        slug, domain: cfg.domain, siteName: cfg.siteName, port,
        matchCount: pv.count, niche: cfg.niche, heroCount: pv.heroes.length,
        created_at: new Date().toISOString(),
        outPath, heroPath, status: clashes.length ? 'conflict' : 'draft', clashes,
      };
      const idx = drafts.findIndex(d => d.slug === slug);
      if (idx >= 0) drafts[idx] = draft; else drafts.push(draft);
      saveDrafts(drafts);
      const copyCmd = `cp ${outPath} ${path.join(FLEET, 'sites', slug + '.json')}`;
      const heroCmd = `# heroes → merge into ${path.join(FLEET, 'data', 'hero-allocation.json')}\ncat ${heroPath}`;
      return json(res, 200, { config: cfg, outPath, heroPath, port, matchCount: pv.count, heroes: pv.heroes, clashes, copyCmd, heroCmd, draft });
    }
    if (p === '/api/delete-draft' && req.method === 'POST') {
      const body = await readBody(req);
      const slug = String(body.slug || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
      if (!slug) return json(res, 400, { error: 'slug required' });
      const drafts = loadDrafts().filter(d => d.slug !== slug);
      saveDrafts(drafts);
      try { fs.unlinkSync(path.join(OUTPUT, slug + '.json')); } catch {}
      try { fs.unlinkSync(path.join(OUTPUT, slug + '.heroes.json')); } catch {}
      return json(res, 200, { ok: true });
    }
    return send(res, 404, 'text/plain', 'not found');
  } catch (e) {
    return json(res, 500, { error: e.message });
  }
});

server.listen(PORT, function () {
  console.log(`[microsite-engine] http://localhost:${this.address().port}  (admin / DW2024!)`);
  console.log(`[microsite-engine] fleet: ${FLEET}  catalog: ${FACETS.total} clean products  fleetOk=${FLEET_OK}`);
});