← back to Beverlyhillsvideos

build.mjs

617 lines

// Build beverlyhillsvideos.com — a Beverly Hills news/blog. Wraps each
// content/*.html fragment in a shared editorial template + generates the
// homepage, article pages, and an AdSense-compliant privacy policy.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';

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>`;
// 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: '8227434793',   // "BHV Content" Display unit (responsive), created 2026-08-06
  home: '',
  article_top: '',
  in_article: '',
  article_foot: '',
};
const adSlot = (name = 'default') => {
  // AdSense review remediation: rely on Auto ads' content-aware placement.
  // Repeated manual units made editorial pages look ad-heavy/low-value.
  return '';
};

const POSTS = [
  { slug: 'beverly-hills-real-estate', file: 'real-estate', kicker: 'REAL ESTATE',
    title: 'Why Beverly Hills Real Estate Sits at the Summit of Global Luxury',
    blurb: 'The neighborhoods, the buyers, and the scarcity that keeps 90210 at the top of the luxury market.' },
  { slug: 'rodeo-drive-guide', file: 'rodeo-drive', kicker: 'SHOPPING',
    title: 'Rodeo Drive: A Guide to the World’s Most Famous Shopping Street',
    blurb: 'Three iconic blocks, Two Rodeo’s cobblestones, and why it’s free to enjoy the world’s great retail theater.' },
  { slug: 'things-to-do-beverly-hills', file: 'things-to-do', kicker: 'CITY GUIDE',
    title: 'Things to Do in Beverly Hills: A Local’s Guide',
    blurb: 'Gardens, Greystone, canyon trails, and a century of architecture — a full day without a single price tag.' },
  { slug: 'dining-in-beverly-hills', file: 'dining', kicker: 'DINING',
    title: 'Dining in Beverly Hills: From Power Lunches to Iconic Institutions',
    blurb: 'The power lunch, the hotel dining rooms, the cuisines — and how to taste the city on any budget.' },
  { slug: 'beverly-hills-nightlife', file: 'nightlife', kicker: 'NIGHTLIFE',
    title: 'Beverly Hills After Dark: Bars, Lounges & the Evening Scene',
    blurb: 'Legendary hotel bars, the power-dinner-that-becomes-the-evening, and where locals go when the night gets loud.' },
  { slug: 'beverly-hills-wellness-spas', file: 'wellness', kicker: 'WELLNESS',
    title: 'Wellness & Spas in Beverly Hills: The Capital of Self-Care',
    blurb: 'Five-star hotel spas, the medspa capital, holistic recovery — and why the city itself is part of the cure.' },
  { slug: 'staying-fit-beverly-hills', file: 'fitness', kicker: 'FITNESS',
    title: 'Staying Fit in Beverly Hills: Studios, Trainers & the Outdoors',
    blurb: 'The boutique-studio scene, the private-training tradition, luxury gyms — and the free canyon trails at the city’s edge.' },
  { slug: 'beverly-hills-cosmetic-doctors', file: 'doctors', kicker: 'MEDICINE', directory: 'doctor',
    title: 'The Cosmetic-Medicine Capital: Beverly Hills Doctors, Dermatologists & Medspas',
    blurb: 'Why the world comes to Bedford Drive — the surgeons, dermatologists, and aesthetic clinics that made 90210 the capital of cosmetic medicine, with a full directory by specialty.' },
  { slug: 'beverly-hills-attorneys', file: 'lawyers', kicker: 'LAW', directory: 'lawyer',
    title: 'The Attorneys of Beverly Hills: Inside the City’s Legal Power Center',
    blurb: 'Entertainment deals, high-profile divorces, and boardroom battles — inside the Wilshire Boulevard firms, with a full directory by practice area.' },
];

// Real Beverly Hills local-business/institution videos, organized by category.
// Every ID oEmbed-verified (HTTP 200 + embeddable) 2026-08-05; non-embeddable /
// off-topic candidates (fake hotel handles, out-of-town listings) were dropped.
const LOVE = 'Love Beverly Hills', CITY = 'City of Beverly Hills';
const CH_URL = {
  'Love Beverly Hills': 'https://www.youtube.com/@lovebeverlyhills',
  'City of Beverly Hills': 'https://www.youtube.com/@cityofbeverlyhills',
  'The Beverly Hills Estates': 'https://www.youtube.com/@thebeverlyhillsestates',
  'Christophe Choo': 'https://www.youtube.com/@christophechoo',
  'The Maybourne Beverly Hills': 'https://www.youtube.com/@TheMaybourneBeverlyHills',
  'SIXTY Hotels': 'https://www.youtube.com/@SixtyHotels',
  'Gagosian': 'https://www.youtube.com/@gagosian',
};
const VIDEO_CATEGORIES = [
  { name: 'Dining & Restaurants', blurb: 'Inside the city’s celebrated kitchens — MICHELIN dining, hotel chefs, and the “Farm to Table” series at Beverly Hills restaurants.', videos: [
    { id: 'shtyIgVvTlg', title: 'MICHELIN Dining in Beverly Hills', channel: LOVE },
    { id: 'Vd9UJFhD58o', title: 'Farm to Table · Viviane', channel: CITY },
    { id: 'I-DTjVxlC-k', title: 'Farm to Table · Il Pastaio', channel: CITY },
    { id: 'cEPQwTXZ2_c', title: 'Farm to Table with Freddy Vargas', channel: CITY },
    { id: 'b0_wkRl2Psg', title: 'Executive Chef Damon Gordon · The Maybourne Beverly Hills', channel: LOVE },
    { id: 'f_b7YbQ4yeg', title: 'Executive Sous Chef Matthew Sprister · The Beverly Hilton', channel: LOVE },
  ] },
  { name: 'Hotels & Spas', blurb: 'The five-star hotels that define Beverly Hills hospitality.', videos: [
    { id: '2JEIBvlX_Ws', title: 'The Maybourne Beverly Hills · Forbes Travel Guide Five-Star', channel: 'The Maybourne Beverly Hills' },
    { id: 'vD0oHYkcA_0', title: 'General Manager Frédéric Zemmour · L’Ermitage Beverly Hills', channel: LOVE },
    { id: 'C3f1TffM4Fk', title: 'SIXTY Beverly Hills in Seconds', channel: 'SIXTY Hotels' },
  ] },
  { name: 'Arts & Galleries', blurb: 'The city’s cultural life — from the Music in the Mansion series at Greystone to Gagosian, whose landmark gallery anchors N. Camden Drive.', videos: [
    { id: 'kaw1nwnp07c', title: 'Hidden Art of Beverly Hills · The Maybourne', channel: LOVE },
    { id: 'lCCHzqzQ0KU', title: 'Music in the Mansion · Cristina Montes Mateo', channel: CITY },
    { id: 'BXOSYwQXDyo', title: 'Es Devlin & Vladimir Yavachev on Christo', channel: 'Gagosian' },
    { id: 'fe_QbTkLQ6U', title: 'Giuseppe Penone · “The Reflection of Bronze”', channel: 'Gagosian' },
  ] },
  { name: 'Shopping & Events', blurb: 'Rodeo Drive and the moments that fill the Beverly Hills calendar.', videos: [
    { id: 'jdcUKhEOqH4', title: 'Rodeo Drive Concours d’Elegance', channel: LOVE },
    { id: 'JfhK8qlZnKI', title: 'K-Pop Flash Mob on Rodeo Drive', channel: CITY },
    { id: 'OHLhwIOnr4A', title: 'This Month’s Events in Beverly Hills', channel: LOVE },
    { id: 'N90exTV8eNs', title: 'Festival Beverly Hills', channel: LOVE },
  ] },
  { name: 'On Social', blurb: 'Short-form Beverly Hills straight from the city’s official social channels.', videos: [
    { platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/reel/C-lSh-cy5yV/', title: 'Richard Orlinski sculptures on Rodeo Drive', channel: 'Love Beverly Hills' },
    { platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/p/Ct9uztRuRFP/', title: 'The Maybourne earns AAA Five Diamond', channel: 'Love Beverly Hills' },
    { platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/p/CQJu-IstVGF/', title: '“Mr. Rodeo Drive” — the Fred Hayman tribute', channel: 'Love Beverly Hills' },
  ] },
  { name: 'Luxury Real Estate', blurb: 'Inside the landmark estates from the firms and agents who sell them.', videos: [
    { id: 'UgZ4P5P89-s', title: '77 Beverly Park Lane · $68,000,000', channel: 'The Beverly Hills Estates' },
    { id: 'JuP1zQJzaWc', title: '2571 Wallingford Drive · $49,995,000', channel: 'The Beverly Hills Estates' },
    { id: 'bkgggxOqIz8', title: '1122 La Altura Rd · $17,995,000', channel: 'The Beverly Hills Estates' },
    { id: '33qhhmq9-1Y', title: 'Top-Floor Primary Suite · $5.5M', channel: 'Christophe Choo' },
    { id: 'QsuX5VkIJEc', title: 'Modern Upgrades Meet Scandinavian Warmth', channel: 'Christophe Choo' },
  ] },
];

// Platform-aware embed. Every source verified (YouTube/Vimeo via oEmbed 200;
// Instagram permalinks HTTP-200 on @lovebevhills). YouTube/Vimeo are clean
// iframes; Instagram/TikTok use their official blockquote + embed.js.
const videoEmbed = (v) => {
  const p = v.platform || 'youtube';
  if (p === 'youtube') return `
        <figure class="vid">
          <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 class="vch">${v.channel}</span></figcaption>
        </figure>`;
  if (p === 'vimeo') return `
        <figure class="vid">
          <div class="frame"><iframe src="https://player.vimeo.com/video/${v.id}" title="${v.title}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>
          <figcaption>${v.title}<span class="vch">${v.channel}</span></figcaption>
        </figure>`;
  if (p === 'instagram') return `
        <figure class="vid vid-social">
          <blockquote class="instagram-media" data-instgrm-permalink="${v.url}" data-instgrm-version="14" style="width:100%;max-width:100%;min-width:260px;margin:0;border:1px solid var(--line);border-radius:6px"></blockquote>
          <figcaption>${v.title}<span class="vch">${v.channel} · Instagram</span></figcaption>
        </figure>`;
  if (p === 'tiktok') return `
        <figure class="vid vid-social">
          <blockquote class="tiktok-embed" cite="${v.url}" data-video-id="${v.id}" style="max-width:100%;min-width:260px;margin:0"><section></section></blockquote>
          <figcaption>${v.title}<span class="vch">${v.channel} · TikTok</span></figcaption>
        </figure>`;
  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">
      <button class="hamb" aria-label="Open menu" aria-expanded="false" onclick="toggleNav(this)"><span></span><span></span><span></span></button>
      <a class="brand" href="/">BEVERLY HILLS<span>VIDEOS</span></a>
    </div>
  </header>
  <div class="drawer" id="nav" aria-hidden="true">
    <button class="drawer-x" aria-label="Close menu" onclick="toggleNav()">&times;</button>
    <nav>
      <a href="/">Home</a>
      <a href="/videos.html">Videos</a>
      <a href="/restaurants.html">Restaurants</a>
      <a href="/posts/beverly-hills-real-estate.html">Real Estate</a>
      <a href="/posts/rodeo-drive-guide.html">Rodeo Drive</a>
      <a href="/posts/things-to-do-beverly-hills.html">City Guide</a>
      <a href="/posts/dining-in-beverly-hills.html">Dining</a>
      <a href="/posts/beverly-hills-nightlife.html">Nightlife</a>
      <a href="/map.html">Map</a>
    </nav>
  </div>
  <script>
  function toggleNav(btn){
    var d=document.getElementById('nav'), open=d.classList.toggle('open');
    document.body.style.overflow=open?'hidden':'';
    d.setAttribute('aria-hidden', open?'false':'true');
    var h=document.querySelector('.hamb'); if(h) h.setAttribute('aria-expanded', open?'true':'false');
    if(open) h&&h.classList.add('x'); else h&&h.classList.remove('x');
  }
  </script>`;

