[object Object]

← back to Beverlyhillsvideos

auto-save: 2026-08-06T09:20:28 (1 files) — build.mjs

2fc8f35f2198b81bf33c89ace640b92919e17df5 · 2026-08-06 09:20:59 -0700 · Steve Abrams

Files touched

Diff

commit 2fc8f35f2198b81bf33c89ace640b92919e17df5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 09:20:59 2026 -0700

    auto-save: 2026-08-06T09:20:28 (1 files) — build.mjs
---
 build.mjs | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 140 insertions(+), 16 deletions(-)

diff --git a/build.mjs b/build.mjs
index bbc3d20..031aa61 100644
--- a/build.mjs
+++ b/build.mjs
@@ -7,12 +7,26 @@ const BRAND = 'Beverly Hills Videos';
 const DOMAIN = 'beverlyhillsvideos.com';
 const ADSENSE_PUB = 'ca-pub-5278231299883833';
 const ADSENSE = `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE_PUB}" crossorigin="anonymous"></script>`;
-// Paste a display ad-unit slot ID here (AdSense → Ads → By ad unit → Display) to
-// activate the in-content ad units below. Auto Ads (dashboard toggle) needs no slot.
-const ADSENSE_SLOT = '';
-const adSlot = () => ADSENSE_SLOT
-  ? `<div class="ad-wrap"><ins class="adsbygoogle" style="display:block" data-ad-client="${ADSENSE_PUB}" data-ad-slot="${ADSENSE_SLOT}" data-ad-format="auto" data-full-width-responsive="true"></ins><script>(adsbygoogle=window.adsbygoogle||[]).push({});</script></div>`
-  : `<!-- ad: set ADSENSE_SLOT in build.mjs to enable in-content units (Auto Ads works without this) -->`;
+// Manual in-content ad units. These run ALONGSIDE Auto Ads ("Both" mode) — the
+// <head> loader already enables Auto Ads once you toggle it on in the AdSense dash.
+// To activate these MANUAL units: create a Display ad unit in AdSense
+// (Ads → By ad unit → Display), copy its numeric data-ad-slot, and paste it below.
+// Paste ONE id into `default` and every slot goes live at once; give a slot its own
+// id later to split reporting. Until an id is set, each slot renders a visible
+// "Advertisement" placeholder so the ad SLOTS are on the page and you can see where
+// they'll serve.
+const ADSENSE_SLOTS = {
+  default: '',       // ← paste one Display ad-unit slot id here to activate all units
+  home: '',
+  article_top: '',
+  in_article: '',
+  article_foot: '',
+};
+const adSlot = (name = 'default') => {
+  const slot = ADSENSE_SLOTS[name] || ADSENSE_SLOTS.default;
+  if (slot) return `<div class="ad-wrap"><span class="ad-label">Advertisement</span><ins class="adsbygoogle" style="display:block" data-ad-client="${ADSENSE_PUB}" data-ad-slot="${slot}" data-ad-format="auto" data-full-width-responsive="true"></ins><script>(adsbygoogle=window.adsbygoogle||[]).push({});</script></div>`;
+  return `<div class="ad-wrap ad-ph"><span class="ad-label">Advertisement</span><span class="ad-ph-note">AdSense slot “${name}” — set ADSENSE_SLOTS.${name} (or .default) in build.mjs</span></div>`;
+};
 
 const POSTS = [
   { slug: 'beverly-hills-real-estate', file: 'real-estate', kicker: 'REAL ESTATE',
@@ -127,6 +141,66 @@ const videoEmbed = (v) => {
   return '';
 };
 
+// ---- original self-hosted restaurant films (HeyGen presenter videos) ----
+// Loaded here (before the home hero + article interleaving both use it).
+// Manifest is written by filmgen/heygen_batch.mjs as each film completes.
+let FILMS = [];
+try { FILMS = JSON.parse(readFileSync('data/films.json', 'utf8')); } catch {}
+
+// In-article video, inserted after every paragraph. Handles a self-hosted film
+// object ({src,poster,name,slug,blurb}) OR a YouTube item ({id,title,channel}).
+const inlineFilm = (f) => `
+      <figure class="p-vid p-film">
+        <video controls preload="none" playsinline poster="${f.poster}"><source src="${f.src}" type="video/mp4"></video>
+        <figcaption><a href="/restaurants/${f.slug}.html">${f.name}</a><span>${(f.blurb || '').replace(/"/g, '&quot;')}</span></figcaption>
+      </figure>`;
+const inlineYT = (v) => `
+      <figure class="p-vid p-yt">
+        <div class="frame"><iframe src="https://www.youtube.com/embed/${v.id}" title="${v.title}" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>
+        <figcaption>${v.title}<span>${v.channel}</span></figcaption>
+      </figure>`;
+const inlineVideo = (item) => item && item.src ? inlineFilm(item) : inlineYT(item);
+
+// Every YouTube item flattened, for fallback interleaving on posts with no films.
+const ALL_YT = VIDEO_CATEGORIES.flatMap(c => c.videos).filter(v => (v.platform || 'youtube') === 'youtube');
+// Per-post video pool: topical items first (by content file), then the whole film
+// library + all YouTube, deduped, so even a 14-paragraph article never runs dry.
+const POST_VIDEO_HINTS = {
+  dining:        ['film'],
+  nightlife:     ['film', 'Hotels & Spas'],
+  'real-estate': ['Luxury Real Estate'],
+  'rodeo-drive': ['Shopping & Events'],
+  'things-to-do':['Arts & Galleries', 'Shopping & Events'],
+  wellness:      ['Hotels & Spas'],
+  fitness:       ['Hotels & Spas'],
+  doctors:       ['Hotels & Spas'],
+  lawyers:       ['Arts & Galleries'],
+};
+function postVideoPool(file) {
+  const hints = POST_VIDEO_HINTS[file] || [];
+  const pool = [], seen = new Set();
+  const add = (it) => { const k = it && (it.src || it.id); if (k && !seen.has(k)) { seen.add(k); pool.push(it); } };
+  for (const h of hints) {
+    if (h === 'film') FILMS.forEach(add);
+    else VIDEO_CATEGORIES.filter(c => c.name === h).forEach(c => c.videos.forEach(v => { if ((v.platform || 'youtube') === 'youtube') add(v); }));
+  }
+  FILMS.forEach(add);   // fill remainder so long posts never repeat within the article
+  ALL_YT.forEach(add);
+  return pool;
+}
+// Insert a video after every </p>, cycling the pool; drop a manual ad unit every
+// 5th paragraph so the article is monetized without an ad after literally each one.
+function interleaveVideos(frag, file) {
+  const pool = postVideoPool(file);
+  if (!pool.length) return frag;
+  let vi = 0, pcount = 0;
+  return frag.replace(/<\/p>/g, (m) => {
+    let out = m + inlineVideo(pool[vi++ % pool.length]);
+    if (++pcount % 5 === 0) out += adSlot('in_article');
+    return out;
+  });
+}
+
 const nav = `
   <header class="site-head">
     <div class="wrap">
@@ -194,7 +268,57 @@ ${footer}
 </html>`;
 
 // ---- home ----
-const homeBody = `
+// Big rotating film montage on load: two stacked <video> layers crossfade through
+// a handful of the self-hosted restaurant films (muted autoplay so browsers allow
+// it; a sound toggle unmutes). Falls back to the static image hero if no films.
+const HERO_FILMS = FILMS.slice(0, 6);
+const homeHero = HERO_FILMS.length ? `
+  <section class="hero-montage" id="hero">
+    <div class="hm-stage">
+      <video class="hm-vid on" muted autoplay loop playsinline preload="auto" poster="${HERO_FILMS[0].poster}"><source src="${HERO_FILMS[0].src}" type="video/mp4"></video>
+      <video class="hm-vid" muted loop playsinline preload="none"></video>
+    </div>
+    <div class="hm-veil"></div>
+    <div class="wrap hm-copy">
+      <p class="eyebrow">The 90210 Edit</p>
+      <h1>Beverly Hills, beyond the postcard.</h1>
+      <p class="sub">An independent guide to the city’s real estate, shopping, dining, culture, and the quiet corners
+        locals love — stories worth slowing down for.</p>
+      <div class="hm-actions">
+        <a href="#guides" class="scrollcue">Explore</a>
+        <button class="hm-sound" type="button" aria-label="Toggle sound" onclick="hmSound(this)">🔊 Sound</button>
+      </div>
+    </div>
+    <div class="hm-caption" id="hmCap"></div>
+  </section>
+  <script>
+  (function(){
+    var films = ${JSON.stringify(HERO_FILMS.map(f => ({ src: f.src, poster: f.poster, name: f.name })))};
+    var vids = document.querySelectorAll('.hero-montage .hm-vid');
+    var cap = document.getElementById('hmCap');
+    if(!films.length || vids.length < 2) return;
+    var idx = 0, active = 0, wantSound = false;
+    function show(n){ if(cap) cap.textContent = n ? '▶ ' + n : ''; }
+    show(films[0].name);
+    vids[0].play().catch(function(){});
+    function advance(){
+      var next = (active + 1) % 2, nf = films[(idx + 1) % films.length], nv = vids[next];
+      nv.setAttribute('poster', nf.poster);
+      nv.innerHTML = '<source src="' + nf.src + '" type="video/mp4">';
+      nv.muted = !wantSound; nv.load();
+      nv.play().catch(function(){});
+      nv.classList.add('on'); vids[active].classList.remove('on');
+      idx = (idx + 1) % films.length; active = next; show(nf.name);
+    }
+    if(films.length > 1) setInterval(advance, 8000);
+    window.hmSound = function(btn){
+      wantSound = !wantSound;
+      vids[active].muted = !wantSound;
+      if(wantSound) vids[active].play().catch(function(){});
+      btn.textContent = wantSound ? '🔇 Mute' : '🔊 Sound';
+    };
+  })();
+  </script>` : `
   <section class="hero home-hero">
     <div class="wrap">
       <p class="eyebrow">The 90210 Edit</p>
@@ -203,8 +327,10 @@ const homeBody = `
         locals love — stories worth slowing down for.</p>
       <a href="#guides" class="scrollcue">Explore</a>
     </div>
-  </section>
-  <section class="feed">
+  </section>`;
+const homeBody = `
+  ${homeHero}
+  <section class="feed" id="guides">
     <div class="wrap">
       <div class="posts">
         ${POSTS.map((p, i) => `
@@ -217,7 +343,7 @@ const homeBody = `
       </div>
     </div>
   </section>
-  <div class="wrap">${adSlot()}</div>
+  <div class="wrap">${adSlot('home')}</div>
   <section class="home-vids">
     <div class="wrap">
       <div class="vch-head"><h2>From the city, on video</h2><a class="vch-link" href="/videos.html">All videos →</a></div>
@@ -268,8 +394,9 @@ for (const p of POSTS) {
     <div class="wrap">
       <p class="crumb"><a href="/">Home</a> · ${p.kicker}</p>
       <h1>${p.title}</h1>
-      ${frag}
-      ${adSlot()}
+      ${adSlot('article_top')}
+      ${interleaveVideos(frag, p.file)}
+      ${adSlot('article_foot')}
       <div class="more">
         <h3>More from Beverly Hills Videos</h3>
         <ul>${others.map(o => `<li><a href="/posts/${o.slug}.html">${o.title}</a></li>`).join('')}</ul>
@@ -281,10 +408,7 @@ for (const p of POSTS) {
   }));
 }
 
-// ---- original restaurant films (HeyGen-produced presenter videos, self-hosted) ----
-// Manifest is written by filmgen/heygen_batch.mjs as each film completes.
-let FILMS = [];
-try { FILMS = JSON.parse(readFileSync('data/films.json', 'utf8')); } catch {}
+// (FILMS is loaded earlier — the home hero + article interleaving both need it.)
 
 // ---- videos page (real, verified BH local-business embeds) ----
 const videosBody = `

← 6cdcaff chore: v1.1.0 (session close — doctors/attorneys map, 44-fil  ·  back to Beverlyhillsvideos  ·  home: rotating film-montage hero; posts: video after every p e0b5b52 →