← back to Dw Domain Fleet
shared/render.js
1646 lines
/**
* render.js — server-rendered HTML for a fleet microsite.
* All pages share one template; per-domain config drives theme/copy/hero.
*/
const crypto = require('crypto');
const { dominantHex } = require('./sort');
// Canonical vendor-tag predicate — single source of truth shared with the
// API-redact middleware. scrubTags() routes through this so the fleet can never
// drift to a stale SUBSET of the denylist again (the 2026-06-03 leak: render.js
// kept its own narrower list and leaked Anna French / Christian Lacroix / Ralph
// Lauren / Laura Ashley / Mind the Gap chips + Color:/Series:/Type metadata that
// the canonical isVendorTag already drops). DTD verdict A (Claude+Codex 2/2).
const { isVendorTag } = require('../../_shared/api-vendor-redact');
/* imgToken / proxyImg — vendor-neutral image URL.
* The raw Shopify CDN image_url embeds the 3rd-party vendor name in the
* filename (e.g. WolfGordonWallcovering_DWWG_em9505.jpg). Emitting it in a card
* data-img / background-image / hero / JSON-LD leaks the vendor (settlement
* vendor-confidentiality). The fix is a server-side image proxy: render emits
* /img/<token>, and server.js resolves token -> real CDN url from a boot map and
* streams the bytes, so the vendor never appears in the DOM OR the network tab
* (DTD 2026-06-03 verdict A — streaming proxy, vote 2/2, Qwen non-vote).
*
* imgToken is a PURE deterministic hash of the url string — render.js stays
* stateless (no POOL access). server.js builds its token->url map with this same
* function so the two sides agree. sha1 here is an opaque short id, NOT a
* security primitive; 20 hex chars = 80 bits, ample for a per-site catalog. */
function imgToken(url) {
if (!url) return '';
const ext = (String(url).match(/\.(jpe?g|png|webp|gif|avif)(?:[?#]|$)/i) || [null, 'jpg'])[1].toLowerCase();
const h = crypto.createHash('sha1').update(String(url)).digest('hex').slice(0, 20);
return h + '.' + (ext === 'jpeg' ? 'jpg' : ext);
}
function proxyImg(url) {
const t = imgToken(url);
return t ? '/img/' + t : '';
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
function clean(s) {
// strip the banned word "Wallpaper" → "Wallcovering" in any rendered copy
return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering');
}
/* productSlug — the fleet's OWN /product/ route key.
* Derived from the Shopify handle with "wallpaper" → "wallcovering" so a fleet
* URL never carries the off-brand word. The real `handle` is left UNTOUCHED on
* the product object — it stays the join key for the rel=canonical tag and the
* buy-sample CTA that point at the live designerwallcoverings.com store. Pure
* and deterministic: the route can recompute it without storing anything.
*/
function productSlug(p) {
const h = (p && p.handle) || '';
let s = scrubVendor(h).replace(/wallpapers/gi, 'wallcoverings').replace(/wallpaper/gi, 'wallcovering')
.replace(/\s+/g, '-'); // scrubVendor leaves a space where each vendor token was — re-hyphenate for a clean slug
s = s.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
if (s && !hasVendorToken(s)) return s;
// Reaching here means scrubVendor either EMPTIED the handle (whole handle was a
// vendor, e.g. "brunschwig-fils") OR left a CONCATENATED vendor token its
// separator-requiring regex can't catch (e.g. "wolfgordonwallcovering" — no
// separator between wolf+gordon, so \bword[^a-z0-9]+word\b never matches). Either
// way, do NOT return the raw/dirty handle (re-leaks the vendor in the URL).
// Derive a clean, deterministic, vendor-free slug: scrubbed sku if usable AND
// itself vendor-free, else a stable hash of the raw handle. The route recomputes
// the same productSlug AND still resolves old /product/<raw-handle> links via x.handle.
const skuSlug = scrubVendor(String((p && p.sku) || '').toLowerCase())
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (skuSlug && !hasVendorToken(skuSlug)) return skuSlug;
let hash = 0;
for (let i = 0; i < h.length; i++) hash = (hash * 31 + h.charCodeAt(i)) >>> 0;
return 'design-' + hash.toString(36);
}
/* scrubTags — drop any tag carrying a 3rd-party vendor token (or internal
* Color:/Series:/Type/Collection metadata) before rendering. Tag-chip surfaces
* (the data-tags attribute on cards + the productPage <span> chip strip) were
* leaking vendor names straight from p.tags[] to the rendered DOM. This filter
* runs at the render boundary so both the data-tags attribute and the
* productPage chips see the scrubbed list.
*
* 2026-06-03 (DTD verdict A): scrubTags now delegates to the CANONICAL
* isVendorTag() in _shared/api-vendor-redact.js — the same predicate the API
* middleware uses — instead of a private, narrower copy. Maintaining a second
* list here caused subset drift: the local list lagged the canonical one and
* leaked Anna French / Christian Lacroix / Ralph Lauren / Laura Ashley /
* Mind the Gap / Dolce & Gabbana / A.S. Création chips, plus internal Color:/
* Series:/Type metadata chips. One source of truth ⇒ no more drift. House
* brands (Designer Wallcoverings, Hollywood, Phillipe Romano, Retro Walls,
* Maison Lustre) are still NEVER scrubbed — isVendorTag keeps them.
*
* The VENDORS_DENYLIST / scrubVendor / hasVendorToken trio below is a DIFFERENT
* operation (in-place vendor-token removal from a displayed title and the
* /product URL slug) that isVendorTag does not expose, so it stays. */
const VENDORS_DENYLIST = [
'wolf gordon', 'nina campbell', 'jeffrey stevens', 'versace',
'arte international', 'lee jofa modern', 'lee jofa', 'innovations usa',
'innovations', 'koroseal', 'schumacher', 'candice olson', 'thibaut',
'maya romanoff', 'scalamandre', 'dedar', 'carnegie', 'cole and son',
'kravet', 'clarke and clarke', 'brunschwig', 'gp j baker', 'sandberg',
'designers guild', 'graham and brown', 'harlequin', 'andrew martin',
'ronald redding', 'blithfield', 'coordonne', 'fentucci', 'sister parish',
'phillip jeffries', 'fromental', 'westport', 'breegan', 'les ensembliers',
'roger thomas', 'stacy garcia', 'de gournay', 'romo', 'farrow and ball',
'colefax and fowler', 'sanderson', 'morris and co', 'osborne and little',
// 2026-06-03: vendors the canonical isVendorTag (api-vendor-redact) carries
// but scrubVendor's slug/title stripper was missing — they leaked into the
// /product URL slug + data-handle (e.g. ".../as-creation", ".../gaston-y-daniela").
// Tag-chips are now handled by isVendorTag; these keep scrubVendor in lockstep
// so the URL slug + visible title never carry a 3rd-party vendor either.
'anna french', 'christian lacroix', 'ralph lauren', 'laura ashley',
'mind the gap', 'as creation', 'a s creation', 'gaston y daniela',
'brand mckenzie', 'dolce and gabbana', 'dolce gabbana', 'versa designed',
'pierre frey', 'clarence house', 'donghia', 'elitis', 'mulberry', 'nobilis',
'phyllis morris', 'scion', 'zoffany', 'marimekko', 'missoni', 'roberto cavalli',
'kelly wearstler', 'winfield thybony', 'villa nova', 'wallquest', 'vahallan',
'lincrusta', 'newmor', 'china seas', 'fabricut', 'baker lifestyle',
];
const VENDORS_DENYLIST_NORM = VENDORS_DENYLIST.map(s =>
String(s).toLowerCase().replace(/[^a-z0-9]+/g, '')
);
function hasVendorToken(s) {
const n = String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '');
if (!n) return false;
for (let i = 0; i < VENDORS_DENYLIST_NORM.length; i++) {
if (n.indexOf(VENDORS_DENYLIST_NORM[i]) >= 0) return true;
}
return false;
}
function scrubTags(tags) {
if (!Array.isArray(tags)) return [];
// Canonical predicate (drops vendors + internal metadata tags, keeps house
// brands). Belt-and-suspenders: also apply the local slug-oriented
// hasVendorToken so any vendor present in scrubVendor's list but momentarily
// absent from the canonical one is still caught.
return tags.filter(t => t && !isVendorTag(t) && !hasVendorToken(t));
}
/* scrubVendor — REMOVE any 3rd-party vendor name from a DISPLAYED string
* (product title, the visible sku/handle in card-meta, and the /product slug).
* Where scrubTags drops a whole tag, this strips just the vendor token in place.
* Longest-match-first so "lee jofa modern" beats "lee jofa"; separators are
* flexible (\b…[^a-z0-9]+…\b) so it catches "Brunschwig & Fils" (title) AND
* "brunschwig-fils" (slug). House brands aren't on the denylist → untouched.
* SAFE for slugs: server.js resolves /product/:handle by raw handle OR
* productSlug(x), and the buy-sample CTA uses the untouched p.handle — so
* cleaning the slug never breaks a product link. (2026-05-29 fleet vendor-leak
* audit: slug/handle + visible card-title vector.) */
const _VENDOR_SCRUB = VENDORS_DENYLIST
.concat(['brunschwig fils', 'lee jofa modern', 'gp j baker', 'g p j baker',
'cole and son', 'cole son', 'clarke and clarke', 'clarke clarke',
'morris and co', 'morris co', 'caroline cecil textiles', 'caroline cecil'])
.slice().sort((a, b) => b.length - a.length)
// Split on non-alphanumeric runs, drop empties, join with an OPTIONAL separator
// ([^a-z0-9]* — zero-or-more) so each vendor matches BOTH the separated form
// ("Brunschwig & Fils", "wolf-gordon") AND the concatenated/no-separator
// handle spelling ("wolfgordon", "gpjbaker") — the 936 wolfgordon URL-slug
// leaks the 2026-05-30 reaudit found. \b boundaries keep it from over-matching
// inside unrelated words. Tokens <2 chars are kept literal-only (no optional
// sep on a single letter would over-match), so multi-initial vendors use the
// explicit "g p j baker" variant above.
.map(v => new RegExp('\\b' + v.split(/[^a-z0-9]+/i).filter(Boolean).join('[^a-z0-9]*') + '\\b', 'ig'));
function scrubVendor(s) {
if (s == null) return s;
let o = String(s);
for (let i = 0; i < _VENDOR_SCRUB.length; i++) o = o.replace(_VENDOR_SCRUB[i], ' ');
return o
.replace(/\s{2,}/g, ' ')
.replace(/(\s*-\s*)+/g, '-') // collapse mid-string residue like "x- -1" → "x-1"
.replace(/-{2,}/g, '-')
.replace(/^[\s\-–—|·,&]+|[\s\-–—|·,&]+$/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
const DW = 'https://designerwallcoverings.com';
/* ---------- shared CSS ---------- */
function css(cfg) {
const t = cfg.theme;
return `
:root{
--bg:${t.bgLight};--fg:${t.inkLight};--muted:#7c736a;--rule:${t.rule||'#d8cec0'};
--accent:${t.accent};--card:#ffffff;--header-bg:${t.bgLight};
--serif:${t.serif||"'Cormorant Garamond','Playfair Display',Georgia,serif"};
--sans:${t.sans||"-apple-system,'Inter',system-ui,sans-serif"};
--cols:5;
}
[data-theme=dark]{
--bg:${t.bg};--fg:${t.ink};--muted:#9a9088;--rule:#2c2620;--card:#16130f;--header-bg:${t.bg};
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.5;
transition:background .3s,color .3s}
a{color:inherit;text-decoration:none}
img{display:block;max-width:100%}
.wrap{max-width:1480px;margin:0 auto;padding:0 32px}
/* ---- promo strip (text-only rotating "new arrivals") --------------------
SHAREABLE SNIPPET: this CSS block + the promoStrip() markup/JS in render.js
are self-contained (depend only on the --accent / --sans theme vars) so they
lift cleanly into the standalone-tier sister sites. Text-only, no images.
White ink on the per-site --accent (same contrast contract as .nsm-cta). */
.promo-strip{position:relative;z-index:96;width:100%;min-height:40px;
background:var(--accent);color:#fff;font-family:var(--sans);
display:flex;align-items:center;justify-content:center;
padding:9px 46px 9px 16px;text-align:center}
.promo-strip .promo-track{max-width:100%;overflow:hidden}
.promo-strip a.promo-item{color:#fff;display:none;align-items:center;gap:8px;
max-width:92vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-size:13px;letter-spacing:.03em}
.promo-strip a.promo-item.on{display:inline-flex}
.promo-strip a.promo-item:hover{text-decoration:underline}
.promo-strip .promo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.promo-strip .promo-dot{opacity:.6;flex:none}
.promo-strip .promo-tag{font-weight:600;letter-spacing:.16em;text-transform:uppercase;
font-size:11px;opacity:.92;flex:none}
.promo-strip .promo-arrow{opacity:.85;flex:none}
.promo-x{position:absolute;right:12px;top:50%;transform:translateY(-50%);
background:transparent;border:0;color:#fff;opacity:.7;cursor:pointer;
font-size:18px;line-height:1;padding:4px 6px}
.promo-x:hover{opacity:1}
/* when the in-flow promo strip is present, drop the FIXED chrome below it so
the topbar/menu/toggle don't overlap the strip (body.promo-on set server-side
to avoid a layout flash; the dismiss JS removes it). */
body.promo-on .topbar{top:40px}
body.promo-on .gucci-menu,body.promo-on .theme-toggle{top:62px}
@media(max-width:640px){
.promo-strip{padding:8px 40px 8px 12px}
.promo-strip a.promo-item{font-size:12px}
body.promo-on .topbar{top:38px}
body.promo-on .gucci-menu,body.promo-on .theme-toggle{top:58px}
}
/* top bar */
.topbar{position:fixed;top:0;left:0;right:0;z-index:90;display:flex;justify-content:space-between;
align-items:center;padding:20px 36px;mix-blend-mode:normal}
.brand{font-family:var(--serif);font-size:21px;letter-spacing:.04em;color:var(--top-fg,#fff)}
.gucci-menu{position:fixed;top:22px;right:36px;z-index:100;display:inline-flex;align-items:center;
gap:12px;background:transparent;border:0;padding:6px 4px;cursor:pointer;color:var(--top-fg,#fff);
opacity:.78;transition:opacity .25s}
.gucci-menu:hover{opacity:1}
.gucci-menu__bars{position:relative;width:18px;height:12px}
.gucci-menu__bars span{position:absolute;left:0;right:0;height:1px;background:currentColor;
transition:transform .3s,opacity .3s}
.gucci-menu__bars span:nth-child(1){top:0}
.gucci-menu__bars span:nth-child(2){top:5.5px}
.gucci-menu__bars span:nth-child(3){top:11px}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(1){top:5.5px;transform:rotate(45deg)}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(2){opacity:0}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(3){top:5.5px;transform:rotate(-45deg)}
.gucci-menu__label{font-size:11px;font-weight:400;letter-spacing:.18em;text-transform:uppercase;line-height:1}
.navpanel{position:fixed;top:0;right:-320px;width:300px;height:100%;background:rgba(15,15,15,.97);
backdrop-filter:blur(14px);z-index:99;padding:96px 36px;transition:right .35s;color:#f3f0ea}
.navpanel.open{right:0}
.navpanel a{display:block;padding:13px 0;font-size:14px;letter-spacing:.06em;text-transform:uppercase;
border-bottom:1px solid rgba(255,255,255,.1)}
.theme-toggle{position:fixed;top:22px;right:124px;z-index:101;background:transparent;border:0;
cursor:pointer;color:var(--top-fg,#fff);padding:4px;display:inline-flex;align-items:center;
opacity:.78;transition:opacity .25s}
.theme-toggle:hover{opacity:1}
.theme-toggle .icon-moon{display:none}
html[data-theme=dark] .theme-toggle .icon-sun{display:none}
html[data-theme=dark] .theme-toggle .icon-moon{display:inline-block}
/* hero */
.cinema{position:relative;width:100%;min-height:clamp(560px,90vh,1080px);overflow:hidden;background:#111}
.cinema-slide{position:absolute;inset:0;background-size:cover;background-position:center 14%;
opacity:0;transition:opacity 1.4s ease}
.cinema-slide.on{opacity:1}
.cinema-grid{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr}
.cinema-grid div{background-size:cover;background-position:center 14%}
.cinema::after{content:"";position:absolute;inset:0;background:linear-gradient(
180deg,rgba(0,0,0,.34) 0%,rgba(0,0,0,.05) 38%,rgba(0,0,0,.5) 100%)}
.hero-cta{position:absolute;bottom:7%;left:50%;transform:translateX(-50%);display:flex;
flex-direction:column;align-items:center;gap:15px;z-index:10}
.hero-cta__sub{font-size:13px;color:#fff;letter-spacing:.04em}
.hero-cta__btn{display:inline-block;background:#fff;color:#111;padding:14px 56px;border-radius:4px;
font-size:12px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;
transition:background .25s,color .25s}
.hero-cta__btn:hover{background:#111;color:#fff}
.hero-mark{text-align:center;padding:46px 20px 14px}
.hero-mark h1{font-family:var(--serif);font-size:clamp(40px,6vw,76px);font-weight:500;
letter-spacing:.02em;line-height:1.05}
.hero-mark p{color:var(--muted);font-size:15px;margin-top:10px;letter-spacing:.03em}
/* controls */
.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;padding:26px 0 20px;
justify-content:space-between;border-bottom:1px solid var(--rule)}
.ctl-sort{display:flex;align-items:center;gap:10px;flex:1;min-width:260px;flex-wrap:wrap}
.ctl-density{display:flex;align-items:center;gap:10px;flex:1;min-width:260px;justify-content:flex-end;flex-wrap:wrap}
.controls label{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
.controls select,.controls input[type=search]{font-family:var(--sans);font-size:14px;padding:9px 12px;
border:1px solid var(--rule);background:var(--card);color:var(--fg);border-radius:3px}
.controls input[type=range]{flex:1;min-width:140px;max-width:200px;accent-color:var(--accent)}
.controls input[type=search]{min-width:160px;flex:1}
.count{font-size:13px;color:var(--muted)}
/* grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:26px;padding:36px 0 60px}
.card{background:var(--card);border:1px solid var(--rule);border-radius:4px;overflow:hidden;
cursor:pointer;transition:transform .2s,box-shadow .2s;display:flex;flex-direction:column}
.card:hover{transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.13)}
.card-link{display:flex;flex-direction:column;height:100%;color:inherit}
.card-img{aspect-ratio:1/1;background:#eee center/cover no-repeat;position:relative;overflow:hidden}
.card-img::after{content:"";position:absolute;inset:0;background-image:var(--card-bg,none);background-repeat:repeat;background-size:33.33% 33.33%;background-position:center;opacity:0;transition:opacity 360ms ease;pointer-events:none}
.card:hover .card-img::after,.card-link:focus-visible .card-img::after{opacity:1}
@media (prefers-reduced-motion: reduce){.card-img::after{transition:none}}
.card-body{padding:13px 14px 16px}
.card-title{font-family:var(--serif);font-size:16px;line-height:1.25;min-height:40px}
.card-meta{font-size:11px;color:var(--muted);letter-spacing:.04em;margin-top:6px;
display:flex;align-items:center;gap:7px}
.dot{width:11px;height:11px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:none}
.card-sample{margin-top:10px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;
color:var(--accent)}
/* footer */
footer{border-top:1px solid var(--rule);padding:48px 0 60px;margin-top:30px}
footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:24px;
font-size:13px;color:var(--muted)}
footer .fb{font-family:var(--serif);font-size:18px;color:var(--fg)}
/* ns-modal (chip / detail) */
.nsm-back{position:fixed;inset:0;background:rgba(10,9,7,.66);backdrop-filter:blur(3px);
z-index:200;display:none;align-items:center;justify-content:center;padding:24px}
.nsm-back.open{display:flex}
.nsm{background:var(--card);color:var(--fg);max-width:760px;width:100%;border-radius:6px;
overflow:hidden;display:grid;grid-template-columns:1fr 1fr;max-height:88vh}
.nsm-img{background:#eee center/cover no-repeat;min-height:340px}
.nsm-body{padding:34px;overflow:auto}
.nsm-body h3{font-family:var(--serif);font-size:26px;margin-bottom:6px}
.nsm-body .sku{font-size:12px;color:var(--muted);letter-spacing:.06em;margin-bottom:18px}
.nsm-chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0}
.nsm-chips span{font-size:11px;padding:4px 9px;border:1px solid var(--rule);border-radius:20px;
color:var(--muted)}
.nsm-cta{display:inline-block;margin-top:18px;background:var(--accent);color:#fff;padding:13px 26px;
border-radius:4px;font-size:12px;letter-spacing:.12em;text-transform:uppercase}
.nsm-close{position:absolute;top:18px;right:22px;font-size:26px;cursor:pointer;color:#fff;z-index:201}
@media(max-width:640px){.nsm{grid-template-columns:1fr}.nsm-img{min-height:220px}}
@media(max-width:900px){.wrap{padding:0 18px}}
`;
}
/* ---------- top bar + nav + theme toggle ---------- */
function chrome(cfg) {
return `
<div class="topbar">
<a class="brand" href="/">${esc(cfg.siteName)}</a>
<div style="display:flex;gap:14px;align-items:center">
<button class="theme-toggle" id="themeBtn" aria-label="Toggle theme">
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</div>
</div>
<button class="gucci-menu" id="menuBtn" aria-expanded="false" aria-controls="navpanel">
<span class="gucci-menu__bars" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="gucci-menu__label">Menu</span>
</button>
<nav class="navpanel" id="navpanel">
<a href="/catalog">Designs</a>
<a href="/about">About</a>
<a href="/info">Info</a>
<a href="mailto:${esc(cfg.siteEmail)}">Contact</a>
</nav>`;
}
/* ---------- promo strip (rotating "new arrivals") ----------
* SHAREABLE SNIPPET (pairs with the .promo-strip CSS in css()). Text-only,
* no images. Shows "NEW · <title> →" rotating through the 3 NEWEST products.
*
* Newest signal: `products` is already sorted newest-first by the server
* (sortProducts(POOL,'newest') = created_at desc — the schema has no
* published_at), so the first 3 ARE the 3 newest. Link target is the fleet's
* OWN vendor-safe /product/<slug> route (productSlug strips vendor tokens) —
* never an external vendor URL (settlement vendor-confidentiality, the same
* rule the cards / buy route already honour).
*
* Per-site flag: cfg.promoBanner. Defaults ON; only an explicit `false`
* disables it. Returns '' (no strip, no body.promo-on offset) when disabled or
* when there are no products to show. Self-contained: depends only on esc /
* clean / scrubVendor / productSlug already in this module.
*/
function promoStrip(cfg, products) {
if (cfg.promoBanner === false) return '';
const items = (Array.isArray(products) ? products : []).slice(0, 3).map(p => {
const t = scrubVendor(clean(p.title).split('|')[0].trim())
|| scrubVendor((p.sku || p.handle || '').replace(/-Sample$/i, ''))
|| 'New Design';
return { t, href: '/product/' + encodeURIComponent(productSlug(p)) };
}).filter(x => x.t);
if (!items.length) return '';
const links = items.map((it, i) =>
`<a class="promo-item${i === 0 ? ' on' : ''}" href="${esc(it.href)}">` +
`<span class="promo-tag">New</span><span class="promo-dot" aria-hidden="true">·</span>` +
`<span class="promo-name">${esc(it.t)}</span>` +
`<span class="promo-arrow" aria-hidden="true">→</span></a>`
).join('');
return `
<div class="promo-strip" id="promoStrip" role="region" aria-label="New arrivals">
<div class="promo-track">${links}</div>
<button class="promo-x" id="promoX" type="button" aria-label="Dismiss new-arrivals banner">×</button>
</div>
<script>
(function(){
var k='${cfg.slug}_promo_dismiss';
var strip=document.getElementById('promoStrip');
if(!strip)return;
try{ if(localStorage.getItem(k)==='1'){ strip.style.display='none';
document.body.classList.remove('promo-on'); return; } }catch(e){}
var items=strip.querySelectorAll('.promo-item'),i=0;
if(items.length>1){ setInterval(function(){
if(document.hidden)return; // don't advance while the tab is backgrounded
items[i].classList.remove('on'); i=(i+1)%items.length; items[i].classList.add('on');
},4200); }
var x=document.getElementById('promoX');
x&&x.addEventListener('click',function(){
strip.style.display='none'; document.body.classList.remove('promo-on');
try{ localStorage.setItem(k,'1'); }catch(e){}
});
})();
</script>`;
}
function script(cfg) {
return `
<script>
(function(){
var k='${cfg.slug}_theme';
var saved=localStorage.getItem(k);
if(saved)document.documentElement.dataset.theme=saved;
var btn=document.getElementById('themeBtn');
btn&&btn.addEventListener('click',function(){
var cur=document.documentElement.dataset.theme==='dark'?'':'dark';
if(cur)document.documentElement.dataset.theme=cur;else delete document.documentElement.dataset.theme;
localStorage.setItem(k,cur);
});
var mb=document.getElementById('menuBtn'),np=document.getElementById('navpanel');
mb&&mb.addEventListener('click',function(){
var o=np.classList.toggle('open');
mb.setAttribute('aria-expanded',o?'true':'false');
});
})();
</script>`;
}
/* anti-flash theme + top-bar fg colour
* canonicalOverride — when set, rel=canonical points there instead of this
* site's own URL. Product pages pass the SKU's canonical home on the main
* store so shared SKUs never compete as duplicate content (panel SEO must-do).
*/
function gtagSnippet(mid) {
if (!mid) return '';
return `<!-- Google Analytics 4 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${mid}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${mid}');</script>
<!-- /Google Analytics 4 -->`;
}
/* gtmHeadSnippet / gtmNoscript — Google Tag Manager injection.
* id is sourced from cfg.gtm (per-site JSON field). When cfg.gtm is absent
* or empty these return '' so non-GTM sites are unaffected.
* Swap GTM-XXXXXXX for the real container id once Steve supplies it.
*
* gtmHeadSnippet goes inside <head> (as high as possible — placed just before
* the closing </head> tag, after GA4 and the anti-flash script).
* gtmNoscript goes as the FIRST child after <body> (Google requirement). */
function gtmHeadSnippet(id) {
if (!id) return '';
return `<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${id}');</script>
<!-- End Google Tag Manager -->`;
}
function gtmNoscript(id) {
if (!id) return '';
return `<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${id}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->`;
}
function head(cfg, title, desc, path, canonicalOverride, bodyClass) {
const url = 'https://' + cfg.domain + (path || '');
const canonical = canonicalOverride || url;
// og/twitter image — route through the /img proxy so the share-preview URL is
// vendor-neutral (raw cfg.heroImage leaks the vendor in its filename). server.js
// registers cfg.heroImage on both funnel + monetize paths so /img resolves it.
const heroTok = cfg.heroImage ? proxyImg(cfg.heroImage) : '';
const heroAbs = heroTok
? 'https://' + cfg.domain + heroTok
: (cfg.heroImage && !/^https?:/.test(cfg.heroImage) ? 'https://' + cfg.domain + cfg.heroImage : '');
const ogImg = heroAbs
? `<meta property="og:image" content="${esc(heroAbs)}"><meta name="twitter:image" content="${esc(heroAbs)}">`
: '';
return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(desc)}">
<meta name="brand" content="${esc(cfg.siteName)}">
${cfg.gscVerify ? `<meta name="google-site-verification" content="${esc(cfg.gscVerify)}">` : ''}
<link rel="canonical" href="${esc(canonical)}">
<meta property="og:type" content="website"><meta property="og:site_name" content="${esc(cfg.siteName)}">
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}">
<meta property="og:url" content="${esc(url)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(title)}"><meta name="twitter:description" content="${esc(desc)}">
${ogImg}${faviconTag(cfg)}
${gtagSnippet(cfg.ga4)}${gtmHeadSnippet(cfg.gtm)}${adsenseHead(cfg)}<style>:root{--top-fg:#fff}</style>
<script>(function(){var s=localStorage.getItem('${cfg.slug}_theme');if(s)document.documentElement.dataset.theme=s;})();</script>
<style>${css(cfg)}</style>${cfg.monetize ? uniqueStyle(cfg) : ''}
</head><body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>${gtmNoscript(cfg.gtm)}`;
}
/* uniqueStyle — makes each monetize site look DISTINCT (not a template farm, which
* is both an AdSense doorway/scaled-content rejection risk and just looks cookie-cutter).
* (1) Actually LOADS the site's serif + a rotated display sans from Google Fonts —
* the configs already vary the serif family but nothing loaded them, so every site
* fell back to system serif and looked identical. (2) Applies one of several per-site
* STYLE PRESETS (heading treatment, hero gradient angle, panel radius, nav accent),
* chosen deterministically from the slug so a site's look is stable but differs from
* its neighbours. Pure additive CSS layered over css()/monetizeCss(). */
function uniqueStyle(cfg) {
const slug = cfg.slug || cfg.domain || '';
let h = 0; for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
// display serif families we support (must match what configs reference) + a rotated sans
const SERIFS = { cormorant: 'Cormorant+Garamond:wght@400;500;600', playfair: 'Playfair+Display:wght@400;600;700', bodoni: 'Bodoni+Moda:wght@400;500;600', fraunces: 'Fraunces:wght@400;500;600', libre: 'Libre+Baskerville:wght@400;700', dmserif: 'DM+Serif+Display' };
const SANS = [ 'Inter:wght@400;500;600', 'Work+Sans:wght@400;500;600', 'DM+Sans:wght@400;500', 'Space+Grotesk:wght@400;500', 'Libre+Franklin:wght@400;500;600', 'Archivo:wght@400;500;600' ];
const serifFam = /cormorant/i.test(cfg.theme.serif || '') ? 'cormorant'
: /playfair/i.test(cfg.theme.serif || '') ? 'playfair'
: /bodoni/i.test(cfg.theme.serif || '') ? 'bodoni'
: ['fraunces', 'libre', 'dmserif', 'cormorant'][h % 4];
const serifStack = { cormorant: "'Cormorant Garamond',Georgia,serif", playfair: "'Playfair Display',Georgia,serif", bodoni: "'Bodoni Moda',Didot,serif", fraunces: "'Fraunces',Georgia,serif", libre: "'Libre Baskerville',Georgia,serif", dmserif: "'DM Serif Display',Georgia,serif" }[serifFam];
const sansKey = SANS[h % SANS.length];
const sansFam = "'" + sansKey.split(':')[0].replace(/\+/g, ' ') + "',-apple-system,system-ui,sans-serif";
const fontsHref = `https://fonts.googleapis.com/css2?family=${SERIFS[serifFam]}&family=${sansKey}&display=swap`;
// per-site presets
const heroAngle = [120, 135, 160, 100, 200, 145][h % 6];
const headTransform = ['none', 'uppercase', 'none', 'uppercase'][(h >> 2) % 4];
const headSpacing = headTransform === 'uppercase' ? '.06em' : (['0', '.01em', '-.01em'][(h >> 3) % 3]);
const headWeight = [500, 600, 700, 400][(h >> 4) % 4];
const panelRadius = [10, 4, 14, 2][(h >> 5) % 4];
const navSticky = (h % 2) ? 'sticky' : 'static';
return `
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="${fontsHref}">
<style>
:root{--serif:${serifStack};--sans:${sansFam};}
.m-hero{background:linear-gradient(${heroAngle}deg,var(--accent) 0%,var(--bg) 62%)!important;}
[data-theme=dark] .m-hero{background:linear-gradient(${heroAngle}deg,var(--accent) 0%,#0c0a08 70%)!important;}
.m-hero h1,.m-brand,.m-panel>summary,.m-sec h2,.m-article .lede,.m-legal h1{font-family:var(--serif)!important;}
.m-hero h1{text-transform:${headTransform};letter-spacing:${headSpacing};font-weight:${headWeight};}
.m-panel{border-radius:${panelRadius}px!important;}
.m-nav{position:${navSticky}!important;}
</style>`;
}
function jsonld(cfg) {
const org = {
'@context':'https://schema.org','@type':'Organization',
name: cfg.siteName, url:'https://'+cfg.domain, email: cfg.siteEmail,
parentOrganization:{ '@type':'Organization', name:'Designer Wallcoverings',
address:{ '@type':'PostalAddress', streetAddress:'15442 Ventura Bl #102',
addressLocality:'Sherman Oaks', addressRegion:'CA', postalCode:'91403' } }
};
return `<script type="application/ld+json">${JSON.stringify(org).replace(/<\/script/gi,'<\\/script')}</script>`;
}
/* ---------- card ----------
* Wrapper is a <div> (NOT <a>) — an <a> wrapping the inner controls would be
* split by the browser's Adoption Agency Algorithm into sibling grid items.
* The crawlable link is an inner <a class=card-link> pointing at
* /product/:slug (productSlug — "wallpaper"-free) so search engines reach
* every per-SKU page on a clean URL. data-handle carries the CLEAN productSlug
* (vendor-free) — the quick-view buy-sample link points at the fleet's own
* /buy/:slug route, which resolves the real Shopify handle SERVER-SIDE and
* 302-redirects to the DW store, so the raw vendor-laden handle never reaches
* the rendered DOM (2026-05-29 settlement vendor-confidentiality, DTD verdict B).
*/
function card(p) {
const hx = dominantHex(p);
const t = scrubVendor(clean(p.title).split('|')[0].trim())
|| scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
|| 'Designer Wallcovering';
const href = '/product/' + encodeURIComponent(productSlug(p));
const img = proxyImg(p.image_url); // vendor-neutral /img/<token> (see imgToken)
return `<div class="card" data-handle="${esc(productSlug(p))}"
data-title="${esc(t)}" data-sku="${esc(scrubVendor(p.sku||p.handle))}"
data-img="${esc(img)}" data-tags="${esc(scrubTags(p.tags).slice(0,12).join(', '))}"
data-price="${esc(p.price)}" data-hex="${esc(hx)}">
<a class="card-link" href="${esc(href)}" aria-label="${esc(t)}">
<div class="card-img" style="background-image:url('${esc(img)}');--card-bg:url('${esc(img)}')"></div>
<div class="card-body">
<div class="card-title">${esc(t)}</div>
<div class="card-meta"><span class="dot" style="background:${hx}"></span>
Designer Wallcoverings · ${esc(scrubVendor(p.sku||p.handle))}</div>
<div class="card-sample">View Details</div>
</div>
</a></div>`;
}
/* ---------- home page ---------- */
function homePage(cfg, heroImgs, featured) {
const title = `${cfg.siteName} | ${cfg.tagline}`;
const desc = clean(cfg.metaDesc);
// Hero images are raw Shopify CDN urls (vendor in filename) — route them
// through the same /img proxy so the homepage hero doesn't leak the vendor.
const slides = heroImgs.slice(0, 8).map(proxyImg).filter(Boolean);
const heroHtml = slides.length >= 3
? slides.map((s,i)=>`<div class="cinema-slide${i===0?' on':''}" style="background-image:url('${esc(s)}')"></div>`).join('')
: `<div class="cinema-grid">${slides.concat(slides).slice(0,4).map(s=>`<div style="background-image:url('${esc(s)}')"></div>`).join('')}</div>`;
// Rotating "new arrivals" strip — first element in <body>, above topbar+hero.
// featured is already newest-first (created_at desc); promoStrip takes the 3
// newest. body.promo-on (set server-side) offsets the fixed chrome below it.
const promo = promoStrip(cfg, featured);
return head(cfg, title, desc, '/', null, promo ? 'promo-on' : '') + promo + jsonld(cfg) + chrome(cfg) + `
<header class="cinema" data-hero-mode="huge">
${heroHtml}
<div class="hero-cta">
<span class="hero-cta__sub">${esc(clean(cfg.heroSub))}</span>
<a class="hero-cta__btn" href="/catalog">Enter</a>
</div>
</header>
<div class="hero-mark">
<h1>${esc(cfg.heroHeadline)}</h1>
<p>${esc(clean(cfg.tagline))} · Curated by Designer Wallcoverings</p>
</div>
<div class="wrap">
<div class="controls">
<div class="ctl-sort">
<label for="sortSel">Featured Selection · Sort</label>
<select id="sortSel">
<option value="newest">Featured</option><option value="color">Color</option>
<option value="light-dark">Light → Dark</option><option value="dark-light">Dark → Light</option>
<option value="style">Style</option><option value="sku">SKU A→Z</option>
<option value="title">Title A→Z</option><option value="price-asc">Price ↑</option>
<option value="price-desc">Price ↓</option>
</select>
</div>
<div class="ctl-density">
<label for="densSel">Density</label>
<input type="range" id="densSel" min="3" max="8" value="5">
<span class="count">${featured.length} patterns</span>
</div>
</div>
<div class="grid" id="grid">${featured.map(card).join('')}</div>
<div style="text-align:center;padding:10px 0 50px">
<a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">View Full Catalog</a>
</div>
</div>
${igEmbed(cfg)}
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
${gridControls(cfg)}
${heroRotate(slides.length)}
</body></html>`;
}
// Official Instagram embed (embed.js) of the site's latest real posts — rendered only
// when the site JSON carries cfg.instagram_posts (set for fleet-served sister sites like
// patterndesignlab / hollywoodwallcovering, whose home is this homePage render). TK-11322.
function igEmbed(cfg) {
const handle = String(cfg.instagram || '').replace(/^@/, '').trim();
const posts = Array.isArray(cfg.instagram_posts) ? cfg.instagram_posts.filter(Boolean) : [];
if (!handle || !posts.length) return '';
const cards = posts.map(u =>
` <blockquote class="instagram-media" data-instgrm-permalink="${esc(u)}" data-instgrm-version="14" style="margin:0;min-width:250px;max-width:328px;width:100%"></blockquote>`
).join('\n');
return `
<section class="ig-embed" aria-label="Follow us on Instagram" style="max-width:1120px;margin:44px auto;padding:0 20px">
<h2 style="font-family:var(--serif,Georgia,serif);font-weight:500;letter-spacing:.02em;text-align:center;margin:0 0 20px">Follow <a href="https://www.instagram.com/${esc(handle)}/" target="_blank" rel="noopener noreferrer">@${esc(handle)}</a> on Instagram</h2>
<div class="ig-embed__row" style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center;align-items:flex-start">
${cards}
</div>
</section>
<script async src="//www.instagram.com/embed.js"></script>`;
}
/* client-side sort + density for the homepage Featured grid (catalog does it
server-side; the featured set is small + server-rendered, so reorder in JS).
Shares localStorage keys with /catalog so the shopper's choice carries over. */
function gridControls(cfg){
return `<script>
(function(){
var dk='${cfg.slug}_density',sk='${cfg.slug}_sort';
var g=document.getElementById('grid'),d=document.getElementById('densSel'),s=document.getElementById('sortSel');
if(!g||!d||!s)return;
// TK-11538: localStorage ACCESS itself throws in Safari private mode / blocked site data — guard it (matches catalogPage read()/save())
function read(k){try{return localStorage.getItem(k);}catch(e){return null;}}
function save(k,v){try{localStorage.setItem(k,v);}catch(e){}}
var cards=Array.prototype.slice.call(g.children);
cards.forEach(function(c,i){c.__i=i;}); // original "Featured" order
var sv=read(dk);if(sv!=null){d.value=sv;if(d.max!==''&&+sv>+d.max)d.value=d.max;if(d.min!==''&&+sv<+d.min)d.value=d.min;} // clamp saved density to the range's bounds
function applyD(){g.style.setProperty('--cols',d.value);}
d.addEventListener('input',function(){applyD();save(dk,d.value);});
applyD();
function hl(hex){var m=/([0-9a-f]{6})/i.exec(hex||'');if(!m)return[999,999];
var n=parseInt(m[1],16),r=(n>>16&255)/255,gg=(n>>8&255)/255,b=(n&255)/255;
var mx=Math.max(r,gg,b),mn=Math.min(r,gg,b),l=(mx+mn)/2,h=0,df=mx-mn;
if(df){if(mx==r)h=((gg-b)/df)%6;else if(mx==gg)h=(b-r)/df+2;else h=(r-gg)/df+4;h*=60;if(h<0)h+=360;}
return[h,l];}
function val(c,k){var x=c.dataset;
if(k==='title')return(x.title||'').toLowerCase();
if(k==='sku')return(x.sku||'').toLowerCase();
if(k==='style')return(x.tags||'').toLowerCase();
if(k==='price-asc'||k==='price-desc')return parseFloat(x.price)||0;
return hl(x.hex);}
function sortBy(k){
var arr=cards.slice();
arr.sort(function(a,b){
if(k==='newest')return a.__i-b.__i;
if(k==='price-asc')return val(a,k)-val(b,k);
if(k==='price-desc')return val(b,k)-val(a,k);
if(k==='color')return val(a,'color')[0]-val(b,'color')[0];
if(k==='light-dark')return val(b,'color')[1]-val(a,'color')[1];
if(k==='dark-light')return val(a,'color')[1]-val(b,'color')[1];
var av=val(a,k),bv=val(b,k);return av<bv?-1:av>bv?1:0;
});
arr.forEach(function(c){Node.prototype.appendChild.call(g,c);});// off the prototype for consistency with catalogPage (defense-in-depth; a <div>'s appendChild isn't clobberable by children the way a <form>'s named controls clobber the form)
}
var saved=read(sk);
if(saved){var ok=Array.prototype.some.call(s.options,function(o){return o.value===saved;});if(ok){s.value=saved;sortBy(saved);}}
s.addEventListener('change',function(){save(sk,s.value);sortBy(s.value);});
})();
</script>`;
}
/* ---------- catalog page ---------- */
function catalogPage(cfg, products, q, sort, page, pages, total) {
const title = `Catalog — ${cfg.siteName}`;
const desc = `Browse ${total} ${clean(cfg.nicheLabel)} from Designer Wallcoverings. Free memo samples, trade service, expert hand-holding on every order.`;
const sopt = (v,l)=>`<option value="${v}"${sort===v?' selected':''}>${l}</option>`;
return head(cfg, title, desc, '/catalog') + jsonld(cfg) + chrome(cfg) + `
<div style="height:84px"></div>
<div class="wrap">
<div class="hero-mark" style="padding:30px 0 4px">
<h1 style="font-size:clamp(32px,4vw,52px)">${esc(cfg.siteName)}</h1>
<p>${esc(clean(cfg.tagline))}</p>
</div>
<form class="controls" method="get" action="/catalog" id="ctlForm">
<div class="ctl-sort">
<input type="search" name="q" value="${esc(q)}" placeholder="Search ${total} patterns…">
<label for="sortSel">Sort</label>
<select name="sort" id="sortSel">
${sopt('newest','Newest')}${sopt('color','Color')}${sopt('light-dark','Light → Dark')}
${sopt('dark-light','Dark → Light')}${sopt('style','Style')}${sopt('sku','SKU A→Z')}
${sopt('title','Title A→Z')}${sopt('price-asc','Price ↑')}${sopt('price-desc','Price ↓')}
</select>
</div>
<div class="ctl-density">
<label for="densSel">Density</label>
<input type="range" id="densSel" min="3" max="8" value="5">
<span class="count">${total} patterns${q?` · "${esc(q)}"`:''}</span>
</div>
</form>
<div class="grid" id="grid">${products.map(card).join('')}</div>
${pages>1?pager(q,sort,page,pages):''}
</div>
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
<script>
(function(){
// Density persists; SORT DELIBERATELY DOES NOT (Steve, TK-11470). The dropdown is always
// whatever ?sort= says, so it can never show one ordering above a differently-ordered grid,
// Back genuinely undoes a sort, and there is no extra redirect/page_view per visit.
var dk='${cfg.slug}_density';
var d=document.getElementById('densSel'),g=document.getElementById('grid');
var ss=document.getElementById('sortSel'),form=document.getElementById('ctlForm');
function read(k){try{return localStorage.getItem(k);}catch(e){return null;}}
function save(k,v){try{localStorage.setItem(k,v);}catch(e){}}
var sv=read(dk);if(sv && +sv>=3 && +sv<=8)d.value=sv;
function apply(){g.style.setProperty('--cols',d.value);}
d.addEventListener('input',function(){apply();save(dk,d.value);});
apply();
// Successful GET controls preserve extra query values; a new selection starts page one.
var params=new URLSearchParams(location.search);
params.forEach(function(value,key){
if(key==='q'||key==='sort'||key==='page')return;
var input=document.createElement('input');input.type='hidden';
input.name=key;input.value=value;Node.prototype.appendChild.call(form,input);
});
// requestSubmit() is Safari 16+ (Sep 2022); on older WebKit the property is undefined and
// .call() would throw inside this listener, silently killing the dropdown again — the very
// bug this block exists to fix, just with a new cause. Fall back to submit(), still called
// off the prototype so a field named "submit"/"requestSubmit" can't clobber it.
function send(){
var F=HTMLFormElement.prototype;
try{ if(typeof F.requestSubmit==='function'){F.requestSubmit.call(form);return;} }catch(e){}
F.submit.call(form);
}
ss.addEventListener('change',send);
// Browsers restore form-control state across history navigation, so after Back the <select>
// can still read "SKU A-Z" above a Newest grid — the same lie by another route. Re-sync it to
// the URL on every show (pageshow also covers bfcache restores, which fire no other event).
window.addEventListener('pageshow',function(){
var want=params.get('sort')||'newest';
try{ want=new URLSearchParams(location.search).get('sort')||'newest'; }catch(e){}
var known=Array.prototype.some.call(ss.options,function(o){return o.value===want;});
if(known && ss.value!==want) ss.value=want;
});
})();
</script>
</body></html>`;
}
function pager(q,sort,page,pages){
const link=(p)=>`/catalog?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&page=${p}`;
let html='<div style="display:flex;gap:8px;justify-content:center;padding:10px 0 50px;flex-wrap:wrap">';
const lo=Math.max(1,page-3),hi=Math.min(pages,page+3);
if(page>1)html+=`<a class="hero-cta__btn" style="position:static;padding:9px 16px" href="${link(page-1)}">Prev</a>`;
for(let i=lo;i<=hi;i++)html+=`<a href="${link(i)}" style="padding:9px 14px;border:1px solid var(--rule);border-radius:3px;${i===page?'background:var(--accent);color:#fff':''}">${i}</a>`;
if(page<pages)html+=`<a class="hero-cta__btn" style="position:static;padding:9px 16px" href="${link(page+1)}">Next</a>`;
return html+'</div>';
}
function aboutPage(cfg){
const title=`About — ${cfg.siteName}`;
return head(cfg,title,clean(cfg.metaDesc),'/about')+jsonld(cfg)+chrome(cfg)+`
<div style="height:84px"></div>
<div class="wrap" style="max-width:760px">
<div class="hero-mark" style="padding:40px 0 10px"><h1>${esc(cfg.siteName)}</h1></div>
<p style="font-size:17px;line-height:1.8;color:var(--fg);margin:20px 0">${esc(clean(cfg.aboutCopy))}</p>
<p style="font-size:15px;line-height:1.8;color:var(--muted);margin:18px 0">
${esc(cfg.siteName)} is part of Designer Wallcoverings — a Sherman Oaks design house serving
the trade since 1986. Every pattern ships with a free memo sample, and our team hand-holds
each order from selection through installation. Reach us at
<a href="mailto:${esc(cfg.siteEmail)}" style="color:var(--accent)">${esc(cfg.siteEmail)}</a>.</p>
<p style="margin:26px 0 60px"><a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">Browse the Catalog</a></p>
</div>
${footer(cfg)}${script(cfg)}</body></html>`;
}
/* ---------- info page ----------
* Per-site informational page: memo samples, trade program, shipping &
* lead times, and a short FAQ. Driven entirely by cfg so it reads well for
* any fleet site. rel=canonical points to this site's own /info.
*/
function infoPage(cfg){
const title=`Info — ${cfg.siteName}`;
const niche=clean(cfg.nicheLabel);
const desc=`Memo samples, trade program, shipping and lead times for ${clean(cfg.siteName)} — ${niche} from a Sherman Oaks design house.`;
const sec=(h,body)=>`
<section style="margin:34px 0;padding-bottom:30px;border-bottom:1px solid var(--rule)">
<h2 style="font-family:var(--serif);font-size:clamp(24px,3vw,34px);margin-bottom:12px">${h}</h2>
${body}
</section>`;
const faq=[
['Do memo samples cost anything?',
`No. Memo samples are free and mailed to you so you can judge colour, texture, and scale in your own light before committing.`],
['How many samples can I request?',
`Request as many as your project needs. We would rather you see every ${niche} option in person than guess from a screen.`],
['Can you help me choose?',
`Yes. Our team hand-holds every order — share your room, lighting, and palette and we will narrow the field with you.`],
['Do you work with designers and architects?',
`Absolutely — the trade program below is built for designers, architects, and specifiers.`]
];
return head(cfg,title,desc,'/info')+jsonld(cfg)+chrome(cfg)+`
<div style="height:84px"></div>
<div class="wrap" style="max-width:820px">
<div class="hero-mark" style="padding:40px 0 6px"><h1>Information</h1>
<p>${esc(clean(cfg.siteName))} · ${esc(niche)}</p></div>
${sec('Memo Samples', `
<p style="font-size:16px;line-height:1.8;color:var(--fg)">
Every pattern in our catalogue ships with a <strong>free memo sample</strong> — a mailed
swatch you can hold against your wall, in your own light, before you order a single roll.
Browse the catalogue, request the swatches that interest you, and we will put them in the
post. There is no charge and no obligation.</p>
<p style="margin-top:16px"><a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">Browse & Request Samples</a></p>`)}
${sec('Trade Program', `
<p style="font-size:16px;line-height:1.8;color:var(--fg)">
Designers, architects, and specifiers receive trade pricing, priority memo-sample
handling, and a dedicated point of contact for every project. If you work in the
trade, enrol once and the pricing applies across the full ${esc(niche)} collection.</p>
<p style="margin-top:14px"><a href="${DW}/pages/trade" rel="noopener noreferrer"
style="color:var(--accent);font-weight:500">View the Trade Program →</a></p>`)}
${sec('Shipping & Lead Times', `
<p style="font-size:16px;line-height:1.8;color:var(--fg)">
Memo samples typically ship within one to two business days. Full orders are made to
order — most ${esc(niche)} ship in roughly two to four weeks, though hand-finished and
specialty lines can take longer. We confirm the lead time for your specific selection
before the order is placed, and our team keeps you posted from selection through
delivery and installation. We ship across the United States.</p>`)}
${sec('Frequently Asked', faq.map(([q,a])=>`
<div style="margin:16px 0">
<div style="font-family:var(--serif);font-size:19px;color:var(--fg)">${q}</div>
<p style="font-size:15px;line-height:1.75;color:var(--muted);margin-top:5px">${a}</p>
</div>`).join(''))}
<p style="margin:6px 0 60px;font-size:15px;color:var(--muted);line-height:1.8">
Still have a question? Email
<a href="mailto:${esc(cfg.siteEmail)}" style="color:var(--accent)">${esc(cfg.siteEmail)}</a>
and a real person will answer.</p>
</div>
${footer(cfg)}${script(cfg)}</body></html>`;
}
function footer(cfg){
return `<footer><div class="wrap">
<div><div class="fb">${esc(cfg.siteName)}</div>
<div style="margin-top:6px">${esc(clean(cfg.tagline))}</div>
<div style="margin-top:4px"><a href="mailto:${esc(cfg.siteEmail)}">${esc(cfg.siteEmail)}</a></div></div>
<div><strong style="color:var(--fg)">Designer Wallcoverings</strong><br>
15442 Ventura Bl #102, Sherman Oaks CA 91403<br>
<a href="${DW}">designerwallcoverings.com</a></div>
<div>Free memo samples · Trade service<br>Expert order hand-holding<br>
© ${new Date().getFullYear()} ${esc(cfg.siteName)}</div>
</div></footer>`;
}
function nsModal(cfg){
return `<div class="nsm-back" id="nsmBack">
<div class="nsm" onclick="event.stopPropagation()">
<div class="nsm-close" id="nsmClose">×</div>
<div class="nsm-img" id="nsmImg"></div>
<div class="nsm-body">
<h3 id="nsmTitle"></h3><div class="sku" id="nsmSku"></div>
<div class="nsm-chips" id="nsmChips"></div>
<p style="font-size:14px;color:var(--muted);line-height:1.7">
Available through Designer Wallcoverings with a free memo sample.
Our team will hand-hold your order — from selection through installation.</p>
<a class="nsm-cta" id="nsmCta" target="_blank" rel="noopener noreferrer">Order Memo Sample</a>
</div>
</div>
</div>
<script>
(function(){
var back=document.getElementById('nsmBack');
function open(c){
document.getElementById('nsmImg').style.backgroundImage="url('"+c.dataset.img+"')";
document.getElementById('nsmTitle').textContent=c.dataset.title;
document.getElementById('nsmSku').textContent='Designer Wallcoverings · '+c.dataset.sku;
var chips=document.getElementById('nsmChips');chips.innerHTML='';
(c.dataset.tags||'').split(',').map(function(s){return s.trim();}).filter(Boolean).slice(0,8)
.forEach(function(t){var s=document.createElement('span');s.textContent=t;chips.appendChild(s);});
document.getElementById('nsmCta').href='/buy/'+encodeURIComponent(c.dataset.handle);
back.classList.add('open');
}
// cards are real crawlable links to /product/:handle — they navigate.
// the modal stays available for any element opting in via .quick-view.
document.addEventListener('click',function(e){
var c=e.target.closest('.quick-view');if(c){e.preventDefault();open(c);return;}
});
document.getElementById('nsmClose').addEventListener('click',function(){back.classList.remove('open');});
back.addEventListener('click',function(){back.classList.remove('open');});
})();
</script>`;
}
function heroRotate(n){
if(n<3)return '';
return `<script>
(function(){
var sl=document.querySelectorAll('.cinema-slide'),i=0;
if(sl.length<2)return;
setInterval(function(){
sl[i].classList.remove('on');
i=(i+1)%sl.length;
sl[i].classList.add('on');
},5200);
})();
</script>`;
}
/* ---------- product page ----------
* Per-SKU page. rel=canonical points to the SKU's canonical home on the main
* store (designerwallcoverings.com/products/<handle>) so a shared SKU rendered
* across multiple fleet sites never competes with the main store or its
* siblings as duplicate content. Title + meta description are per-site-unique
* (carry the site name + tagline), satisfying the panel SEO must-do.
*/
function productPage(cfg, p, related) {
const name = scrubVendor(clean(p.title).split('|')[0].trim())
|| scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
|| 'Designer Wallcovering';
const sku = scrubVendor(p.sku || p.handle); // display-only; raw p.handle still used for DW-store links
const imgProxy = proxyImg(p.image_url); // /img/<token> — vendor-neutral
const imgAbs = imgProxy ? `https://${cfg.domain}${imgProxy}` : ''; // absolute for JSON-LD image
// Self-canonical on the CLEAN slug (vendor-free). Was ${DW}/products/<rawHandle>,
// which leaked 3rd-party vendor names in the rendered <link>/JSON-LD. Buy + main-store
// links now route through /buy/:slug (server resolves the real handle + 302-redirects),
// so the vendor handle never appears in the DOM.
const canonical = `https://${cfg.domain}/product/${encodeURIComponent(productSlug(p))}`;
// per-site-unique title + meta — site name makes each fleet copy distinct
const title = `${name} — ${sku} | ${cfg.siteName}`;
const desc = `${name} (${sku}) — ${clean(cfg.tagline)} Available through ${cfg.siteName} and Designer Wallcoverings with a free memo sample and expert order hand-holding.`;
const hx = dominantHex(p);
const chips = scrubTags(p.tags).filter(Boolean).slice(0, 12)
.map(t => `<span>${esc(t)}</span>`).join('');
const productLd = {
'@context':'https://schema.org','@type':'Product',
name, sku, image: imgAbs, brand:{ '@type':'Brand', name:'Designer Wallcoverings' },
url: canonical
};
return head(cfg, title, desc, '/product/' + encodeURIComponent(productSlug(p)), canonical)
+ `<script type="application/ld+json">${JSON.stringify(productLd).replace(/<\/script/gi,'<\\/script')}</script>`
+ jsonld(cfg) + chrome(cfg) + `
<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
(Steve 2026-05-29). dw-domain-fleet has no admin auth, so this is
security-by-obscurity: ?theme=2 in the URL flips to theme2 and persists
in localStorage. The toggle UI itself is hidden until theme2 mode is
active so default visitors never see it. Shares localStorage key
'wallco-page-theme' for cross-site continuity. -->
<script>
(function(){
var qs = new URLSearchParams(location.search);
var fromUrl = qs.get('theme');
var saved = null; try { saved = localStorage.getItem('wallco-page-theme'); } catch(e){}
var theme = null;
if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
else if (saved === 'theme2' || saved === 'theme1') theme = saved;
if (theme) {
document.documentElement.setAttribute('data-page-theme', theme);
if (fromUrl) { try { localStorage.setItem('wallco-page-theme', theme); } catch(e){} }
}
/* No opt-in → no attribute set, toggle stays display:none (gate works). */
})();
</script>
<style id="page-theme-css-fleet">
[data-page-theme="theme2"] body{
--bg:#ffffff !important; --ink:#0a0a0a !important;
--muted:#5a5a5a !important; --line:#e5e5e5 !important;
--card-bg:#fafafa !important; --accent:#c14a2e !important;
background:#fff !important; color:#0a0a0a !important;
}
[data-page-theme="theme2"] h1,
[data-page-theme="theme2"] h2,
[data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
[data-page-theme="theme2"] .card{
background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
}
#page-theme-toggle{ display:none; }
[data-page-theme] #page-theme-toggle{ display:inline-flex; }
#page-theme-toggle{
position:fixed; top:96px; right:14px; z-index:90;
gap:0; align-items:center;
background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
border-radius:999px; padding:3px; backdrop-filter:blur(6px);
box-shadow:0 2px 10px rgba(0,0,0,.08);
font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
}
#page-theme-toggle button{
border:0; background:transparent; color:#5a5048; cursor:pointer;
padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
}
#page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
#page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
</style>
<div id="page-theme-toggle" role="group" aria-label="Page theme">
<button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
<button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
</div>
<script>
(function(){
var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
var btns = document.querySelectorAll('#page-theme-toggle button');
function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
sync(cur);
btns.forEach(function(b){
b.addEventListener('click', function(){
var t = b.dataset.theme;
document.documentElement.setAttribute('data-page-theme', t);
try { localStorage.setItem('wallco-page-theme', t); } catch(e){}
sync(t);
});
});
})();
</script>
<div style="height:84px"></div>
<div class="wrap">
<div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;padding:28px 0 10px"
class="pdp-grid">
<div class="card-img" style="background-image:url('${esc(imgProxy)}');
border-radius:6px;min-height:clamp(360px,52vw,640px)"></div>
<div>
<div style="font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)">
${esc(cfg.siteName)}</div>
<h1 style="font-family:var(--serif);font-size:clamp(30px,3.4vw,46px);margin:10px 0 6px">
${esc(name)}</h1>
<div class="card-meta" style="margin-bottom:18px"><span class="dot" style="background:${hx}"></span>
Designer Wallcoverings · ${esc(sku)}</div>
<div class="nsm-chips" style="display:flex;flex-wrap:wrap;gap:6px;margin:14px 0">${chips}</div>
<p style="font-size:14px;color:var(--muted);line-height:1.7;margin:16px 0">
${esc(name)} is available through ${esc(cfg.siteName)} — part of Designer Wallcoverings.
Order a free memo sample and our team will hand-hold your order from selection
through installation.</p>
<a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff"
href="/buy/${esc(productSlug(p))}" target="_blank" rel="noopener noreferrer">
Order Memo Sample</a>
<p style="font-size:12px;color:var(--muted);margin:14px 0 0">
Catalogued by Designer Wallcoverings ·
<a href="/buy/${esc(productSlug(p))}" rel="noopener noreferrer" style="color:var(--accent)">View on the main store</a></p>
</div>
</div>
${related && related.length ? `
<div class="controls" style="margin-top:38px"><label>Related Selections</label></div>
<div class="grid">${related.map(card).join('')}</div>` : ''}
</div>
<style>@media(max-width:780px){.pdp-grid{grid-template-columns:1fr!important}}</style>
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
</body></html>`;
}
/* ============================================================================
* MONETIZE MODE (Task #2 — parked / for-sale SEO domains only)
* A site with cfg.monetize === true is NOT a DW funnel. It renders a standalone,
* genuinely-written single-topic content site (nav + hero + article + guide +
* Privacy) so it passes Google AdSense review, then carries AdSense Auto Ads
* (publisher pub-5278231299883833) and a dismissible "domain for sale" banner
* that routes make-an-offer traffic to the greendomainbrokers broker.
* The DW catalog / buy-sample funnel is deliberately absent here — those sites
* keep the untouched funnel template above. Nothing here is customer-facing DW.
* ==========================================================================*/
const ADSENSE_PUB = 'pub-5278231299883833';
const BROKER_URL = 'https://greendomainbrokers.com';
/* AdSense Auto Ads loader — injected into <head> only when cfg.monetize (or an
* explicit cfg.adsense) is set. Auto Ads place units automatically, so no manual
* <ins> slots are needed. Google also requires a matching /ads.txt (served by
* server.js) and a Privacy Policy page (privacyPage below). */
function adsenseHead(cfg) {
if (!cfg.monetize && !cfg.adsense) return '';
return `<!-- Google AdSense Auto Ads -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ADSENSE_PUB}" crossorigin="anonymous"></script>
<!-- /Google AdSense -->`;
}
/* Dismissible "this domain is for sale" banner → greendomainbrokers make-offer.
* Reuses the .promo-strip visual contract (white ink on --accent). Sits as the
* first in-flow element (body.promo-on offsets the fixed chrome). */
function forSaleBanner(cfg) {
// APPROVAL-FIRST (Steve 2026-09-09): a prominent "for sale" banner reads as a
// parked domain to AdSense review, so it stays OFF until forSalePhase === 'live'
// (flip per site AFTER AdSense approval). Until then the only sale signal is the
// subtle neutral "domain inquiries" line in monetizeFooter.
if (cfg.forSalePhase !== 'live') return '';
if (!cfg.monetize && !cfg.forSale) return '';
const url = (cfg.brokerUrl || BROKER_URL) + '/?domain=' + encodeURIComponent(cfg.domain);
return `
<div class="promo-strip forsale-strip" id="forSaleStrip" role="region" aria-label="Domain for sale">
<div class="promo-track">
<a class="promo-item on" href="${esc(url)}" target="_blank" rel="noopener noreferrer nofollow">
<span class="promo-tag">For Sale</span>
<span class="promo-dot" aria-hidden="true">·</span>
<span class="promo-name">${esc(cfg.domain)} is available — make an offer</span>
<span class="promo-arrow" aria-hidden="true">→</span></a>
</div>
<button class="promo-x" id="forSaleX" type="button" aria-label="Dismiss for-sale banner">×</button>
</div>
<script>
(function(){
var k='${cfg.slug}_forsale_dismiss',s=document.getElementById('forSaleStrip');
if(!s)return;
try{if(localStorage.getItem(k)==='1'){s.style.display='none';document.body.classList.remove('promo-on');return;}}catch(e){}
var x=document.getElementById('forSaleX');
x&&x.addEventListener('click',function(){s.style.display='none';document.body.classList.remove('promo-on');
try{localStorage.setItem(k,'1');}catch(e){}});
})();
</script>`;
}
/* Monetize-mode nav — no DW catalog / memo / trade links (this is not a DW
* funnel). Home / Guide / About / Privacy / Contact only. */
function monetizeChrome(cfg) {
// In-flow sticky header (not fixed) so it never overlaps the for-sale strip
// or the hero. Inline nav — no hamburger overlay needed at this link count.
return `
<header class="m-nav">
<a class="m-brand" href="/">${esc(cfg.siteName)}</a>
<nav class="m-nav__links">
<a href="/">Home</a>
${(cfg.content && Array.isArray(cfg.content.articles) && cfg.content.articles.length) ? '<a href="/articles">Articles</a>' : ''}
<a href="/guide">Guide</a>
<a href="/about">About</a>
<a href="/privacy">Privacy</a>
<a href="/contact">Contact</a>
<button class="theme-toggle" id="themeBtn" aria-label="Toggle theme">
<svg class="icon-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>
<svg class="icon-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</nav>
</header>`;
}
/* Monetize-mode CSS — a real content-site hero + article layout, isolated from
* the funnel template's catalog CSS. Uses the site theme vars already in css(). */
function monetizeCss() {
return `
/* pattern grid — up to 20 niche patterns as a responsive grid (TK-11322) */
.m-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin:28px 0 32px}
.m-gallery__it{position:relative;margin:0;overflow:hidden;border:1px solid var(--rule);border-radius:4px;background:var(--rule)}
.m-gallery__it img{display:block;width:100%;aspect-ratio:4/5;object-fit:cover;transition:transform .4s ease}
.m-gallery__it:hover img{transform:scale(1.04)}
.m-gallery__cap{position:absolute;left:0;right:0;bottom:0;padding:7px 9px;font-size:11px;letter-spacing:.02em;
color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.6));font-family:var(--serif)}
@media(max-width:520px){.m-gallery{grid-template-columns:repeat(2,1fr)}}
/* in-flow sticky header — NO fixed overlap with the for-sale strip / hero */
.m-nav{position:sticky;top:0;z-index:50;display:flex;align-items:center;justify-content:space-between;
gap:16px;padding:15px 32px;background:var(--header-bg);border-bottom:1px solid var(--rule)}
.m-nav .m-brand{font-family:var(--serif);font-size:20px;letter-spacing:.03em;color:var(--fg)}
.m-nav__links{display:flex;align-items:center;gap:24px}
.m-nav__links a{font-size:12px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted)}
.m-nav__links a:hover{color:var(--fg)}
.m-nav .theme-toggle{position:static;top:auto;right:auto;color:var(--muted)}
@media(max-width:680px){.m-nav{flex-wrap:wrap;gap:10px;padding:12px 18px}
.m-nav__links{gap:16px;font-size:11px;letter-spacing:.08em}}
.m-hero{position:relative;min-height:clamp(440px,72vh,760px);display:flex;align-items:flex-end;
justify-content:center;text-align:center;overflow:hidden;
background:linear-gradient(135deg,var(--accent) 0%,var(--bg) 62%);}
[data-theme=dark] .m-hero{background:linear-gradient(135deg,var(--accent) 0%,#0c0a08 70%);}
.m-hero__bg{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0}
.m-hero.has-img::before{content:"";position:absolute;inset:0;z-index:1;
background:linear-gradient(180deg,rgba(0,0,0,.34) 0%,rgba(0,0,0,.15) 42%,rgba(0,0,0,.74) 100%)}
.m-hero:not(.has-img)::before{content:"";position:absolute;inset:0;opacity:.10;z-index:1;
background-image:radial-gradient(circle at 20% 30%,#fff 0,transparent 42%),radial-gradient(circle at 80% 70%,#fff 0,transparent 42%);}
.m-hero__in{position:relative;z-index:2;max-width:860px;padding:48px 28px 60px}
.m-hero h1{font-family:var(--serif);font-size:clamp(44px,7vw,92px);font-weight:500;line-height:1.02;
letter-spacing:.01em;color:#fff;text-shadow:0 2px 30px rgba(0,0,0,.45);text-wrap:balance}
.m-hero p{color:rgba(255,255,255,.94);font-size:clamp(16px,2vw,21px);margin-top:18px;line-height:1.6;
max-width:60ch;margin-left:auto;margin-right:auto;text-shadow:0 1px 14px rgba(0,0,0,.4)}
.m-article{max-width:760px;margin:0 auto;padding:64px 0 30px}
.m-article .lede{font-family:var(--serif);font-size:clamp(20px,2.4vw,26px);line-height:1.55;
color:var(--fg);margin-bottom:32px}
.m-sec{margin:0 0 40px}
.m-sec h2{font-family:var(--serif);font-size:clamp(24px,3vw,34px);margin-bottom:14px;color:var(--fg)}
.m-sec p{font-size:16.5px;line-height:1.85;color:var(--fg);margin-bottom:14px}
.m-guide{background:var(--card);border:1px solid var(--rule);border-radius:10px;padding:34px 32px;margin:20px 0 10px}
.m-guide h2{font-family:var(--serif);font-size:clamp(22px,2.6vw,30px);margin-bottom:18px}
.m-guide ul{list-style:none;display:grid;gap:14px}
.m-guide li{display:flex;gap:12px;font-size:15.5px;line-height:1.6;color:var(--fg)}
.m-guide li b{font-family:var(--serif);color:var(--accent);flex:none}
/* collapsible content panels (native <details> — content is server-rendered in
the DOM, so it stays fully crawlable/indexable; collapsed != hidden for SEO) */
.m-panels{max-width:760px;margin:0 auto;padding:8px 0 30px}
.m-panel{border:1px solid var(--rule);border-radius:10px;margin:0 0 12px;background:var(--card);overflow:hidden}
.m-panel>summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;
gap:16px;padding:19px 24px;font-family:var(--serif);font-size:clamp(19px,2.3vw,26px);color:var(--fg)}
.m-panel>summary::-webkit-details-marker{display:none}
.m-panel>summary::after{content:"+";font-family:var(--sans);font-weight:400;font-size:26px;line-height:1;
color:var(--accent);flex:none}
.m-panel[open]>summary::after{content:"\\2212"}
.m-panel>summary:hover{color:var(--accent)}
.m-panel__body{padding:2px 24px 22px}
.m-panel__body p{font-size:16.5px;line-height:1.85;color:var(--fg);margin-bottom:14px}
.m-panel__body ul{list-style:none;display:grid;gap:12px;margin-top:2px}
.m-panel__body li{display:flex;gap:14px;font-size:15.5px;line-height:1.6;color:var(--fg)}
.m-panel__body li b{font-family:var(--serif);color:var(--accent);flex:none;min-width:70px}
.m-faq{margin:0 0 16px}
.m-faq h3{font-family:var(--serif);font-size:18px;color:var(--fg);margin-bottom:5px}
.m-faq p{font-size:15.5px;line-height:1.75;color:var(--muted);margin:0}
/* cookie-consent notice */
.cookie-notice{position:fixed;left:16px;right:16px;bottom:16px;z-index:120;max-width:640px;margin:0 auto;
display:flex;align-items:center;gap:16px;justify-content:space-between;flex-wrap:wrap;
background:var(--card);color:var(--fg);border:1px solid var(--rule);border-radius:10px;
padding:14px 18px;box-shadow:0 10px 34px rgba(0,0,0,.18);font-size:13.5px;line-height:1.5}
.cookie-notice a{color:var(--accent);text-decoration:underline}
.cookie-notice button{flex:none;background:var(--accent);color:#fff;border:0;border-radius:6px;
padding:9px 18px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;cursor:pointer}
.m-legal{max-width:760px;margin:0 auto;padding:52px 0 70px}
.m-legal h1{font-family:var(--serif);font-size:clamp(30px,4vw,44px);margin-bottom:8px}
.m-legal h2{font-family:var(--serif);font-size:22px;margin:26px 0 8px}
.m-legal p{font-size:15px;line-height:1.8;color:var(--fg);margin-bottom:12px}
.m-legal a{color:var(--accent)}
/* home "Read more" article cross-links + /articles index */
.m-more{margin:44px 0 4px;border-top:1px solid var(--rule);padding-top:30px}
.m-more h2,.m-index h1{font-family:var(--serif);color:var(--fg)}
.m-more h2{font-size:clamp(20px,2.4vw,26px);margin-bottom:16px}
.m-more__list,.m-index__list{list-style:none;display:grid;gap:12px}
.m-more__list a,.m-index__list a{display:block;padding:16px 20px;border:1px solid var(--rule);border-radius:10px;
background:var(--card);transition:border-color .15s,transform .15s}
.m-more__list a:hover,.m-index__list a:hover{border-color:var(--accent);transform:translateY(-1px)}
.m-more__t,.m-index__t{display:block;font-family:var(--serif);font-size:19px;color:var(--fg)}
.m-more__d,.m-index__d{display:block;font-size:14.5px;line-height:1.55;color:var(--muted);margin-top:5px}
.m-index__meta{display:block;font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-top:8px}
.m-index{max-width:760px;margin:0 auto;padding:56px 0 30px}
.m-index h1{font-size:clamp(30px,4vw,46px);margin-bottom:10px}
.m-index .lede{font-family:var(--serif);font-size:clamp(19px,2.3vw,24px);line-height:1.55;color:var(--fg);margin-bottom:28px}
/* long-form article page — real reading, prose not collapsed */
.m-post{max-width:720px;margin:0 auto;padding:52px 0 20px}
.m-post .crumb{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:14px}
.m-post .crumb a{color:var(--accent)}
.m-post h1{font-family:var(--serif);font-size:clamp(30px,4.4vw,50px);line-height:1.06;color:var(--fg);margin-bottom:12px;text-wrap:balance}
.m-post .dek{font-family:var(--serif);font-size:clamp(18px,2.2vw,23px);line-height:1.5;color:var(--muted);margin-bottom:10px}
.m-post .byline{font-size:12.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:30px;border-bottom:1px solid var(--rule);padding-bottom:20px}
.m-post h2{font-family:var(--serif);font-size:clamp(23px,2.8vw,32px);color:var(--fg);margin:36px 0 12px}
.m-post p{font-size:17px;line-height:1.9;color:var(--fg);margin-bottom:16px}
.m-post__nav{margin-top:40px;border-top:1px solid var(--rule);padding-top:26px}
.m-post__nav h3{font-family:var(--serif);font-size:19px;color:var(--fg);margin-bottom:12px}`;
}
/* Article (BlogPosting) JSON-LD — makes each long-form page eligible for
* article rich results and signals a real content property to crawlers. */
function articleJsonLd(cfg, a) {
const ld = {
'@context': 'https://schema.org', '@type': 'Article',
headline: String(a.title || ''),
description: String(a.dek || ''),
author: { '@type': 'Organization', name: cfg.siteName },
publisher: { '@type': 'Organization', name: cfg.siteName },
mainEntityOfPage: 'https://' + cfg.domain + '/articles/' + a.slug,
inLanguage: 'en'
};
return `<script type="application/ld+json">${JSON.stringify(ld).replace(/<\/script/gi, '<\\/script')}</script>`;
}
/* /articles — index of the site's long-form articles (real internal-linking
* hub; a multi-page content property reads as legitimate to AdSense review). */
function articlesIndex(cfg) {
const c = cfg.content || {};
const articles = Array.isArray(c.articles) ? c.articles : [];
const title = `Articles — ${cfg.siteName}`;
const desc = clean(`In-depth articles from ${cfg.siteName} — ${cfg.tagline || ''}`);
const lede = c.articlesLede
|| `In-depth reading on ${clean(cfg.nicheLabel || 'the subject')} — practical guides written to actually help.`;
const list = articles.map(a => `
<li><a href="/articles/${esc(a.slug)}">
<span class="m-index__t">${esc(a.title)}</span>
${a.dek ? `<span class="m-index__d">${esc(a.dek)}</span>` : ''}
${a.readMins ? `<span class="m-index__meta">${esc(String(a.readMins))} min read</span>` : ''}
</a></li>`).join('');
return head(cfg, title, desc, '/articles')
+ `<style>${monetizeCss()}</style>`
+ jsonld(cfg) + monetizeChrome(cfg) + `
<div class="wrap"><section class="m-index">
<h1>Articles</h1>
<p class="lede">${esc(lede)}</p>
<ul class="m-index__list">${list}</ul>
</section></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* /articles/:slug — one long-form article rendered as real prose (not collapsed
* panels), with a breadcrumb, an author/read-time byline, Article JSON-LD, and a
* "more articles" internal-link block at the foot. */
function articlePage(cfg, a, others) {
const title = `${a.title} — ${cfg.siteName}`;
const desc = clean(a.dek || cfg.metaDesc || cfg.tagline);
// Generated entries store paragraph prose in `body`; older entries may use
// structured `sections`. Support both schemas so articles cannot render empty.
const sections = Array.isArray(a.sections) ? a.sections : [];
const bodyHtml = !sections.length && typeof a.body === 'string'
? a.body.split(/\n\s*\n+/).map(par => `<p>${esc(par.trim())}</p>`).join('')
: '';
const secHtml = sections.map(s =>
`<h2>${esc(s.h)}</h2>${
String(s.body || '').split(/\n\n+/).map(par => `<p>${esc(par)}</p>`).join('')
}`).join('');
const moreHtml = (Array.isArray(others) && others.length) ? `
<nav class="m-post__nav" aria-label="More articles">
<h3>More from ${esc(cfg.siteName)}</h3>
<ul class="m-index__list">${others.slice(0, 3).map(o => `
<li><a href="/articles/${esc(o.slug)}"><span class="m-index__t">${esc(o.title)}</span>${
o.dek ? `<span class="m-index__d">${esc(o.dek)}</span>` : ''
}</a></li>`).join('')}
</ul>
</nav>` : '';
return head(cfg, title, desc, '/articles/' + a.slug)
+ `<style>${monetizeCss()}</style>`
+ jsonld(cfg) + articleJsonLd(cfg, a) + monetizeChrome(cfg) + `
<div class="wrap"><article class="m-post">
<div class="crumb"><a href="/articles">Articles</a> › ${esc(a.title)}</div>
<h1>${esc(a.title)}</h1>
${a.dek ? `<p class="dek">${esc(a.dek)}</p>` : ''}
<div class="byline">${esc(cfg.siteName)}${a.readMins ? ` · ${esc(String(a.readMins))} min read` : ''}</div>
${bodyHtml ? `<div class="m-post__body">${bodyHtml}</div>` : ''}
${secHtml}
${moreHtml}
</article></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* Monetize-mode home: nav + big hero + genuine topical article + guide +
* Privacy/Contact footer. Content comes from cfg.content (per-site, real copy):
* cfg.content = { lede, sections:[{h,body}], guide:[{k,v}] }
* Falls back to niche-derived copy so a site without a content block still
* renders a legitimate (if generic) page. */
function monetizeHome(cfg, patterns = []) {
const c = cfg.content || {};
const title = `${cfg.siteName} — ${clean(cfg.tagline)}`;
const desc = clean(cfg.metaDesc || cfg.tagline);
const niche = clean(cfg.nicheLabel || 'design');
const lede = c.lede || `${clean(cfg.siteName)} is a guide to ${niche} — what to look for, how to choose, and the ideas worth borrowing.`;
const sections = Array.isArray(c.sections) && c.sections.length ? c.sections : [
{ h: `About ${clean(cfg.siteName)}`, body: clean(cfg.aboutCopy || cfg.tagline) },
];
const guide = Array.isArray(c.guide) ? c.guide : [];
const banner = forSaleBanner(cfg);
// Proxy the hero bg through /img too (raw cfg.heroImage leaks the vendor in its
// filename; server.js registers cfg.heroImage for monetize sites) (TK-11322).
const heroImg = cfg.heroImage ? (proxyImg(cfg.heroImage) || cfg.heroImage) : '';
// NOTE: authored content.* copy is rendered esc-only (NOT clean()) — these are
// for-sale generic domains that legitimately contain "wallpaper"; the DW
// wallpaper→wallcovering swap only applies to DW-branded funnel copy.
// Collapsible panels — native <details>, content server-rendered in the DOM
// (SEO-safe). First panel open by default so there is visible content at rest.
const secHtml = sections.map((s, i) =>
`<details class="m-panel"${i === 0 ? ' open' : ''}><summary>${esc(s.h)}</summary>
<div class="m-panel__body">${
String(s.body || '').split(/\n\n+/).map(par => `<p>${esc(par)}</p>`).join('')
}</div></details>`).join('');
const guideHtml = guide.length ? `
<details class="m-panel"><summary>${esc(c.guideTitle || 'A quick guide')}</summary>
<div class="m-panel__body">
<ul>${guide.map(g => `<li><b>${esc(g.k)}</b><span>${esc(g.v)}</span></li>`).join('')}</ul>
</div></details>` : '';
const faq = Array.isArray(c.faq) ? c.faq : [];
const faqHtml = faq.length ? `
<details class="m-panel"><summary>${esc(c.faqTitle || 'Frequently asked')}</summary>
<div class="m-panel__body">
${faq.map(f => `<div class="m-faq"><h3>${esc(f.q)}</h3><p>${esc(f.a)}</p></div>`).join('')}
</div></details>` : '';
// Pattern grid — up to 20 niche-matched patterns (data/pattern-grid.json) as real
// <img> through the /img proxy (vendor-neutral filenames — never raw Shopify CDN,
// which leaks the vendor). Names are already vendor-stripped + leak-checked by the
// generator; a nameless tile renders image-only. Replaces the 1 CSS-bg hero so a
// fresh AdSense account reads far richer (TK-11322).
const grid = (Array.isArray(patterns) ? patterns : [])
.map(t => ({ img: proxyImg(t && t.img), name: (t && t.name) || '' }))
.filter(t => t.img).slice(0, 20);
const galleryHtml = grid.length ? `
<section class="m-gallery" aria-label="${esc(niche)} patterns">
${grid.map((t, i) => `<figure class="m-gallery__it"><img src="${t.img}" alt="${esc(t.name || clean(cfg.nicheLabel || cfg.siteName))}" loading="${i < 4 ? 'eager' : 'lazy'}" decoding="async" width="600" height="750">${t.name ? `<figcaption class="m-gallery__cap">${esc(t.name)}</figcaption>` : ''}</figure>`).join('')}
</section>` : '';
const articles = Array.isArray(c.articles) ? c.articles : [];
const articlesHtml = articles.length ? `
<section class="m-more" aria-labelledby="more-h">
<h2 id="more-h">Read more</h2>
<ul class="m-more__list">${articles.map(a => `
<li><a href="/articles/${esc(a.slug)}"><span class="m-more__t">${esc(a.title)}</span>${
a.dek ? `<span class="m-more__d">${esc(a.dek)}</span>` : ''
}</a></li>`).join('')}
</ul>
</section>` : '';
return head(cfg, title, desc, '/', null, '')
+ `<style>${monetizeCss()}</style>`
+ banner + jsonld(cfg) + faqJsonLd(faq) + monetizeChrome(cfg) + `
<header class="m-hero${heroImg ? ' has-img' : ''}">
${heroImg ? `<div class="m-hero__bg" style="background-image:url('${esc(heroImg)}')"></div>` : ''}
<div class="m-hero__in">
<h1>${esc(cfg.heroHeadline || cfg.siteName)}</h1>
<p>${esc(clean(cfg.tagline))}</p>
</div>
</header>
<div class="wrap">
<article class="m-article">
<p class="lede">${esc(lede)}</p>
${galleryHtml}
<div class="m-panels" id="guide">${secHtml}${guideHtml}${faqHtml}</div>
${articlesHtml}
</article>
</div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* Monetize-mode footer — Privacy/About/Contact links + for-sale line. No DW
* address block (this is not a DW-branded funnel). */
function monetizeFooter(cfg) {
const url = (cfg.brokerUrl || BROKER_URL) + '/?domain=' + encodeURIComponent(cfg.domain);
// During approval (forSalePhase !== 'live') the sale signal is a NEUTRAL, subtle
// footer line ("Domain inquiries") — not the word "for sale" — so it doesn't read
// as a parked domain. Post-approval the prominent banner returns via forSaleBanner.
const saleLine = cfg.forSalePhase === 'live'
? `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer nofollow">This domain is for sale →</a><br>`
: `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer nofollow" style="opacity:.75">Domain inquiries</a><br>`;
// Instagram — only when the site has a VERIFIED account (cfg.instagram, sourced
// from the MCC audited dw-ig-accounts list; never invented). TK-11322.
const ig = (cfg.instagram || '').replace(/^@/, '').trim();
const igLine = ig
? `<a href="https://www.instagram.com/${esc(ig)}/" target="_blank" rel="noopener noreferrer">Instagram → @${esc(ig)}</a><br>`
: '';
return `<footer><div class="wrap">
<div><div class="fb">${esc(cfg.siteName)}</div>
<div style="margin-top:6px">${esc(clean(cfg.tagline))}</div>
${ig ? `<div style="margin-top:8px">${igLine}</div>` : ''}</div>
<div><a href="/">Home</a> · ${(cfg.content && Array.isArray(cfg.content.articles) && cfg.content.articles.length) ? '<a href="/articles">Articles</a> · ' : ''}<a href="/guide">Guide</a> · <a href="/about">About</a> ·
<a href="/privacy">Privacy</a> · <a href="/contact">Contact</a></div>
<div>${saleLine}© ${new Date().getFullYear()} ${esc(cfg.siteName)}</div>
</div></footer>
${cookieNotice(cfg)}`;
}
/* Privacy Policy — required for AdSense approval. Covers cookies, Google's use
* of the DoubleClick/ad cookie, third-party vendors, and opt-out links. */
function privacyPage(cfg) {
const title = `Privacy Policy — ${cfg.siteName}`;
return head(cfg, title, `Privacy policy for ${clean(cfg.siteName)}.`, '/privacy')
+ `<style>${monetizeCss()}</style>`
+ (cfg.monetize || cfg.forSale ? '' : '') + jsonld(cfg) + monetizeChrome(cfg) + `
<div class="wrap"><section class="m-legal">
<h1>Privacy Policy</h1>
<p>Last updated ${new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'})}. This policy explains how ${esc(clean(cfg.siteName))} (“we”, “this site”) handles information when you visit ${esc(cfg.domain)}.</p>
<h2>Information we collect</h2>
<p>This is a content website. We do not ask you to create an account and we do not knowingly collect personal information you do not choose to send us. If you email us, we receive the address and message you send so we can reply.</p>
<h2>Cookies & advertising</h2>
<p>Third-party vendors, including Google, use cookies to serve ads based on your prior visits to this and other websites. Google’s use of advertising cookies enables it and its partners to serve ads to you based on your visit to this site and/or other sites on the internet.</p>
<p>You may opt out of personalized advertising by visiting <a href="https://www.google.com/settings/ads" target="_blank" rel="noopener noreferrer">Google Ads Settings</a>. You can also opt out of a third-party vendor’s use of cookies for personalized advertising at <a href="https://www.aboutads.info/choices" target="_blank" rel="noopener noreferrer">aboutads.info/choices</a>.</p>
<h2>Analytics</h2>
<p>We may use privacy-respecting analytics to understand aggregate traffic (pages viewed, general region, device type). This data is not used to identify you personally.</p>
<h2>Your choices</h2>
<p>Most browsers let you refuse or delete cookies through their settings. Doing so will not prevent you from reading this site.</p>
<h2>Contact</h2>
<p>Questions about this policy? Email <a href="mailto:${esc(cfg.siteEmail)}">${esc(cfg.siteEmail)}</a>.</p>
</section></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* Monetize-mode About — standalone, no DW funnel copy. */
function monetizeAbout(cfg) {
const c = cfg.content || {};
const title = `About — ${cfg.siteName}`;
const body = c.about || clean(cfg.aboutCopy || cfg.tagline);
return head(cfg, title, clean(cfg.metaDesc || cfg.tagline), '/about')
+ `<style>${monetizeCss()}</style>`
+ jsonld(cfg) + monetizeChrome(cfg) + `
<div class="wrap"><section class="m-legal">
<h1>${esc(cfg.siteName)}</h1>
${String(body).split(/\n\n+/).map(p => `<p>${esc(p)}</p>`).join('')}
<p>Questions or ideas? Email <a href="mailto:${esc(cfg.siteEmail)}">${esc(cfg.siteEmail)}</a>.</p>
</section></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* Dismissible cookie-consent notice — required for personalized ads to EEA/UK
* traffic, good practice everywhere. Links to the Privacy Policy. localStorage
* dismiss so it shows once per viewer. Fixed bottom bar, low-key. */
function cookieNotice(cfg) {
if (!cfg.monetize && !cfg.adsense) return '';
return `
<div class="cookie-notice" id="cookieNotice" role="dialog" aria-label="Cookie notice">
<span>We use cookies for ads and analytics. By using this site you agree to our
<a href="/privacy">Privacy Policy</a>.</span>
<button type="button" id="cookieOk">Got it</button>
</div>
<script>
(function(){
var k='${cfg.slug}_cookie_ok',n=document.getElementById('cookieNotice');
if(!n)return;
try{if(localStorage.getItem(k)==='1'){n.style.display='none';return;}}catch(e){}
var b=document.getElementById('cookieOk');
b&&b.addEventListener('click',function(){n.style.display='none';try{localStorage.setItem(k,'1');}catch(e){}});
})();
</script>`;
}
/* FAQPage JSON-LD — makes the FAQ eligible for rich results. */
function faqJsonLd(faq) {
if (!Array.isArray(faq) || !faq.length) return '';
const ld = {
'@context': 'https://schema.org', '@type': 'FAQPage',
mainEntity: faq.map(f => ({
'@type': 'Question', name: String(f.q || ''),
acceptedAnswer: { '@type': 'Answer', text: String(f.a || '') }
}))
};
return `<script type="application/ld+json">${JSON.stringify(ld).replace(/<\/script/gi, '<\\/script')}</script>`;
}
/* Per-site monogram favicon (data-URI SVG) — first letter on the theme accent.
* Applies to every page via head(); no server route or asset file needed. */
function faviconTag(cfg) {
const letter = esc((cfg.siteName || '?').trim().charAt(0).toUpperCase() || 'W');
const accent = (cfg.theme && cfg.theme.accent) || '#7a2230';
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='12' fill='${accent}'/><text x='32' y='44' font-family='Georgia,serif' font-size='38' fill='%23fff' text-anchor='middle'>${letter}</text></svg>`;
return `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(svg).replace(/'/g, '%27')}">`;
}
/* Second content page (/guide) — a real, distinct topical page so the site is
* multi-page with substantive content (raises AdSense-approval odds). Content
* from cfg.content.guidePage { lede, sections:[{h,body}] }; falls back to the
* home guide list if no dedicated guide article was authored. */
function guidePage(cfg) {
const c = cfg.content || {};
const g = c.guidePage || null;
const title = `Guide — ${cfg.siteName}`;
const desc = clean(`A practical guide from ${cfg.siteName} — ${cfg.tagline || ''}`);
const lede = (g && g.lede) || c.lede || `A practical guide to ${clean(cfg.nicheLabel || 'the essentials')}.`;
const sections = (g && Array.isArray(g.sections) && g.sections.length) ? g.sections
: (Array.isArray(c.sections) ? c.sections : []);
const secHtml = sections.map((s, i) =>
`<details class="m-panel"${i === 0 ? ' open' : ''}><summary>${esc(s.h)}</summary>
<div class="m-panel__body">${String(s.body || '').split(/\n\n+/).map(p => `<p>${esc(p)}</p>`).join('')}</div>
</details>`).join('');
return head(cfg, title, desc, '/guide')
+ `<style>${monetizeCss()}</style>`
+ jsonld(cfg) + monetizeChrome(cfg) + `
<div class="wrap"><article class="m-article" style="padding-top:52px">
<h1 style="font-family:var(--serif);font-size:clamp(30px,4vw,46px);margin-bottom:14px">Guide</h1>
<p class="lede">${esc(lede)}</p>
<div class="m-panels">${secHtml}</div>
</article></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
/* Contact page (/contact) — a clear contact route AdSense expects. */
function contactPage(cfg) {
const title = `Contact — ${cfg.siteName}`;
return head(cfg, title, `Contact ${clean(cfg.siteName)}.`, '/contact')
+ `<style>${monetizeCss()}</style>`
+ jsonld(cfg) + monetizeChrome(cfg) + `
<div class="wrap"><section class="m-legal">
<h1>Contact</h1>
<p>Questions, feedback, or a correction to something you read here? We would like to hear from you.</p>
<p style="font-size:19px;margin:18px 0"><a href="mailto:${esc(cfg.siteEmail)}" style="color:var(--accent)">${esc(cfg.siteEmail)}</a></p>
<p>We read every message and reply to genuine inquiries.</p>
</section></div>
${monetizeFooter(cfg)}
${script(cfg)}
</body></html>`;
}
module.exports = { homePage, catalogPage, aboutPage, infoPage, productPage, monetizeHome, monetizeAbout, privacyPage, guidePage, contactPage, articlesIndex, articlePage, clean, esc, productSlug, scrubVendor, imgToken, proxyImg };