← back to Thedesignerlibrary

scripts/build-library.mjs

262 lines

#!/usr/bin/env node
// build-library.mjs — reads the local dw_unified mirror and emits:
//   data/library.json   — the 4 shelf datasets (brands / collections / styles / hues)
//   data/products.json  — trimmed product records for the /browse grid API
// $0 local. Run on Mac2 (needs the /tmp PG socket), then deploy the data dir.

import { spawnSync } from 'node:child_process';
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STORE = 'https://www.designerwallcoverings.com';

// Names that must NEVER be customer-facing (private-label upstreams).
// vendor values should already be clean; this is the belt-and-suspenders gate.
// Word-boundary regex so "Versace" / "Rigoletto" / "Versailles" stay innocent
// while true leaks ("Versa 20 oz. Vinyl", "RIGO") are caught.
const LEAK_RE = /\b(wallquest|chesapeake|nextwall|seabrook|command\s?54|desima|carlsten|nicolette\s+mayer|greenland|momentum|versa|rigo|tokiwa|justin\s+david)\b/i;
const leaky = (s) => LEAK_RE.test(s || '');

const SQL = `COPY (
  SELECT row_to_json(t) FROM (
    SELECT id, vendor, title, handle, image_url,
           created_at_shopify AS created,
           metafields#>>'{dwc,collection,value}'  AS collection,
           metafields#>>'{specs,style,value}'     AS style,
           metafields#>>'{custom,color_hex,value}' AS hex,
           tags
    FROM shopify_products
    WHERE status='ACTIVE' AND image_url IS NOT NULL AND vendor IS NOT NULL
  ) t
) TO STDOUT`;

console.log('querying dw_unified mirror…');
const res = spawnSync('psql', ['-h', '/tmp', 'dw_unified', '-c', SQL], {
  encoding: 'utf8', maxBuffer: 1024 * 1024 * 1024,
});
if (res.status !== 0) { console.error(res.stderr); process.exit(1); }

const rows = res.stdout.split('\n').filter(Boolean).map(l => JSON.parse(l.replace(/\\\\/g, '\\')));
console.log(`rows: ${rows.length}`);

