← back to Interiordesignershowroom
auto-save: 2026-08-03T11:53:46 (3 files) — lib/render.js server.js lib/assetv.js
3b62dda092204105af9b3c2f73cbfb4e09bfc421 · 2026-08-03 11:53:53 -0700 · Steve Abrams
Files touched
A lib/assetv.jsM lib/render.jsM server.js
Diff
commit 3b62dda092204105af9b3c2f73cbfb4e09bfc421
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 3 11:53:53 2026 -0700
auto-save: 2026-08-03T11:53:46 (3 files) — lib/render.js server.js lib/assetv.js
---
lib/assetv.js | 28 ++++++++++++++++++++++++++++
lib/render.js | 15 ++++++++-------
server.js | 11 ++++++-----
3 files changed, 42 insertions(+), 12 deletions(-)
diff --git a/lib/assetv.js b/lib/assetv.js
new file mode 100644
index 0000000..daa7abd
--- /dev/null
+++ b/lib/assetv.js
@@ -0,0 +1,28 @@
+// Asset cache-buster. Computes a short version hash from the css/js file mtimes at
+// boot; v('/css/site.css') -> '/css/site.css?v=<hash>'. When a css/js file changes and
+// the server restarts (i.e. a deploy), the hash changes -> the URL changes -> browsers
+// fetch fresh. This is what lets those assets be cached HARD (immutable 1y) without the
+// post-deploy stale-CSS risk that made cycle 8 keep them at max-age=0.
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+function compute() {
+ try {
+ let sig = '';
+ for (const d of ['css', 'js']) {
+ const dir = path.join(__dirname, '..', 'public', d);
+ for (const fn of fs.readdirSync(dir).sort()) {
+ sig += fn + fs.statSync(path.join(dir, fn)).mtimeMs;
+ }
+ }
+ return crypto.createHash('sha1').update(sig).digest('hex').slice(0, 10);
+ } catch (_) {
+ return 'dev';
+ }
+}
+
+const ASSET_V = compute();
+const v = (url) => `${url}?v=${ASSET_V}`;
+
+module.exports = { ASSET_V, v };
diff --git a/lib/render.js b/lib/render.js
index b7f7a03..ffa2d61 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -2,7 +2,8 @@
// affiliate sites live or die on SEO, and crawlers reward real HTML with proper
// <title>/meta/canonical/JSON-LD over JS-hydrated shells.
-const { ROOMS } = require('./nav'); // shared room list — powers the header hamburger
+const { ROOMS } = require('./nav');
+const { v } = require('./assetv'); // ?v= cache-buster for local css/js // shared room list — powers the header hamburger
const SITE = {
name: 'Interior Designer\'s Showroom',
@@ -177,7 +178,7 @@ function sceneFigure({ scene_image, title, hotspots = [] }) {
<img class="room-scene" src="${esc(scene_image)}" alt="AI-generated scene for ${esc(title)}">
<div class="hotspot-layer">${spots}</div>
${hotspots.length ? '<span class="scene-hint" aria-hidden="true">Tap a dot to shop a piece</span>' : ''}
- </figure><script src="/js/hotspots.js" defer></script>`;
+ </figure><script src="${v('/js/hotspots.js')}" defer></script>`;
}
// Large thumbnail rail of the room's pieces — stacks to the RIGHT of the scene on
@@ -204,7 +205,7 @@ function roomThumbs(products = []) {
</div>
</div>`;
}).join('');
- return `<div class="room-thumbs" role="list" aria-label="Items in this room">${items}</div><script src="/js/room-thumbs.js" defer></script>`;
+ return `<div class="room-thumbs" role="list" aria-label="Items in this room">${items}</div><script src="${v('/js/room-thumbs.js')}" defer></script>`;
}
// Breadcrumbs from a single [{name, url}] trail — the LAST item is the current page
@@ -280,7 +281,7 @@ function layout({ title, description, canonical, jsonld, image, body, activeNav,
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Serif+Display&display=swap">
-<link rel="stylesheet" href="/css/site.css">
+<link rel="stylesheet" href="${v('/css/site.css')}">
${(Array.isArray(jsonld) ? jsonld : [jsonld]).filter(Boolean).map((j) => `<script type="application/ld+json">${JSON.stringify(j).replace(/</g, '\\u003c')}</script>`).join('')}
</head>
<body>
@@ -307,9 +308,9 @@ ${disclosureBar()}
<p style="margin:0 0 6px">${esc(DISCLOSURE_SHORT)}</p>
<p class="fine">© ${new Date().getFullYear()} ${esc(SITE.name)}. Prices and availability are accurate as of the date/time shown and are subject to change. As an Amazon Associate we earn from qualifying purchases.</p>
</footer>
-<script src="/js/moodboard.js" defer></script>
-<script src="/js/roommenu.js" defer></script>
-<script src="/js/adminbar.js" defer></script>
+<script src="${v('/js/moodboard.js')}" defer></script>
+<script src="${v('/js/roommenu.js')}" defer></script>
+<script src="${v('/js/adminbar.js')}" defer></script>
</body>
</html>`;
}
diff --git a/server.js b/server.js
index 8a6b9a0..5451b01 100644
--- a/server.js
+++ b/server.js
@@ -37,8 +37,8 @@ app.use(express.json({ limit: '256kb' }));
// bare (no ?v=), so any max-age>0 risks serving stale css/js against fresh HTML after a
// deploy. Default (max-age=0 + ETag) makes them cheap 304 revalidations instead. A ?v=
// cache-buster is the future upgrade that would let these be cached hard.
-app.use('/css', express.static(path.join(__dirname, 'public/css')));
-app.use('/js', express.static(path.join(__dirname, 'public/js')));
+app.use('/css', express.static(path.join(__dirname, 'public/css'), { maxAge: '365d', immutable: true }));
+app.use('/js', express.static(path.join(__dirname, 'public/js'), { maxAge: '365d', immutable: true }));
// Images split by staleness profile: /img/rooms scene images are 16-hex CONTENT-HASHED
// (new content = new URL) so they're safely immutable for a year; everything else (guide
// editorial images, favicons, og.png — stable, human-readable names) gets a short 1d TTL
@@ -48,7 +48,8 @@ app.use('/img', express.static(path.join(__dirname, 'public/img'), { maxAge: '1d
const { ROOMS } = require('./lib/nav'); // shared with render.js (nav) — one source of truth
-const { intOrNull, intIds } = require('./lib/ids'); // bigint-safe id guards (shared)
+const { intOrNull, intIds } = require('./lib/ids');
+const { v } = require('./lib/assetv'); // ?v= cache-buster for local css/js // bigint-safe id guards (shared)
// --- tiny markdown-lite for guide bodies (headings, bold, paragraphs) ------
function md(src = '') {
@@ -145,7 +146,7 @@ async function renderCatalog(res, { f, basePath, title, description, canonical,
${catalog.facetRail(f, counts, basePath)}
<div class="catalog-main">${gridControls(products.length)}${grid(products)}</div>
</div>
- </section><script src="/js/grid.js"></script>`;
+ </section><script src="${v('/js/grid.js')}"></script>`;
const jsonld = [productListJsonld(products, canonical), breadcrumbJsonld(crumbs)].filter(Boolean);
// Social share image: the top product's photo so a shared facet/room link shows real
// furniture, not the generic logo card. Falls back to og.png in layout() when empty.
@@ -579,7 +580,7 @@ app.get('/build', (_req, res) => {
<div class="rb-products" id="rbProducts" aria-live="polite" aria-label="Product results"><p class="empty">Loading…</p></div>
</aside>
</div>
- </section><script src="/js/build.js"></script>`;
+ </section><script src="${v('/js/build.js')}"></script>`;
res.send(layout({ title: 'Room Builder', description: 'Compose a shoppable room — pick a style, hue, color, theme & period, drag in pieces, and generate.', canonical: `${SITE.url}/build`, body, activeNav: '/build' }));
});
← 6c5123a seo: honest Offer schema (data-driven itemCondition + condit
·
back to Interiordesignershowroom
·
perf: content-hash asset cache-buster (?v=) + immutable css/ 93f0ffc →