← 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

// /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}`));