const footer = `
  <footer class="site-foot">
    <div class="wrap">
      <p>&copy; ${new Date().getFullYear()} ${BRAND} &middot; ${DOMAIN}</p>
      <p class="fine">An independent editorial guide to Beverly Hills — its neighborhoods, culture, shopping, and dining.
        Not affiliated with the City of Beverly Hills. <a href="/privacy.html">Privacy</a></p>
      <p class="fine">Hero image: “Via Rodeo, Beverly Hills” by <a href="https://commons.wikimedia.org/wiki/File:Via_Rodeo,_Beverly_Hills.jpg" target="_blank" rel="noopener noreferrer">Kjetil Ree</a>, <a href="https://creativecommons.org/licenses/by-sa/2.5/" target="_blank" rel="noopener noreferrer">CC BY-SA 2.5</a>.</p>
    </div>
  </footer>`;

const page = ({ title, desc, canonical, body }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} · ${BRAND}</title>
<meta name="description" content="${desc}">
<link rel="canonical" href="https://${DOMAIN}${canonical}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${desc}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${BRAND}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Jost:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
${ADSENSE}
</head>
<body>
${nav}
${body}
${footer}
</body>
</html>`;

// ---- home ----
// 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>
      <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>
      <a href="#guides" class="scrollcue">Explore</a>
    </div>
  </section>`;
