[object Object]

← 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

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 →