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