const homeBody = `
  ${homeHero}
  <section class="feed" id="guides">
    <div class="wrap">
      <div class="posts">
        ${POSTS.map((p, i) => `
        <a class="post-card${i === 0 ? ' lead' : ''}" href="/posts/${p.slug}.html">
          <span class="kicker">${p.kicker}</span>
          <h2>${p.title}</h2>
          <p>${p.blurb}</p>
          <span class="read">Read →</span>
        </a>`).join('')}
      </div>
    </div>
  </section>
  <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>
      <p class="vch-tag">Real footage from Beverly Hills businesses and institutions — dining, hotels, the arts, and more.</p>
      <div class="vid-grid">
        ${VIDEO_CATEGORIES.slice(0, 3).map(c => videoEmbed(c.videos[0])).join('')}
      </div>
    </div>
  </section>`;

// ---- build ----
mkdirSync('public/posts', { recursive: true });

writeFileSync('public/index.html', page({
  title: 'Beverly Hills news, real estate, shopping & dining',
  desc: 'An independent editorial guide to Beverly Hills — real estate, Rodeo Drive, city guide, and dining.',
  canonical: '/', body: homeBody,
}));

// ---- specialty directory (doctors / lawyers), rendered from map-locations.json ----
let DIR_LOCS = [];
try { DIR_LOCS = JSON.parse(readFileSync('data/map-locations.json', 'utf8')).locations || []; } catch {}
function renderDirectory(type) {
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  const rows = DIR_LOCS.filter(l => l.type === type);
  const groups = {};
  for (const l of rows) (groups[l.category] ||= []).push(l);
  return `<div class="directory">` + Object.keys(groups).sort().map(cat => `
    <div class="dir-group">
      <h4 class="dir-cat">${esc(cat)} <em>${groups[cat].length}</em></h4>
      ${groups[cat].map(l => {
        const tel = l.phone ? l.phone.replace(/[^+0-9]/g, '') : '';
        return `<div class="dir-item">
          <span class="dir-name">${esc(l.name)}</span>
          <a class="dir-addr" href="https://maps.google.com/?q=${encodeURIComponent(l.name + ' ' + l.address)}" target="_blank" rel="noopener">${esc(l.address)}</a>
          ${l.phone ? `<a class="dir-phone" href="tel:${tel}">${esc(l.phone)}</a>` : ''}
        </div>`;
      }).join('')}
    </div>`).join('') + `</div>`;
}

