← 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, '"')}</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 →