← back to Dw Vendor Microsites

vendors/justindavid/viewer/server.js

418 lines

#!/usr/bin/env node
/**
 * DW Vendor Microsite — generalized catalog viewer (LOCAL, read-only browse + sample-order).
 *
 * Generalized from ~/Projects/cowtan-lines/viewer/server.js. The engine is
 * vendor-agnostic: buildRows / sorters / facets / the /api/sample-request flow are
 * uniform across every vendor's catalog schema (the staging JSONL is already
 * normalized to the canonical cowtan shape by lib/export-catalog.sh).
 *
 * What is vendor-specific (read from argv / env / fieldmap.json):
 *   - JSONL source path   : argv[3] || env VENDOR_JSONL || ../staging/cowtan.jsonl
 *   - banner / page title : env VENDOR_TITLE || fieldmap.title || 'Vendor Trade Lines'
 *   - samples-only policy  : fieldmap.samples_only (no prices/checkout when true)
 *
 * These lines are intentionally OFF Shopify — the ONLY purchase action is a no-payment
 * sample request. No prices are sold, no Shopify links, no checkout.
 *
 * Zero dependencies. Images are live vendor CDN URLs, used as-is (never upscaled).
 *   node server.js [port] [jsonl] [fieldmap]
 *     port      default 0 = OS-assigned free port
 *     jsonl     default env VENDOR_JSONL || ../staging/cowtan.jsonl
 *     fieldmap  default env VENDOR_FIELDMAP || ./fieldmap.json (optional)
 */
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = parseInt(process.argv[2] || '0', 10);
const DIR = path.join(__dirname, '..', '..', '..', 'staging'); // repo/vendors/<v>/viewer -> repo/staging
const SRC = process.argv[3] || process.env.VENDOR_JSONL || path.join(__dirname, '..', 'staging', 'cowtan.jsonl');
const FIELDMAP_PATH = process.argv[4] || process.env.VENDOR_FIELDMAP || path.join(__dirname, 'fieldmap.json');
const PUBLIC = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');
const REQUESTS = path.join(DATA, 'sample-requests.jsonl');

let FIELDMAP = {};
try { if (fs.existsSync(FIELDMAP_PATH)) FIELDMAP = JSON.parse(fs.readFileSync(FIELDMAP_PATH, 'utf8')); } catch { FIELDMAP = {}; }
const TITLE = process.env.VENDOR_TITLE || FIELDMAP.title || 'Vendor Trade Lines';
const SAMPLES_ONLY = FIELDMAP.samples_only !== false; // default true (samples-only / no checkout)

const readJsonl = (f) => (fs.existsSync(f)
  ? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
  : []);

const num = (v) => { if (v == null) return null; const m = String(v).match(/[\d.]+/); return m ? parseFloat(m[0]) : null; };