// ---------- helpers ----------
function hexToHsl(hex) {
  if (!hex || !/^#?[0-9a-fA-F]{6}$/.test(hex)) return null;
  const h6 = hex.replace('#', '');
  const r = parseInt(h6.slice(0, 2), 16) / 255, g = parseInt(h6.slice(2, 4), 16) / 255, b = parseInt(h6.slice(4, 6), 16) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
  let h = 0;
  if (d) {
    if (max === r) h = ((g - b) / d) % 6;
    else if (max === g) h = (b - r) / d + 2;
    else h = (r - g) / d + 4;
    h = (h * 60 + 360) % 360;
  }
  const l = (max + min) / 2;
  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
  return { h, s, l };
}

// Hue buckets — order IS the shelf order (light → dark rainbow)
const HUE_BOOKS = [
  { key: 'white',    title: 'Whites & Creams', spine: '#efe9dd', test: c => c.l >= 0.82 },
  { key: 'greige',   title: 'Greige & Beige',  spine: '#c9bda9', test: c => c.s < 0.16 && c.l >= 0.55 },
  { key: 'gold',     title: 'Golds & Yellows', spine: '#c9a227', test: c => c.h >= 40 && c.h < 70 && c.s >= 0.16 },
  { key: 'orange',   title: 'Oranges & Terracotta', spine: '#b5652e', test: c => c.h >= 18 && c.h < 40 && c.s >= 0.16 },
  { key: 'red',      title: 'Reds', spine: '#9a2b2b', test: c => (c.h >= 348 || c.h < 18) && c.s >= 0.16 && c.l < 0.72 },
  { key: 'pink',     title: 'Pinks & Blush', spine: '#d29ba4', test: c => (c.h >= 310 || c.h < 18) && c.s >= 0.10 && c.l >= 0.6 },
  { key: 'purple',   title: 'Purples & Plum', spine: '#5d4470', test: c => c.h >= 255 && c.h < 310 },
  { key: 'blue',     title: 'Blues', spine: '#31547e', test: c => c.h >= 190 && c.h < 255 && c.s >= 0.10 },
  { key: 'teal',     title: 'Teals & Aqua', spine: '#2e6f6a', test: c => c.h >= 150 && c.h < 190 && c.s >= 0.10 },
  { key: 'green',    title: 'Greens', spine: '#4a6741', test: c => c.h >= 70 && c.h < 150 && c.s >= 0.10 },
  { key: 'brown',    title: 'Browns & Naturals', spine: '#6d5138', test: c => c.h >= 18 && c.h < 55 && c.s >= 0.10 && c.l < 0.55 },
  { key: 'gray',     title: 'Grays', spine: '#8a8d92', test: c => c.s < 0.10 && c.l >= 0.28 && c.l < 0.82 },
  { key: 'black',    title: 'Blacks & Charcoal', spine: '#23252a', test: c => c.l < 0.28 },
];
function hueBucket(hex) {
  const c = hexToHsl(hex);
  if (!c) return null;
  for (const b of HUE_BOOKS) if (b.test(c)) return b.key;
  return 'gray';
}

// Style taxonomy — matched (in order) against the style metafield, then tags
const STYLE_BOOKS = [
  { key: 'grasscloth', title: 'Grasscloth & Naturals', spine: '#7a6a4f', words: ['grasscloth', 'sisal', 'raffia', 'jute', 'natural fiber', 'seagrass', 'hemp'] },
  { key: 'texture',    title: 'Textures & Weaves', spine: '#8d8072', words: ['texture', 'textured', 'weave', 'woven', 'linen', 'silk ', 'suede', 'plaster'] },
  { key: 'floral',     title: 'Florals', spine: '#a05a6e', words: ['floral', 'flower', 'rose', 'peony', 'blossom'] },
  { key: 'botanical',  title: 'Botanicals & Leaves', spine: '#4e6b4a', words: ['botanical', 'leaf', 'leaves', 'fern', 'palm', 'tree', 'branch', 'vine'] },
  { key: 'chinoiserie',title: 'Chinoiserie & Scenic', spine: '#3f5e63', words: ['chinoiserie', 'scenic', 'mural', 'panoramic', 'landscape', 'toile de jouy'] },
  { key: 'damask',     title: 'Damask & Medallion', spine: '#4a5a7a', words: ['damask', 'medallion', 'ogee', 'scroll'] },
  { key: 'geometric',  title: 'Geometrics', spine: '#39566b', words: ['geometric', 'trellis', 'lattice', 'hexagon', 'chevron', 'greek key', 'fretwork'] },
  { key: 'stripe',     title: 'Stripes & Plaids', spine: '#71513b', words: ['stripe', 'striped', 'plaid', 'check', 'gingham', 'ticking'] },
  { key: 'abstract',   title: 'Abstract & Modern Art', spine: '#5e5470', words: ['abstract', 'brushstroke', 'watercolor', 'ombre', 'marble', 'agate'] },
  { key: 'animal',     title: 'Animal & Skins', spine: '#6b5636', words: ['animal', 'leopard', 'zebra', 'tiger', 'skin', 'faux bois', 'crocodile', 'snake', 'cheetah'] },
  { key: 'toile',      title: 'Toile & Traditional', spine: '#7a4a52', words: ['toile', 'traditional', 'heritage', 'classic', 'victorian', 'colonial'] },
  { key: 'metallic',   title: 'Metallics & Foils', spine: '#9c8547', words: ['metallic', 'foil', 'gold leaf', 'silver', 'gilded', 'shimmer', 'mica'] },
  { key: 'kids',       title: 'Kids & Whimsy', spine: '#4f7f8c', words: ['kids', 'children', 'nursery', 'whimsical', 'novelty'] },
  { key: 'coastal',    title: 'Coastal & Nautical', spine: '#54788e', words: ['coastal', 'nautical', 'beach', 'ocean', 'seashell', 'wave'] },
  { key: 'contemporary', title: 'Contemporary', spine: '#54606c', words: ['contemporary', 'modern', 'minimal', 'mid-century', 'mid century'] },
  { key: 'commercial', title: 'Commercial & Type II', spine: '#5c6660', words: ['commercial', 'type ii', 'type 2', 'contract', 'vinyl wallcovering', 'wall protection'] },
];
function styleBucket(style, tagsText) {
  const hay = ((style || '') + ' ' + (tagsText || '')).toLowerCase();
  for (const b of STYLE_BOOKS) if (b.words.some(w => hay.includes(w))) return b.key;
  return null;
}

// ---------- normalize products ----------
const products = [];
let leaked = 0;
for (const r of rows) {
  if (leaky(r.vendor) || leaky(r.title) || leaky(r.collection)) { leaked++; continue; }
  const tagsText = (r.tags || '').replace(/[{}"]/g, ' ');
  const hex = r.hex && /^#?[0-9a-fA-F]{6}$/.test(r.hex) ? (r.hex.startsWith('#') ? r.hex : '#' + r.hex) : null;
  products.push({
    id: r.id,
    vendor: r.vendor.trim(),
    title: r.title,
    handle: r.handle,
    image: r.image_url,
    created: r.created,
    collection: (r.collection || '').trim() || null,
    hex,
    hue: hueBucket(hex),
    style: styleBucket(r.style, tagsText),
  });
}
console.log(`kept ${products.length} products (leak-gated: ${leaked})`);

const byNewest = (a, b) => (b.created || '').localeCompare(a.created || '');
const url = (p) => `${STORE}/products/${p.handle}`;

function pagesFor(list, cap, viewAllHref, viewAllLabel) {
  const pages = list.slice(0, cap).map(p => ({
    title: p.title.split('|')[0].trim().slice(0, 60),
    caption: p.vendor,
    image: p.image,
    href: url(p),
  }));
  if (list.length > cap && viewAllHref) {
    pages.push({ title: `View all ${list.length.toLocaleString()} ›`, caption: viewAllLabel || '', href: viewAllHref });
  }
  return pages;
}

// median hue spine color for a group
function groupSpine(list, fallback) {
  const hs = list.map(p => hexToHsl(p.hex)).filter(Boolean).filter(c => c.s >= 0.08);
  if (hs.length < 5) {
    // low-saturation group: use median lightness neutral
    const ls = list.map(p => hexToHsl(p.hex)).filter(Boolean).map(c => c.l).sort((a, b) => a - b);
    if (!ls.length) return fallback;
    const l = ls[Math.floor(ls.length / 2)];
    const v = Math.round(l * 210 + 20).toString(16).padStart(2, '0');
    return `#${v}${v}${v}`;
  }
  const sorted = hs.map(c => c.h).sort((a, b) => a - b);
  const h = sorted[Math.floor(sorted.length / 2)];
  return `hsl(${Math.round(h)}, 38%, 44%)`;
}

// ---------- BRANDS shelf ----------
const byVendor = new Map();
for (const p of products) {
  if (!byVendor.has(p.vendor)) byVendor.set(p.vendor, []);
  byVendor.get(p.vendor).push(p);
}
const brandBooks = [...byVendor.entries()]
  .filter(([, list]) => list.length >= 20)
  .sort((a, b) => b[1].length - a[1].length)
  .slice(0, 48)
  .map(([vendor, list]) => {
    list.sort(byNewest);
    return {
      key: vendor,
      title: vendor,
      subtitle: `${list.length.toLocaleString()} designs`,
      spineColor: groupSpine(list, '#5a5f6a'),
      pages: pagesFor(list, 36, `/browse?vendor=${encodeURIComponent(vendor)}`, vendor),
    };
  });
brandBooks.sort((a, b) => a.title.localeCompare(b.title));

// ---------- COLLECTIONS (two-level: brand -> collection books) ----------
const collectionsByBrand = {};
for (const [vendor, list] of byVendor.entries()) {
  const byColl = new Map();
  for (const p of list) {
    if (!p.collection) continue;
    const c = p.collection.slice(0, 48);
    if (!byColl.has(c)) byColl.set(c, []);
    byColl.get(c).push(p);
  }
  const books = [...byColl.entries()]
    .filter(([, l]) => l.length >= 6)
    .sort((a, b) => b[1].length - a[1].length)
    .slice(0, 60)
    .map(([coll, l]) => {
      l.sort(byNewest);
      return {
        key: `${vendor}::${coll}`,
        title: coll,
        subtitle: `${vendor} · ${l.length}`,
        spineColor: groupSpine(l, '#6a6f7a'),
        pages: pagesFor(l, 36, `/browse?vendor=${encodeURIComponent(vendor)}&collection=${encodeURIComponent(coll)}`, coll),
      };
    });
  if (books.length >= 2) {
    books.sort((a, b) => a.title.localeCompare(b.title));
    collectionsByBrand[vendor] = books;
  }
}

// ---------- STYLES shelf ----------
const styleBooks = STYLE_BOOKS.map(sb => {
  const list = products.filter(p => p.style === sb.key).sort(byNewest);
  if (list.length < 12) return null;
  return {
    key: sb.key,
    title: sb.title,
    subtitle: `${list.length.toLocaleString()} designs`,
    spineColor: sb.spine,
    pages: pagesFor(list, 36, `/browse?style=${sb.key}`, sb.title),
  };
}).filter(Boolean);

// ---------- HUES shelf (the rainbow) ----------
const hueBooks = HUE_BOOKS.map(hb => {
  const list = products.filter(p => p.hue === hb.key).sort(byNewest);
  if (list.length < 12) return null;
  return {
    key: hb.key,
    title: hb.title,
    subtitle: `${list.length.toLocaleString()} designs`,
    spineColor: hb.spine,
    pages: pagesFor(list, 36, `/browse?hue=${hb.key}`, hb.title),
  };
}).filter(Boolean);

// ---------- write ----------
mkdirSync(join(ROOT, 'data'), { recursive: true });
const library = {
  builtAt: new Date().toISOString(),
  counts: { products: products.length, brands: brandBooks.length, styles: styleBooks.length, hues: hueBooks.length, collectionBrands: Object.keys(collectionsByBrand).length },
  brands: brandBooks,
  collectionsByBrand,
  styles: styleBooks,
  hues: hueBooks,
};
writeFileSync(join(ROOT, 'data', 'library.json'), JSON.stringify(library));

const grid = products.map(p => ({
  id: p.id, v: p.vendor, t: p.title.split('|')[0].trim(), h: p.handle,
  i: p.image, x: p.hex, hu: p.hue, st: p.style, c: p.collection, d: p.created,
}));
writeFileSync(join(ROOT, 'data', 'products.json'), JSON.stringify(grid));

console.log(JSON.stringify(library.counts, null, 2));
console.log(`library.json ${(JSON.stringify(library).length / 1e6).toFixed(1)}MB, products.json ${(JSON.stringify(grid).length / 1e6).toFixed(1)}MB`);