for (const p of POSTS) {
  let frag = readFileSync(`content/${p.file}.html`, 'utf8');
  if (p.directory) frag = frag.replace('{{DIRECTORY}}', renderDirectory(p.directory));
  const others = POSTS.filter(x => x.slug !== p.slug).slice(0, 3);
  const body = `
  <article class="post">
    <div class="wrap">
      <p class="crumb"><a href="/">Home</a> · ${p.kicker}</p>
      <h1>${p.title}</h1>
      ${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>
      </div>
    </div>
  </article>`;
  writeFileSync(`public/posts/${p.slug}.html`, page({
    title: p.title, desc: p.blurb, canonical: `/posts/${p.slug}.html`, body,
  }));
}

// ---- videos page (real, verified BH local-business embeds) ----
const videosBody = `
  <section class="hero home-hero vids-hero">
    <div class="wrap">
      <p class="eyebrow">Watch</p>
      <h1>Beverly Hills, on video.</h1>
      <p class="sub">Real videos from the city’s own businesses and institutions — the visitors bureau, its
        landmark real-estate firms, and the people who know 90210 best.</p>
    </div>
  </section>
  ${FILMS.length ? `
  <section class="films"><div class="wrap">
    <div class="vch-head"><h2>Restaurant Films</h2></div>
    <p class="vch-tag">Original short films on the great Beverly Hills restaurants, produced for ${BRAND}.</p>
    <div class="film-grid">
      ${FILMS.map(f => `
      <figure class="film">
        <video controls preload="metadata" playsinline poster="${f.poster}">
          <source src="${f.src}" type="video/mp4">
        </video>
        <figcaption><strong><a href="/restaurants/${f.slug}.html">${f.name}</a></strong><span>${f.blurb}</span></figcaption>
      </figure>`).join('')}
    </div>
  </div></section>` : ''}
  <div class="wrap">${adSlot()}</div>
  ${VIDEO_CATEGORIES.map(c => `
  <section class="vid-channel">
    <div class="wrap">
      <div class="vch-head"><h2>${c.name}</h2></div>
      <p class="vch-tag">${c.blurb}</p>
      <div class="vid-grid">
        ${c.videos.map(videoEmbed).join('')}
      </div>
    </div>
  </section>`).join('')}
  <section class="vid-note"><div class="wrap">
    <p><strong>Follow Beverly Hills across platforms:</strong>
      <a href="https://www.youtube.com/@lovebeverlyhills" target="_blank" rel="noopener noreferrer">YouTube</a> ·
      <a href="https://www.instagram.com/lovebevhills/" target="_blank" rel="noopener noreferrer">Instagram</a> ·
      <a href="https://www.tiktok.com/@lovebevhills" target="_blank" rel="noopener noreferrer">TikTok</a></p>
    <p><strong>Featured channels:</strong> ${Object.entries(CH_URL).map(([n, u]) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${n}</a>`).join(' · ')}</p>
    <p>Videos are embedded from their creators’ official channels (YouTube, Vimeo, Instagram, TikTok) and remain the
    property of their respective owners. ${BRAND} is an independent editorial guide and is not affiliated with the
    channels featured.</p>
  </div></section>
  <script async src="//www.instagram.com/embed.js"></script>
  <script async src="https://www.tiktok.com/embed.js"></script>`;
writeFileSync('public/videos.html', page({
  title: 'Beverly Hills Videos — real videos from the city’s businesses',
  desc: 'Watch real Beverly Hills videos from the visitors bureau, luxury real-estate firms, and local institutions.',
  canonical: '/videos.html', body: videosBody,
}));

// ---- map page (restaurants + Rodeo Drive stores, geocoded) ----
let MAP_LOCS = [];
try { MAP_LOCS = JSON.parse(readFileSync('data/map-locations.json', 'utf8')).locations || []; } catch {}
// wire each mapped restaurant to its film page (if one exists)
{ const rn = s => (s||'').toLowerCase().replace(/beverly hills|estiatorio|the /g,'').replace(/[^a-z0-9]+/g,'');
  MAP_LOCS.forEach(l => { if (l.type==='restaurant') { const b=rn(l.name); const f=FILMS.find(x=>{const a=rn(x.name);return a&&b&&(a===b||a.includes(b)||b.includes(a));}); if (f) l.film=f.slug; } }); }
const mapBody = `
  <section class="hero home-hero vids-hero">
    <div class="wrap">
      <p class="eyebrow">The Map</p>
      <h1>Where to go in Beverly Hills.</h1>
      <p class="sub">Fine dining, Rodeo Drive boutiques, the city’s celebrated cosmetic doctors, and its high-profile attorneys — all mapped across the Golden Triangle. Tap any pin to call.</p>
    </div>
  </section>
  <section class="mapsec">
    <div class="wrap">
      <div class="maplegend">
        <button class="ml on" data-t="restaurant"><span class="dot d-r"></span> Restaurants <em>${MAP_LOCS.filter(l=>l.type==='restaurant').length}</em></button>
        <button class="ml on" data-t="store"><span class="dot d-s"></span> Stores <em>${MAP_LOCS.filter(l=>l.type==='store').length}</em></button>
        <button class="ml on" data-t="doctor"><span class="dot d-d"></span> Doctors <em>${MAP_LOCS.filter(l=>l.type==='doctor').length}</em></button>
        <button class="ml on" data-t="lawyer"><span class="dot d-l"></span> Attorneys <em>${MAP_LOCS.filter(l=>l.type==='lawyer').length}</em></button>
      </div>
      <div id="map"></div>
    </div>
  </section>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
  <script>
  const LOCS = ${JSON.stringify(MAP_LOCS)};
  (function(){
    const map = L.map('map',{scrollWheelZoom:false}).setView([34.0705,-118.4015],15);
    L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{
      attribution:'&copy; OpenStreetMap &copy; CARTO', subdomains:'abcd', maxZoom:20}).addTo(map);
    const col = {restaurant:'#1f3d30', store:'#b8935a', doctor:'#2f5d7c', lawyer:'#6d2f3d'};
    const layers = {restaurant:L.layerGroup().addTo(map), store:L.layerGroup().addTo(map), doctor:L.layerGroup().addTo(map), lawyer:L.layerGroup().addTo(map)};
    const bounds = [];
    LOCS.forEach(l=>{
      bounds.push([l.lat,l.lng]);
      const m = L.circleMarker([l.lat,l.lng],{radius:7,color:'#fff',weight:2,fillColor:col[l.type]||'#333',fillOpacity:1});
      const tel = l.phone ? l.phone.replace(/[^+0-9]/g,'') : '';
      m.bindPopup('<div class="pop"><strong>'+l.name+'</strong>'
        +'<span class="pcat">'+l.category+'</span>'
        +'<a class="paddr" href="https://maps.google.com/?q='+encodeURIComponent(l.name+' '+l.address)+'" target="_blank" rel="noopener">'+l.address+'</a>'
        +(l.phone ? '<a class="pcall" href="tel:'+tel+'">Call '+l.phone+'</a>' : '')
        +(l.menu_url ? '<a class="pmenu" href="'+l.menu_url+'" target="_blank" rel="noopener">View current menu →</a>' : '')
        +(l.film ? '<a class="pfilm" href="/restaurants/'+l.film+'.html">▶ Watch the film</a>' : '')
        +'</div>');
      m.addTo(layers[l.type]||layers.restaurant);
    });
    if(bounds.length) map.fitBounds(bounds,{padding:[40,40]});
    map.on('click',()=>map.scrollWheelZoom.enable());
    document.querySelectorAll('.ml').forEach(b=>b.addEventListener('click',()=>{
      const t=b.dataset.t, on=b.classList.toggle('on');
      if(on) map.addLayer(layers[t]); else map.removeLayer(layers[t]);
    }));
  })();
  </script>`;
writeFileSync('public/map.html', page({
  title: 'Map — Beverly Hills dining, shopping, doctors & attorneys',
  desc: 'An interactive map of Beverly Hills fine dining, Rodeo Drive boutiques, cosmetic doctors, and high-profile attorneys across the Golden Triangle — with click-to-call.',
  canonical: '/map.html', body: mapBody,
}));

// ---- per-restaurant film pages (SEO surface: film + cuisine + contact + menu) ----
mkdirSync('public/restaurants', { recursive: true });
let R_CUISINE = {}, R_SCRIPTS = {};
try { (JSON.parse(readFileSync('data/restaurants.json','utf8')).restaurants||[]).forEach(r => R_CUISINE[r.name]=r.cuisine); } catch {}
Object.assign(R_CUISINE, { "Marea":"Italian Seafood","Ocean Prime":"Steakhouse","Azur Beverly Hills":"French","Bad Roman":"Italian-American","88 Club":"Chinese","The Rooftop by JG":"New American","The Hideaway":"Steakhouse","Fogo de Chao":"Brazilian","The Terrace at The Maybourne":"Mediterranean","Miura":"Japanese Sushi","South Beverly Grill":"American","Piccolo Paradiso":"Italian","Kazan":"Japanese Ramen","Xi'an":"Chinese" });
try { R_SCRIPTS = JSON.parse(readFileSync('data/film-scripts.json','utf8')); } catch {}
const rnorm = s => (s||'').toLowerCase().replace(/beverly hills|estiatorio|the /g,'').replace(/[^a-z0-9]+/g,'').trim();
const findBy = (obj, name) => { const n = rnorm(name); for (const k of Object.keys(obj)) { const kn = rnorm(k); if (kn && (kn===n || kn.includes(n) || n.includes(kn))) return obj[k]; } return null; };
const contactFor = name => MAP_LOCS.filter(l=>l.type==='restaurant').find(l => { const a=rnorm(l.name), b=rnorm(name); return a===b || a.includes(b) || b.includes(a); });
const restFilms = FILMS.map(f => {
  const c = contactFor(f.name);
  return { ...f,
    cuisine: R_CUISINE[f.name] || findBy(R_CUISINE, f.name) || (c && c.category) || 'Beverly Hills',
    narration: R_SCRIPTS[f.name] || findBy(R_SCRIPTS, f.name) || f.blurb,
    address: c && c.address, phone: c && c.phone, menu_url: c && c.menu_url };
});
for (const f of restFilms) {
  const tel = f.phone ? f.phone.replace(/[^+0-9]/g,'') : '';
  const contact = (f.address || f.phone || f.menu_url) ? `
      <div class="rest-contact">
        ${f.address ? `<a class="paddr" href="https://maps.google.com/?q=${encodeURIComponent(f.name+' '+f.address)}" target="_blank" rel="noopener">${f.address}</a>` : ''}
        ${f.phone ? `<a class="pcall" href="tel:${tel}">Call ${f.phone}</a>` : ''}
        ${f.menu_url ? `<a class="pmenu" href="${f.menu_url}" target="_blank" rel="noopener">View current menu →</a>` : ''}
      </div>` : '';
  const body = `
  <article class="rest"><div class="wrap">
    <p class="crumb"><a href="/">Home</a> · <a href="/restaurants.html">Restaurants</a> · ${f.cuisine}</p>
    <p class="eyebrow">${f.cuisine} · Beverly Hills</p>
    <h1>${f.name.replace(/ Beverly Hills$/,'')}</h1>
    <figure class="film rest-film"><video controls preload="metadata" playsinline poster="${f.poster}"><source src="${f.src}" type="video/mp4"></video></figure>
    <p class="rest-narr">${f.narration}</p>
    ${contact}
    ${adSlot()}
    <p class="rest-more"><a href="/map.html">See it on the map →</a> &nbsp;·&nbsp; <a href="/videos.html">All restaurant films →</a></p>
  </div></article>`;
  writeFileSync(`public/restaurants/${f.slug}.html`, page({
    title: `${f.name.replace(/ Beverly Hills$/,'')} — Beverly Hills ${f.cuisine} | ${BRAND}`,
    desc: f.narration.slice(0,155),
    canonical: `/restaurants/${f.slug}.html`, body,
  }));
}
// index
const restIndex = `
  <section class="hero home-hero vids-hero"><div class="wrap">
    <p class="eyebrow">The Restaurants</p><h1>Every great table in Beverly Hills.</h1>
    <p class="sub">Original short films on ${restFilms.length} of the city's finest restaurants — with menus, maps, and click-to-call.</p>
  </div></section>
  <section class="rest-grid-sec"><div class="wrap"><div class="rest-grid">
    ${restFilms.map(f => `<a class="rest-card" href="/restaurants/${f.slug}.html">
      <div class="rc-thumb"><img src="${f.poster}" alt="${f.name}" loading="lazy"></div>
      <div class="rc-meta"><strong>${f.name.replace(/ Beverly Hills$/,'')}</strong><span>${f.cuisine}</span></div></a>`).join('')}
  </div></div></section>`;
writeFileSync('public/restaurants.html', page({
  title: `Beverly Hills Restaurants — ${restFilms.length} Original Films, Menus & Map | ${BRAND}`,
  desc: `Original short films on ${restFilms.length} of the finest restaurants in Beverly Hills, each with its current menu, address, and click-to-call.`,
  canonical: '/restaurants.html', body: restIndex,
}));

// ---- privacy (AdSense-compliant) ----
const privacyBody = `
  <article class="post">
    <div class="wrap">
      <h1>Privacy Policy</h1>
      <p class="crumb">${BRAND} · ${DOMAIN} · Last updated ${new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'})}</p>
      <p>This policy explains what ${BRAND} collects, how we use it, and your choices.</p>
      <h2>Information we collect</h2>
      <p>Standard server logs (IP address, browser type, referring pages, timestamps) and information stored in
      cookies. We do not sell your personal information.</p>
      <h2>Cookies and advertising</h2>
      <p>We use cookies to operate the site and to display advertising. <strong>Third-party vendors, including
      Google, use cookies to serve ads</strong> 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 ${DOMAIN} and/or
      other sites on the Internet. 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>,
      or opt out of third-party vendors’ use of cookies at
      <a href="https://www.aboutads.info/choices/" target="_blank" rel="noopener noreferrer">aboutads.info</a>
      (or <a href="https://www.youronlinechoices.eu/" target="_blank" rel="noopener noreferrer">youronlinechoices.eu</a>
      in the EEA/UK). Where required by law, we request consent before setting non-essential advertising cookies.
      See <a href="https://policies.google.com/technologies/partner-sites" target="_blank" rel="noopener noreferrer">Google’s Privacy &amp; Terms</a>.</p>
      <h2>Your choices</h2>
      <p>Most browsers let you block or delete cookies through their settings, and you can use the opt-out links
      above to limit personalized advertising.</p>
      <h2>Contact</h2>
      <p>Questions or a data request? Email <a href="mailto:privacy@${DOMAIN}">privacy@${DOMAIN}</a>.</p>
    </div>
  </article>`;
writeFileSync('public/privacy.html', page({
  title: 'Privacy Policy', desc: `How ${BRAND} handles data, cookies, and advertising.`,
  canonical: '/privacy.html', body: privacyBody,
}));

console.log('built: index + ' + POSTS.length + ' posts + privacy');