← back to Chinoiseriewallpaper
server.js
427 lines
/**
* CHINOISERIE WALLPAPER — DW family microsite
* Heritage luxury chinoiserie wallcovering vertical.
* Hermès visual template: rich saturated, editorial serif, gold accents.
*
* Port: 9853
* Data: data/products.json (snapshot from DW Shopify /products.json)
*
* Sort-skill canonical (2026-05-10) — 10-mode dispatcher with hex-derived
* color/luminance/hue ordering, plus color dots per card. Strict chinoiserie
* filter rejects textures (grasscloth/sisal/cork/paintable/herringbone/...)
* and non-Asian residential bleed-through.
*/
'use strict';
try { require('dotenv').config(); } catch (e) {}
const express = require('express');
const path = require('path');
const fs = require('fs');
const PORT = process.env.PORT || 9853;
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const DOMAIN = 'chinoiseriewallpaper.com';
// Admin catalog (PG-backed) is opt-in via MICROSITE_ADMIN_ENABLED=true. Prod
// stays static-only (data/products.json) so Kamatera doesn't need `pg` or the
// _shared/ tree. Dev (Mac2) flips the flag on to use dw_unified + /admin/catalog.
const ADMIN_ENABLED = process.env.MICROSITE_ADMIN_ENABLED === 'true';
let catalog = null;
if (ADMIN_ENABLED) {
try { catalog = require('../_shared/admin-catalog'); }
catch (e) { console.error(`[chinoiserie] admin-catalog unavailable (${e.message}) — falling back to static JSON`); }
}
const siteCfg = JSON.parse(fs.readFileSync(path.join(__dirname, 'site.config.json'), 'utf8'));
const SITE_SLUG = siteCfg.slug || path.basename(__dirname);
const SITE_RAILS = Array.isArray(siteCfg.rails) ? siteCfg.rails : [];
// Read-time rail membership: a product belongs to a rail if its `aesthetic` OR
// any `tag` equals the rail key (or a declared synonym). Sister-site catalogs
// have a degenerate `aesthetic` (here: "all"), so naive
// `p.aesthetic === key` matching collapses every rail to empty — the richer
// signal lives in `tags`. Prefer the shared matcher on dev; fall back to a
// self-contained copy on prod where the _shared/ tree isn't deployed.
let railMatch;
try {
railMatch = require('../_shared/rail-match');
} catch (e) {
const norm = s => String(s == null ? '' : s).trim().toLowerCase();
const productInRail = (p, key, syn) => {
const vals = [norm(key)].concat((syn && Array.isArray(syn[key]) ? syn[key] : []).map(norm));
if (vals.includes(norm(p && p.aesthetic))) return true;
const tags = p && Array.isArray(p.tags) ? p.tags : [];
return tags.some(t => vals.includes(norm(t)));
};
railMatch = {
productInRail,
buildRails(products, railKeys, opts) {
opts = opts || {}; const syn = opts.synonyms || null;
const minShare = opts.minShare != null ? opts.minShare : 0.08;
const minItems = opts.minItems != null ? opts.minItems : 4;
const perRail = opts.perRail != null ? opts.perRail : 12;
const maxShare = opts.maxShare != null ? opts.maxShare : 0.99;
const list = Array.isArray(products) ? products : []; const N = list.length || 1; const out = [];
for (const key of (railKeys || [])) {
const m = list.filter(p => productInRail(p, key, syn)); const c = m.length;
if (c >= minItems && c / N >= minShare && c / N <= maxShare) out.push({ key, aesthetic: key, count: c, items: m.slice(0, perRail) });
}
return out;
},
railFacets(products, railKeys, syn) {
const list = Array.isArray(products) ? products : []; const f = {};
for (const key of (railKeys || [])) f[key] = list.filter(p => productInRail(p, key, syn || null)).length;
return f;
}
};
}
const { productInRail, buildRails, railFacets } = railMatch;
// Synonyms come from the site's own config (prod-safe); the shared default map
// is a dev-only convenience when _shared/ is present.
let RAIL_SYN = siteCfg.railSynonyms || {};
if (!siteCfg.railSynonyms) { try { RAIL_SYN = require('../_shared/rail-synonyms.json'); } catch (e) {} }
// Catalog now lives in dw_unified.microsite_products — populated async at
// startup (see bottom of file). Falls back to data/products.json if PG is down.
let DATA_RAW = [];
// ── Strict chinoiserie filter ──────────────────────────────────────────────
// Reject ANY texture, natural-fiber surface, or obvious non-Asian residential.
const REJECT_TEXTURE = /\b(paintable|supaglypta|anaglypta|anaglytpa|grasscloth|grass\s?weave|sisal|cork|raffia|paperweave|sandstone|herringbone|basketweave|woven|weave|burlap|hemp|jute|plaster|stucco|cement|concrete|brick|stone(?!\s?(garden|tablet))|wood\s?grain|crackle|distressed|leather|suede|velvet(?!\s?(dragon|peony|pagoda))|linen(?!\s?(dragon|peony|pagoda|chinois))|foil(?!\s?dragon)|paper\s?weave|durable\s+(residential|commercial)\s*$)/i;
const REJECT_NONASIAN = /\b(bh.?90210|beverly.?hills.?90210|iconic.{0,4}bh|erotic|ice\s?cream|highland|kew|portland|tudor|wilton|hamnett|spencer|berkeley|churchill|derby|alexander|alfred|alfresco|ada|alma|samara|design\s?panel|residential\s+wallpaper|visual.{0,3}merchandiser|\bimage[ _-]?4\b|lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine|agra|parvani|aino|alva|eden|margareta|maria|allison|antilles|bahia|bali(?!\s?pagoda)|ceriman|eland|freeport|halie|labasa|mandalay|moroccan|ornament\s+border|pravata|puna|regatta|rowan|seagreens|somerset|taza|trelawny|versace|w\d{4}-\d|yucatan|floraison|bouquet|thebe|almora|anahi)\b/i;
// Strict include — must contain ONE explicit Asian/chinoiserie signal in title or tags
// Removed generic "panoramic"/"scenic mural" — they let in non-Asian murals
// (Robert Moses, Fire Island, etc). Toile kept since chinoiserie toile is canon.
const STRICT_CHINOIS = /\b(chinoiserie|chinois|asian(?!\s?inspired)?|oriental|japanese|japanse|chinese|ming|manchu|imperial|dynasty|pagoda|peony|peonies|peacock|blossom|cherry\s?blossom|magnolia|lotus|chrysanthemum|plum\s?blossom|bamboo|willow|koi|crane|phoenix|phoeni|hand[\s-]?paint(?:ed)?|toile|dragon|tea\s?house|teahouse|ginkgo|wisteria|orchid|silk\s?road|jingdezhen|hangzhou|kyoto|kabuki|geisha|samurai|shogun|emperor|empress|porcelain|qing|tang|edo|meiji|nihonga|sumi[\s-]?e|fujiyama|fuji|coromandel|cathay|sansui|tonquin|zhou|jun|huang)\b/i;
function isJunk(p) {
if (!p.image_url) return true;
if (!p.handle) return true;
const title = String(p.title || '');
const tagBlob = (p.tags || []).join(' ');
const blob = `${title} ${tagBlob}`;
// Texture and non-Asian rejection runs against TITLE only — tags carry stray
// "linen/weave/foil" metadata that mis-categorizes legitimate chinois patterns.
if (REJECT_TEXTURE.test(title)) return true;
if (REJECT_NONASIAN.test(title)) return true;
// Chinoiserie signal can live in title OR tags (broader catch).
if (!STRICT_CHINOIS.test(blob)) return true;
return false;
}
// Populated at startup from dw_unified (see bottom of file).
let PRODUCTS_PRE = [];
// ── Color hex helpers (sort-skill canonical) ───────────────────────────────
const COLOR_HEX = {
black:'#0a0a0a', charcoal:'#36454f', graphite:'#404040', slate:'#708090',
gray:'#808080', grey:'#808080', silver:'#c0c0c0', pewter:'#8a8a8a',
white:'#ffffff', ivory:'#fffff0', cream:'#fffdd0', champagne:'#f7e7ce',
beige:'#f5f5dc', sand:'#c2b280', sandstone:'#decd9b', stone:'#92847a',
blonde:'#f5e1a4',
brown:'#6b4226', chestnut:'#954535', umber:'#635147', tan:'#d2b48c',
khaki:'#c3b091', honey:'#e0b46a', copper:'#b87333', brass:'#b5a642',
bronze:'#cd7f32', gold:'#d4af37', amber:'#ffbf00', butterscotch:'#dfa75c',
saffron:'#f4c430', ochre:'#cc7722', mustard:'#ffdb58',
red:'#c92a2a', coral:'#ff7f50', pink:'#ffc0cb', salmon:'#fa8072',
rose:'#e0879c', magenta:'#c71585', fuchsia:'#ff00ff',
orange:'#ff8c00', peach:'#ffdab9', apricot:'#fbceb1',
yellow:'#f5d76e', butter:'#fff9c4',
olive:'#808000', green:'#3d8a4f', lime:'#bfff00', mint:'#9bdec1',
emerald:'#50c878', forest:'#228b22', sage:'#9caf88', moss:'#8a9a5b',
teal:'#008080', aqua:'#7fdbda', turquoise:'#40e0d0', cyan:'#00ffff',
aquamarine:'#7fffd4', seafoam:'#9fe2bf', lagoon:'#5c98a0',
blue:'#3a5fb8', navy:'#1f2a5a', indigo:'#4b0082', periwinkle:'#ccccff',
cobalt:'#0047ab', sapphire:'#0f52ba', azure:'#007fff',
purple:'#6a0dad', violet:'#8a2be2', lavender:'#b497bd', plum:'#8e4585',
burgundy:'#800020', maroon:'#800000', wine:'#722f37',
gilver:'#c9c0bb', platinum:'#e5e4e2',
metallic:'#bfa67a', foil:'#c0c0c0', mica:'#c4c4c4',
natural:'#d8c9a3', neutral:'#e6dec5',
};
function rgbOfTag(tag) {
const k = String(tag||'').toLowerCase().trim();
for (const name of Object.keys(COLOR_HEX)) if (k.includes(name)) return COLOR_HEX[name];
return null;
}
function hexToRgb(h) {
const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(h||'');
return m ? [parseInt(m[1],16),parseInt(m[2],16),parseInt(m[3],16)] : null;
}
function rgbToHsl(r,g,b) {
r/=255; g/=255; b/=255;
const mx = Math.max(r,g,b), mn = Math.min(r,g,b);
let h, s, l = (mx+mn)/2;
if (mx === mn) { h = 0; s = 0; }
else {
const d = mx-mn;
s = l > 0.5 ? d/(2-mx-mn) : d/(mx+mn);
switch (mx) {
case r: h = (g-b)/d + (g<b?6:0); break;
case g: h = (b-r)/d + 2; break;
default: h = (r-g)/d + 4;
}
h *= 60;
}
return [h, s, l];
}
function dominantHex(p) {
if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) return p.dominant_hex_real;
// try title words first (often more specific than tags), then tags
const titleHex = rgbOfTag(p.title || '');
if (titleHex) return titleHex;
const tags = (p.tags||[]).map(t => String(t).toLowerCase());
for (const t of tags) { const hx = rgbOfTag(t); if (hx) return hx; }
return '#888888';
}
function colorDots(p) {
const seen = new Set(); const out = [];
if (p.dominant_hex_real && /^#[\da-f]{6}$/i.test(p.dominant_hex_real)) {
out.push(p.dominant_hex_real); seen.add(p.dominant_hex_real.toLowerCase());
}
const titleHex = rgbOfTag(p.title || '');
if (titleHex && !seen.has(titleHex.toLowerCase())) { seen.add(titleHex.toLowerCase()); out.push(titleHex); }
for (const t of (p.tags||[])) {
const hx = rgbOfTag(t);
if (hx && !seen.has(hx.toLowerCase())) { seen.add(hx.toLowerCase()); out.push(hx); if (out.length >= 4) break; }
}
if (!out.length) out.push('#888888');
return out;
}
function luminance(p) {
const rgb = hexToRgb(dominantHex(p));
return rgb ? (0.2126*rgb[0] + 0.7152*rgb[1] + 0.0722*rgb[2]) : 128;
}
function hue(p) {
const rgb = hexToRgb(dominantHex(p));
if (!rgb) return 999;
const [h, s] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
return s < 0.08 ? 999 : h; // neutrals bucket to end
}
function colorRank(p) {
// Coarse 7-bucket grouping for "Color (grouped)" sort
const rgb = hexToRgb(dominantHex(p));
if (!rgb) return 7;
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
if (s < 0.1) return l < 0.25 ? 0 : (l > 0.85 ? 1 : 2); // black / white / gray
if (h < 20 || h >= 340) return 3; // red
if (h < 60) return 4; // orange/yellow
if (h < 170) return 5; // green
if (h < 260) return 6; // blue/indigo
return 7; // purple/pink
}
function styleKey(p) {
const blob = `${p.title||''} ${(p.tags||[]).join(' ')}`.toLowerCase();
if (/pagoda|temple|palace|lantern|ming|dynasty|imperial|manchu|celestial|chinois|emperor|empress|dragon/.test(blob)) return '1imperial';
if (/peacock|crane|phoeni|koi|fish|frog|dragonfly/.test(blob)) return '2avian';
if (/peony|peonies|blossom|cherry|magnolia|lotus|chrysanthemum|plum|orchid|wisteria/.test(blob)) return '3botanica';
if (/bamboo|willow|garden|tea\s?house/.test(blob)) return '4garden';
if (/toile|scenic|panoramic|mural|landscape/.test(blob)) return '5scenic';
if (/hand[\s-]?paint/.test(blob)) return '6handpainted';
if (/japanes|japanse|ginkgo|fuji|kabuki|geisha|samurai|edo|meiji|sumi/.test(blob)) return '7japanese';
return '9chinoiserie';
}
// ── Sort dispatcher (sort-skill canonical) ─────────────────────────────────
function sortProducts(list, mode) {
if (mode === 'sku') return [...list].sort((a,b) => String(a.sku||a.handle||'').localeCompare(String(b.sku||b.handle||'')));
if (mode === 'title') return [...list].sort((a,b) => String(a.title||'').localeCompare(String(b.title||'')));
if (mode === 'color') return [...list].sort((a,b) => colorRank(a) - colorRank(b) || String(a.title||'').localeCompare(String(b.title||'')));
if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || String(a.title||'').localeCompare(String(b.title||'')));
if (mode === 'light-dark') return [...list].sort((a,b) => luminance(b) - luminance(a));
if (mode === 'dark-light') return [...list].sort((a,b) => luminance(a) - luminance(b));
if (mode === 'wheel') return [...list].sort((a,b) => hue(a) - hue(b));
if (mode === 'price-asc') return [...list].sort((a,b) => (a.price||0) - (b.price||0));
if (mode === 'price-desc') return [...list].sort((a,b) => (b.price||0) - (a.price||0));
// List-view sortable columns — every column sortable in both directions.
if (mode === 'title-desc') return [...list].sort((a,b) => String(b.title||'').localeCompare(String(a.title||'')));
if (mode === 'sku-desc') return [...list].sort((a,b) => String(b.sku||b.handle||'').localeCompare(String(a.sku||a.handle||'')));
if (mode === 'aesthetic') return [...list].sort((a,b) => String(a.aesthetic||'').localeCompare(String(b.aesthetic||'')) || String(a.title||'').localeCompare(String(b.title||'')));
if (mode === 'aesthetic-desc') return [...list].sort((a,b) => String(b.aesthetic||'').localeCompare(String(a.aesthetic||'')) || String(a.title||'').localeCompare(String(b.title||'')));
return list; // 'newest' — server's natural order
}
function decorateColors(items) {
return items.map(p => ({ ...p, color_dots: colorDots(p), dominant_hex: dominantHex(p) }));
}
// ── Express ────────────────────────────────────────────────────────────────
const app = express();
app.use(express.json({ limit: '256kb' }));
// Block snapshot/backup files (*.bak, *.pre-*) from being served as static assets.
app.use((req, res, next) => {
if (/\.(bak|pre-[^/]*)$|\.bak\.|\.pre-/i.test(req.path)) {
return res.status(404).type('text/plain').send('Not found');
}
next();
});
// Fleet-wide DW vendor-name redactor on /api/* (drops vendors facet, replaces vendor field).
app.use(require('../_shared/api-vendor-redact'));
require('../_shared/_universal-promo-banner')(app, { slug: 'chinoiseriewallpaper' });
app.use(express.static(path.join(__dirname, 'public')));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// ── Shared filter ──────────────────────────────────────────────────────────
// Applies the same q + aesthetic narrowing used by /api/products. `skip`
// names a dimension to NOT apply (so /api/facets can compute per-dimension
// counts against the otherwise-filtered set).
function filterProducts(list, { q, aesthetic } = {}, skip) {
if (q && skip !== 'q') {
const needle = String(q).toLowerCase();
// Vendor names intentionally excluded from search to keep DW vendor identity
// out of customer-facing surfaces (standing rule, MEMORY.md 2026-05-20).
list = list.filter(p =>
(p.title || '').toLowerCase().includes(needle) ||
(p.tags || []).some(t => t.toLowerCase().includes(needle))
);
}
if (aesthetic && aesthetic !== 'all' && skip !== 'aesthetic') {
list = list.filter(p => productInRail(p, aesthetic, RAIL_SYN));
}
return list;
}
// ── API — products ─────────────────────────────────────────────────────────
app.get('/api/products', (req, res) => {
const { q, aesthetic, page = 1, limit = 60, sort = 'newest' } = req.query;
let list = filterProducts(PRODUCTS_PRE, { q, aesthetic });
list = sortProducts(list, sort);
const total = list.length;
const pageN = Math.max(1, parseInt(page, 10) || 1);
const lim = Math.min(60, parseInt(limit, 10) || 60);
const start = (pageN - 1) * lim;
res.json({
total,
page: pageN,
limit: lim,
pages: Math.ceil(total / lim),
sort,
items: decorateColors(list.slice(start, start + lim)),
});
});
// ── API — sliders ──────────────────────────────────────────────────────────
app.get('/api/sliders', (req, res) => {
const rails = buildRails(PRODUCTS_PRE, SITE_RAILS, { synonyms: RAIL_SYN });
res.json({ rails: rails.map(r => ({ aesthetic: r.aesthetic, items: r.items })) });
});
// ── API — facets ───────────────────────────────────────────────────────────
// Counts reflect the FILTERED result set, not the unfiltered catalog. Each
// aesthetic count is computed against the q-narrowed set (skipping the
// aesthetic dimension itself, so motif chips still show their reachable
// totals after a search). `total` is the fully-filtered count.
app.get('/api/facets', (req, res) => {
const { q, aesthetic } = req.query;
const filtered = filterProducts(PRODUCTS_PRE, { q, aesthetic }, 'aesthetic');
const aesthetics = railFacets(filtered, SITE_RAILS, RAIL_SYN);
const total = filterProducts(PRODUCTS_PRE, { q, aesthetic }).length;
res.json({ aesthetics, total });
});
// ── API — health ───────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_PRE.length }));
app.get('/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_PRE.length }));
// ── Sample redirect ────────────────────────────────────────────────────────
// Step 2 of the vendor handle/sku text-leak fix (2026-06-09): the client's
// /sample/ hrefs now point at the vendor-scrubbed `handle_display` slug
// (emitted by _shared/api-vendor-redact since Step 1), so the raw vendor-bearing
// handle never rides in the URL bar. This route is a pass-through redirect, so it
// is DUAL-KEY by mapping: a raw handle/sku passes through unchanged (old bookmarks
// + buy path, byte-identical behavior); a redacted display slug resolves to the
// RAW Shopify handle via a map built after catalog load. If two raw keys redact
// to the same display slug, that slug is logged and left raw-only (pass-through).
const { redactVendorText } = require('../_shared/vendor-text-redact');
let SAMPLE_DISPLAY = new Map(); // redacted display slug -> product
function buildSampleDisplayMap() {
const map = new Map();
const collided = new Set();
for (const p of PRODUCTS_PRE) {
for (const key of [p.handle, p.sku]) {
if (!key) continue;
const d = redactVendorText(String(key));
if (!d || d === key) continue; // nothing redacted — raw pass-through already correct
const prev = map.get(d);
if (prev && prev !== p) { collided.add(d); continue; }
map.set(d, p);
}
}
for (const d of collided) {
map.delete(d);
console.warn(`[chinoiserie] /sample display-slug collision — keeping raw-only for "${d}"`);
}
SAMPLE_DISPLAY = map;
console.log(`[chinoiserie] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
}
app.get('/sample/:handle', (req, res) => {
const key = req.params.handle;
// Raw first — any key matching a real raw handle/sku passes through unchanged (old behavior).
const isRaw = PRODUCTS_PRE.some(x => x.handle === key || x.sku === key);
const p = isRaw ? null : SAMPLE_DISPLAY.get(key); // then the redacted display form -> raw handle
res.redirect(302, `${DW_SHOPIFY}/products/${encodeURIComponent(p ? p.handle : key)}#sample`);
});
// ── Robots / sitemap ───────────────────────────────────────────────────────
app.get('/robots.txt', (_req, res) => {
res.type('text/plain').send(`User-agent: *\nAllow: /\nSitemap: https://${DOMAIN}/sitemap.xml\n`);
});
app.get('/sitemap.xml', (_req, res) => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://${DOMAIN}/</loc><changefreq>weekly</changefreq></url>
</urlset>`;
res.type('application/xml').send(xml);
});
// ── Main page — serve index.html from public/ ─────────────────────────────
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Admin catalog CRUD — /admin/catalog (basic-auth) + /api/admin/* REST.
if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
// ── 404 guard — JSON for /api/*, lightweight HTML otherwise ────────────────
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'not_found', path: req.path });
}
res.status(404).type('text/html').send(
'<!doctype html><meta charset="utf-8"><title>Not found</title>' +
'<style>body{font-family:Cormorant Garamond,Georgia,serif;background:#f5f1e8;color:#0a0a0a;text-align:center;padding:80px 20px}h1{font-size:48px;font-weight:300;margin:0 0 12px}p{font-size:16px;color:#6e6e68;margin:0 0 24px}a{color:#0a0a0a;text-decoration:underline}</style>' +
'<h1>Not found</h1><p>The page you requested isn’t here.</p><p><a href="/">Return home</a></p>'
);
});
// Load the catalog. When admin is enabled, prefer dw_unified; else static JSON.
(async () => {
let loaded = false;
if (catalog) {
try {
const rows = await catalog.getProducts(SITE_SLUG);
PRODUCTS_PRE = rows.filter(p => !isJunk(p));
console.log(`[chinoiserie] loaded ${rows.length} from dw_unified, after strict-chinois filter: ${PRODUCTS_PRE.length}`);
loaded = true;
} catch (e) {
console.error(`[chinoiserie] PG catalog load failed (${e.message}) — falling back to data/products.json`);
}
}
if (!loaded) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));
PRODUCTS_PRE = (Array.isArray(raw) ? raw : []).filter(p => !isJunk(p));
console.log(`[chinoiserie] loaded ${PRODUCTS_PRE.length} from data/products.json (static mode)`);
} catch (_) { PRODUCTS_PRE = []; }
}
buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
app.listen(PORT, '127.0.0.1', () => {
console.log(`[chinoiserie] Listening on http://127.0.0.1:${PORT} — ${PRODUCTS_PRE.length} chinois patterns`);
});
})();