// perceived luminance 0..255 from #rrggbb
function lum(hex) {
  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
  const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
// hue 0..360 from #rrggbb (for color-wheel sort)
function hue(hex) {
  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  if (d === 0) return -1; // grayscale -> sort to end
  let h;
  if (mx === r) h = ((g - b) / d) % 6;
  else if (mx === g) h = (b - r) / d + 2;
  else h = (r - g) / d + 4;
  h *= 60; if (h < 0) h += 360;
  return h;
}

// ---- ENHANCED bucketing (Steve's spec) ------------------------------------
// Full HSL from #rrggbb -> {h:0..360, s:0..1, l:0..1} or null.
function hsl(hex) {
  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
  let r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
  if ([r, g, b].some((v) => Number.isNaN(v))) return null;
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
  const l = (mx + mn) / 2;
  let s = 0, h = 0;
  if (d !== 0) {
    s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
    if (mx === r) h = ((g - b) / d) % 6;
    else if (mx === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h *= 60; if (h < 0) h += 360;
  }
  return { h, s, l };
}

// Canonical color families (order = display order; 0-count still listed by facets()).
const COLOR_FAMILIES = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
// ai_colors hint words -> family (fallback when hex absent/ambiguous).
const AI_COLOR_TO_FAMILY = {
  red: 'Red', crimson: 'Red', scarlet: 'Red', burgundy: 'Red', maroon: 'Red', wine: 'Red',
  orange: 'Orange/Brown', terracotta: 'Orange/Brown', rust: 'Orange/Brown', brown: 'Orange/Brown', tan: 'Orange/Brown', camel: 'Orange/Brown', copper: 'Orange/Brown', bronze: 'Orange/Brown', amber: 'Orange/Brown', coffee: 'Orange/Brown', chocolate: 'Orange/Brown',
  yellow: 'Yellow', gold: 'Yellow', mustard: 'Yellow', ochre: 'Yellow',
  green: 'Green', olive: 'Green', sage: 'Green', lime: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green',
  teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
  blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', azure: 'Blue', denim: 'Blue',
  purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
  pink: 'Pink', rose: 'Pink', blush: 'Pink', fuchsia: 'Pink', magenta: 'Pink', coral: 'Pink', salmon: 'Pink',
  beige: 'Neutral/Beige', cream: 'Neutral/Beige', ivory: 'Neutral/Beige', taupe: 'Neutral/Beige', sand: 'Neutral/Beige', linen: 'Neutral/Beige', natural: 'Neutral/Beige', oatmeal: 'Neutral/Beige', greige: 'Neutral/Beige', stone: 'Neutral/Beige',
  black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
  grey: 'Grey', gray: 'Grey', silver: 'Grey', slate: 'Grey', pewter: 'Grey', smoke: 'Grey',
  white: 'White', chalk: 'White', alabaster: 'White', snow: 'White',
};
// hue-degrees -> chromatic family bucket.
function familyFromHue(h) {
  if (h < 15 || h >= 345) return 'Red';
  if (h < 45) return 'Orange/Brown';
  if (h < 70) return 'Yellow';
  if (h < 160) return 'Green';
  if (h < 195) return 'Teal';
  if (h < 255) return 'Blue';
  if (h < 290) return 'Purple';
  if (h < 345) return 'Pink';
  return 'Red';
}
// Derive the color family for a row. hex first (HSL), ai_colors as fallback hint.
function colorFamily(hex, aiColors) {
  const c = hsl(hex);
  if (c) {
    // low-saturation -> achromatic by luminance
    if (c.s < 0.12) {
      if (c.l < 0.18) return 'Black';
      if (c.l > 0.88) return 'White';
      if (c.l > 0.70) return 'Neutral/Beige';
      return 'Grey';
    }
    // low-ish sat + warm hue + mid/high lum -> beige rather than a vivid family
    if (c.s < 0.22 && c.l > 0.62 && (c.h < 70 || c.h >= 330)) return 'Neutral/Beige';
    return familyFromHue(c.h);
  }
  // hex missing/invalid -> ai_colors hint (first matchable word)
  if (Array.isArray(aiColors)) {
    for (const w of aiColors) {
      const key = String(w || '').trim().toLowerCase();
      if (AI_COLOR_TO_FAMILY[key]) return AI_COLOR_TO_FAMILY[key];
      // partial: "dark green" -> green
      for (const word of key.split(/[^a-z]+/)) if (AI_COLOR_TO_FAMILY[word]) return AI_COLOR_TO_FAMILY[word];
    }
  }
  return null;
}

// Canonical styles (display order). Derived from ai_styles + ai_patterns keywords.
const STYLE_BUCKETS = ['Floral', 'Botanical', 'Damask', 'Toile', 'Stripe', 'Geometric', 'Plaid/Check', 'Trellis', 'Animal/Fauna', 'Scenic/Mural', 'Abstract', 'Texture/Plain', 'Metallic', 'Traditional', 'Modern'];
const STYLE_KEYWORDS = [
  ['Floral', ['floral', 'flower', 'rose', 'peony', 'bloom', 'blossom', 'chintz']],
  ['Botanical', ['botanical', 'leaf', 'leaves', 'fern', 'palm', 'foliage', 'tree', 'vine', 'garden', 'jungle', 'tropical']],
  ['Damask', ['damask', 'medallion', 'ogee']],
  ['Toile', ['toile']],
  ['Stripe', ['stripe', 'striped', 'pinstripe', 'ticking']],
  ['Plaid/Check', ['plaid', 'check', 'tartan', 'gingham', 'houndstooth', 'windowpane']],
  ['Trellis', ['trellis', 'lattice', 'fretwork']],
  ['Geometric', ['geometric', 'chevron', 'herringbone', 'diamond', 'hexagon', 'grid', 'dot', 'polka', 'moroccan', 'fret', 'greek key', 'op art']],
  ['Animal/Fauna', ['animal', 'bird', 'butterfly', 'fauna', 'leopard', 'zebra', 'tiger', 'peacock', 'fish', 'insect', 'skin', 'hide']],
  ['Scenic/Mural', ['scenic', 'mural', 'landscape', 'panoramic', 'chinoiserie', 'view', 'cityscape', 'map']],
  ['Metallic', ['metallic', 'foil', 'mica', 'gilt', 'gilded', 'shimmer', 'glitter']],
  ['Texture/Plain', ['texture', 'textured', 'plain', 'solid', 'grasscloth', 'linen', 'woven', 'weave', 'sisal', 'cork', 'faux', 'concrete', 'stucco', 'plaster', 'leather', 'suede', 'tweed']],
  ['Abstract', ['abstract', 'organic', 'watercolor', 'marble', 'painterly', 'brushstroke', 'ombre', 'tie dye', 'splatter']],
  ['Traditional', ['traditional', 'classic', 'heritage', 'colonial', 'victorian']],
  ['Modern', ['modern', 'contemporary', 'minimal', 'mid-century', 'midcentury', 'scandinavian']],
];
// First matching style bucket from the row's ai_styles + ai_patterns words.
function styleBucket(aiStyles, aiPatterns) {
  const words = [];
  for (const arr of [aiStyles, aiPatterns]) if (Array.isArray(arr)) for (const w of arr) words.push(String(w || '').toLowerCase());
  const blob = ' ' + words.join(' ') + ' ';
  for (const [bucket, kws] of STYLE_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
  return null;
}

// Material buckets from material / product_type strings.
const MATERIAL_BUCKETS = ['Non-woven', 'Paper', 'Vinyl', 'Grasscloth', 'Fabric/Textile', 'Cork', 'Mylar/Metallic', 'Leather/Suede', 'Mural', 'Other'];
const MATERIAL_KEYWORDS = [
  ['Non-woven', ['non-woven', 'nonwoven', 'non woven']],
  ['Grasscloth', ['grasscloth', 'grass cloth', 'sisal', 'jute', 'hemp', 'raffia', 'arrowroot', 'seagrass']],
  ['Vinyl', ['vinyl', 'pvc', 'type ii', 'type 2', 'commercial']],
  ['Cork', ['cork']],
  ['Mylar/Metallic', ['mylar', 'metallic', 'foil', 'mica']],
  ['Leather/Suede', ['leather', 'suede', 'ultrasuede', 'hide']],
  ['Mural', ['mural', 'panel', 'panoramic']],
  ['Fabric/Textile', ['fabric', 'textile', 'linen', 'silk', 'cotton', 'wool', 'velvet', 'woven', 'weave']],
  ['Paper', ['paper', 'wallpaper', 'wallcovering', 'pulp']],
];
function materialBucket(material, ptype) {
  const blob = ' ' + (String(material || '') + ' ' + String(ptype || '')).toLowerCase() + ' ';
  if (blob.trim() === '') return null;
  for (const [bucket, kws] of MATERIAL_KEYWORDS) for (const kw of kws) if (blob.includes(kw)) return bucket;
  return null;
}

function buildRows() {
  const rows = [];
  let i = 0;
  for (const r of readJsonl(SRC)) {
    let images = [];
    if (Array.isArray(r.all_images)) images = r.all_images;
    else if (typeof r.all_images === 'string' && r.all_images.trim()) { try { images = JSON.parse(r.all_images); } catch { images = []; } }
    if (!images.length && r.image_url) images = [r.image_url];
    const ptype = (r.product_type || '').replace(/^./, (c) => c.toUpperCase()); // normalize casing
    const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
    const aiColors = parseArr(r.ai_colors), aiStyles = parseArr(r.ai_styles), aiPatterns = parseArr(r.ai_patterns);
    const enriched = (aiColors.length > 0 || aiStyles.length > 0); // ai_colors OR ai_styles non-empty
    rows.push({
      idx: i++,
      sku: r.dw_sku || r.mfr_sku || String(i),
      mfr_sku: r.mfr_sku || null,
      pattern_name: r.pattern_name || r.mfr_sku || '(untitled)',
      color_name: r.color_name || null,
      collection: r.collection || null,
      product_type: ptype || null,
      material: r.material || null,
      width: r.width || null,
      repeat_v: r.repeat_v || null,
      repeat_h: r.repeat_h || null,
      color_primary: r.color_primary || null,
      color_hex: r.color_hex || null,
      ai_colors: aiColors,
      ai_styles: aiStyles,
      ai_patterns: aiPatterns,
      repeat_classification: r.repeat_classification || null,
      finish: r.finish || null,
      application: r.application || null,
      coverage: r.coverage || null,
      fire_rating: r.fire_rating || null,
      line: r.line || r.material || null,
      image: r.image_url || images[0] || null,
      images,
      image_count: images.length,
      // ENHANCED buckets (server-side)
      family: colorFamily(r.color_hex, aiColors),
      style: styleBucket(aiStyles, aiPatterns),
      material_bucket: materialBucket(r.material, ptype),
      enriched,
      _lum: lum(r.color_hex),
      _hue: hue(r.color_hex),
    });
  }
  return rows;
}

let ROWS = buildRows();
fs.watchFile(SRC, { interval: 5000 }, () => { ROWS = buildRows(); });

const cmpStr = (a, b) => String(a == null ? '' : a).localeCompare(String(b == null ? '' : b), undefined, { sensitivity: 'base' });
const nullsLast = (v) => (v == null ? Infinity : v);

const sorters = {
  newest: null, // natural staged order
  pattern: (a, b) => cmpStr(a.pattern_name, b.pattern_name),
  color_name: (a, b) => cmpStr(a.color_name, b.color_name),
  collection: (a, b) => cmpStr(a.collection, b.collection) || cmpStr(a.pattern_name, b.pattern_name),
  line: (a, b) => cmpStr(a.line, b.line) || cmpStr(a.pattern_name, b.pattern_name),
  product_type: (a, b) => cmpStr(a.product_type, b.product_type) || cmpStr(a.pattern_name, b.pattern_name),
  material: (a, b) => cmpStr(a.material, b.material) || cmpStr(a.pattern_name, b.pattern_name),
  // light -> dark: brightest first; missing-hex always sort last
  light: (a, b) => { const la = a._lum == null ? -1 : a._lum, lb = b._lum == null ? -1 : b._lum; return lb - la; },
  // dark -> light: darkest first; missing-hex always sort last
  dark: (a, b) => { const la = a._lum == null ? 999 : a._lum, lb = b._lum == null ? 999 : b._lum; return la - lb; },
  hue: (a, b) => {
    // grayscale (-1) and null sort to the very end
    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue;
    const hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
    return ha - hb;
  },
  sku: (a, b) => cmpStr(a.mfr_sku || a.sku, b.mfr_sku || b.sku),
  // group by canonical color family (display order), nulls last, then by hue within
  family: (a, b) => {
    const ia = a.family == null ? 999 : COLOR_FAMILIES.indexOf(a.family);
    const ib = b.family == null ? 999 : COLOR_FAMILIES.indexOf(b.family);
    if (ia !== ib) return ia - ib;
    const ha = a._hue == null || a._hue < 0 ? 9999 : a._hue, hb = b._hue == null || b._hue < 0 ? 9999 : b._hue;
    return ha - hb;
  },
  // group by canonical style (display order), nulls last, then pattern name
  style: (a, b) => {
    const ia = a.style == null ? 999 : STYLE_BUCKETS.indexOf(a.style);
    const ib = b.style == null ? 999 : STYLE_BUCKETS.indexOf(b.style);
    return ia - ib || cmpStr(a.pattern_name, b.pattern_name);
  },
};

function facets() {
  // ENHANCED facets: color-family / style / material (bucketed) REPLACE raw color_primary
  // as the color facet. Plus enriched, line, product_type, collection.
  const fam = {}, sty = {}, mat = {};
  const f = { line: {}, product_type: {}, collection: {} };
  let enrichedN = 0, unenrichedN = 0;
  for (const r of ROWS) {
    const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
    bump(fam, r.family);
    bump(sty, r.style);
    bump(mat, r.material_bucket);
    bump(f.line, r.line);
    bump(f.product_type, r.product_type);
    bump(f.collection, r.collection);
    if (r.enriched) enrichedN++; else unenrichedN++;
  }
  // canonical-ordered facets: every value listed even at count 0 (UI greys 0s)
  const ordered = (canon, counts) => canon.map((value) => ({ value, count: counts[value] || 0 }));
  // count-desc for free-form facets; collection alpha (searchable)
  const byCount = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
  const byAlpha = (counts) => Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
  return {
    family: ordered(COLOR_FAMILIES, fam),
    style: ordered(STYLE_BUCKETS, sty),
    material_bucket: ordered(MATERIAL_BUCKETS, mat),
    enriched: [
      { value: 'enriched', count: enrichedN },
      { value: 'unenriched', count: unenrichedN },
    ],
    line: byCount(f.line),
    product_type: byCount(f.product_type),
    collection: byAlpha(f.collection),
  };
}

const json = (res, code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };

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

  if (u.pathname === '/api/config') {
    return json(res, 200, { title: TITLE, samples_only: SAMPLES_ONLY, total: ROWS.length,
      shopify: {
        domain: process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
        storefrontToken: process.env.SHOPIFY_STOREFRONT_TOKEN || '',
        sampleVariantId: process.env.SHOPIFY_SAMPLE_VARIANT_ID || ''
      } });
  }

  if (u.pathname === '/api/facets') {
    return json(res, 200, { total: ROWS.length, facets: facets() });
  }

  if (u.pathname === '/api/skus') {
    const q = (u.searchParams.get('q') || '').trim().toLowerCase();
    const sort = u.searchParams.get('sort') || 'newest';
    const offset = parseInt(u.searchParams.get('offset') || '0', 10);
    const limit = Math.min(parseInt(u.searchParams.get('limit') || '60', 10), 200);
    // multi-select facets: pipe-separated ('|'). Buckets + free-form + enriched.
    const sel = (k) => { const v = u.searchParams.get(k); return v ? v.split('|').filter(Boolean) : []; };
    const fFam = sel('family'), fStyle = sel('style'), fMat = sel('material_bucket');
    const fEnr = sel('enriched'); // 'enriched' | 'unenriched' (multi tolerated -> OR)
    const fLine = sel('line'), fType = sel('product_type'), fColl = sel('collection');

    let rows = ROWS;
    const inAny = (arr, val) => arr.length === 0 || arr.includes(val == null ? '' : val);
    const enrMatch = (r) => {
      if (!fEnr.length) return true;
      const wantE = fEnr.includes('enriched'), wantU = fEnr.includes('unenriched');
      return (wantE && r.enriched) || (wantU && !r.enriched);
    };
    rows = rows.filter((r) =>
      inAny(fFam, r.family) && inAny(fStyle, r.style) && inAny(fMat, r.material_bucket) &&
      inAny(fLine, r.line) && inAny(fType, r.product_type) && inAny(fColl, r.collection) && enrMatch(r));
    if (q) {
      rows = rows.filter((r) => (
        (r.pattern_name || '') + ' ' + (r.color_name || '') + ' ' + (r.mfr_sku || '') + ' ' +
        (r.sku || '') + ' ' + (r.collection || '')).toLowerCase().includes(q));
    }
    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
    const page = rows.slice(offset, offset + limit);
    return json(res, 200, { total: rows.length, all: ROWS.length, offset, limit, rows: page });
  }

  if (u.pathname === '/api/sample-request' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
    req.on('end', () => {
      let p; try { p = JSON.parse(body || '{}'); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
      const name = (p.name || '').toString().trim();
      const email = (p.email || '').toString().trim();
      const items = Array.isArray(p.items) ? p.items.slice(0, 200) : [];
      if (!name || !email || !/.+@.+\..+/.test(email) || !items.length) {
        return json(res, 400, { ok: false, error: 'name, valid email, and at least one item are required' });
      }
      const rec = {
        ts: new Date().toISOString(),
        vendor: TITLE,
        name, email,
        company: (p.company || '').toString().trim() || null,
        phone: (p.phone || '').toString().trim() || null,
        address: (p.address || '').toString().trim() || null,
        notes: (p.notes || '').toString().trim() || null,
        items: items.map((it) => ({
          sku: (it.sku || '').toString(),
          mfr_sku: (it.mfr_sku || '').toString() || null,
          pattern_name: (it.pattern_name || '').toString() || null,
          color_name: (it.color_name || '').toString() || null,
          line: (it.line || '').toString() || null,
        })),
        ua: (req.headers['user-agent'] || '').slice(0, 200),
      };
      try {
        fs.mkdirSync(DATA, { recursive: true });
        fs.appendFileSync(REQUESTS, JSON.stringify(rec) + '\n');
      } catch (e) {
        return json(res, 500, { ok: false, error: 'could not save request' });
      }
      return json(res, 200, { ok: true, count: rec.items.length });
    });
    return;
  }

  // static
  let pth = u.pathname === '/' ? '/index.html' : u.pathname;
  const file = path.join(PUBLIC, path.normalize(pth).replace(/^(\.\.[/\\])+/, ''));
  if (!file.startsWith(PUBLIC) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end('not found'); }
  const ext = path.extname(file);
  const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }[ext] || 'text/plain';
  res.writeHead(200, { 'Content-Type': mime });
  res.end(fs.readFileSync(file));
});

server.listen(PORT, () => {
  const addr = server.address();
  console.log(`${TITLE} viewer -> http://localhost:${addr.port}  (${ROWS.length} products${SAMPLES_ONLY ? ', samples-only' : ''})`);
});