← back to Thedesignerlibrary
server.js
182 lines
// The Designer Library — thedesignerlibrary.com
// Serves the 3D bookshelf (per-shelf JSON so the landing view stays light)
// and the /browse grid API over the static data built by scripts/build-library.mjs.
const express = require('express');
const fs = require('fs');
const path = require('path');
const CFG = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
const PORT = process.env.PORT || CFG.port || 9807;
const ORIGIN = 'https://thedesignerlibrary.com';
let LIB = { brands: [], collectionsByBrand: {}, styles: [], hues: [], counts: {}, builtAt: null };
let GRID = [];
let BROWSE_HTML = '';
let FACETS_CACHE = null;
const SORTED_CACHE = {};
function hexToHsl(hex) {
if (!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;
return { h, s: d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)), l };
}
function loadData() {
try {
LIB = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'library.json'), 'utf8'));
GRID = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
// precompute HSL once — the color sorts would otherwise re-parse hex
// strings ~1.4M times per unfiltered request (85k rows × log n compares)
for (const p of GRID) p._hsl = hexToHsl(p.x);
FACETS_CACHE = null;
for (const k of Object.keys(SORTED_CACHE)) delete SORTED_CACHE[k];
BROWSE_HTML = fs.readFileSync(path.join(__dirname, 'public', 'browse.html'), 'utf8');
console.log(`library loaded: ${GRID.length} products, built ${LIB.builtAt}`);
} catch (e) { console.error('data load failed:', e.message); }
}
loadData();
const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => { res.setHeader('X-Frame-Options', 'SAMEORIGIN'); next(); });
// raw JSON in a search index helps no one — keep crawlers on the HTML pages
app.use('/api', (req, res, next) => { res.setHeader('X-Robots-Tag', 'noindex, nofollow'); next(); });
app.get('/api/health', (_req, res) => res.json({ ok: true, products: GRID.length, builtAt: LIB.builtAt }));
// per-shelf datasets — the landing view fetches only its own shelf.
// Data is build-time static, so let Cloudflare edge-cache it (s-maxage).
app.get('/api/shelf/:name', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=3600');
const n = req.params.name;
if (n === 'brands') return res.json({ books: LIB.brands });
if (n === 'styles') return res.json({ books: LIB.styles });
if (n === 'hues') return res.json({ books: LIB.hues });
if (n === 'collections') {
const brand = req.query.brand;
if (!brand) return res.json({ brands: Object.keys(LIB.collectionsByBrand).sort() });
return res.json({ books: LIB.collectionsByBrand[brand] || [] });
}
res.status(404).json({ error: 'unknown shelf' });
});
// ---------- /browse grid API ----------
const SORTS = {
newest: (a, b) => (b.d || '').localeCompare(a.d || ''),
title: (a, b) => a.t.localeCompare(b.t),
vendor: (a, b) => a.v.localeCompare(b.v) || a.t.localeCompare(b.t),
collection: (a, b) => (a.c || '').localeCompare(b.c || '') || a.t.localeCompare(b.t),
'light-dark': (a, b) => (b._hsl?.l ?? -1) - (a._hsl?.l ?? -1),
'dark-light': (a, b) => (a._hsl?.l ?? 2) - (b._hsl?.l ?? 2),
wheel: (a, b) => (a._hsl?.h ?? 999) - (b._hsl?.h ?? 999),
};
// unfiltered sorted views are identical for every visitor — sort once, slice forever
function getSorted(sort) {
if (!SORTED_CACHE[sort]) SORTED_CACHE[sort] = [...GRID].sort(SORTS[sort]);
return SORTED_CACHE[sort];
}
function queryProducts(q) {
const { vendor, collection, style, hue } = q;
const sort = SORTS[q.sort] ? q.sort : 'newest';
const page = Math.max(1, parseInt(q.page) || 1);
const limit = Math.min(96, Math.max(1, parseInt(q.limit) || 48));
const term = (q.q || '').trim();
const filtered = vendor || collection || style || hue || term;
let sorted;
if (!filtered) {
sorted = getSorted(sort);
} else {
let list = GRID;
if (vendor) list = list.filter(p => p.v === vendor);
if (collection) list = list.filter(p => p.c === collection);
if (style) list = list.filter(p => p.st === style);
if (hue) list = list.filter(p => p.hu === hue);
if (term) {
const terms = term.toLowerCase().split(/\s+/).filter(Boolean);
list = list.filter(p => { const hay = (p.t + ' ' + p.v + ' ' + (p.c || '')).toLowerCase(); return terms.every(t => hay.includes(t)); });
}
sorted = [...list].sort(SORTS[sort]);
}
const start = (page - 1) * limit;
return {
total: sorted.length, page, limit,
products: sorted.slice(start, start + limit).map(p => ({
title: p.t, vendor: p.v, handle: p.h, image: p.i, hex: p.x, hue: p.hu, style: p.st, collection: p.c,
url: `${CFG.storeBase}/products/${p.h}`,
})),
};
}
app.get('/api/products', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600');
res.json(queryProducts(req.query));
});
app.get('/api/facets', (req, res) => {
if (!FACETS_CACHE) {
const count = (key) => {
const m = new Map();
for (const p of GRID) { const v = p[key]; if (v) m.set(v, (m.get(v) || 0) + 1); }
return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([value, n]) => ({ value, n }));
};
FACETS_CACHE = { vendors: count('v').slice(0, 60), hues: count('hu'), styles: count('st') };
}
res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=3600');
res.json(FACETS_CACHE);
});
// ---------- SEO surface ----------
app.get('/robots.txt', (_req, res) => {
res.type('text/plain').send(`User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: ${ORIGIN}/sitemap.xml\n`);
});
app.get('/sitemap.xml', (_req, res) => {
// just the two real pages — filter URLs all serve the same client-rendered
// shell, so listing them would hand Google thousands of duplicate pages
const urls = [
{ loc: `${ORIGIN}/`, changefreq: 'weekly', priority: '1.0' },
{ loc: `${ORIGIN}/browse`, changefreq: 'daily', priority: '0.9' },
];
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => `<url><loc>${u.loc}</loc><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`).join('\n')}
</urlset>`);
});
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
// /browse with the first 48 products server-rendered — the only indexable
// product content on the site (the 3D landing is pixels to a crawler).
// The client script re-renders the grid on load, so hydration is a wash.
app.get('/browse', (req, res) => {
res.setHeader('Cache-Control', 'no-cache');
let html = BROWSE_HTML;
try {
const first = queryProducts({ sort: 'newest', limit: 48 });
const cards = first.products.map(p => `<div class="card"><a href="${escHtml(p.url)}" target="_blank" rel="nofollow sponsored noopener"><img loading="lazy" src="${escHtml(p.image)}" alt="${escHtml(p.title)}" width="300" height="300"></a><div class="meta"><a class="t" href="${escHtml(p.url)}" target="_blank" rel="nofollow sponsored noopener">${escHtml(p.title)}</a><div class="v">${escHtml(p.vendor)}${p.collection ? ' · ' + escHtml(p.collection) : ''}</div></div></div>`).join('');
html = html.replace('<div id="grid"></div>', `<div id="grid">${cards}</div>`);
} catch { /* fall through to the empty shell */ }
res.send(html);
});
app.use(express.static(path.join(__dirname, 'public'), {
extensions: ['html'],
setHeaders(res, filePath) {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); // deploys visible immediately
else if (filePath.includes(`${path.sep}vendor${path.sep}`)) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // versioned filenames
else res.setHeader('Cache-Control', 'public, max-age=86400');
},
}));
app.listen(PORT, () => console.log(`The Designer Library on :${PORT}`));