PageSourceSearch

https://cambsnews.co.uk/cnh.js?v=20260928-video1

js cambsnews.co.uk collected 2026-10-02 13:21:27 UTC 166,385 bytes, 3,463 lines download raw bytes

1/* ============================================================
2 * CONFIG — edit these values
3 * ============================================================ */
4var CONFIG = {
5  CN_HOST: 'https://www.cambsnews.co.uk',        // CambsNews WordPress origin
6  DP_HOST: 'https://discoverpeterborough.co.uk', // Discover Peterborough origin
7  YT_API_KEY: 'AIzaSyCTm5rbjf_NUsTuPZxGuo_d89JcKRI303I', // YouTube Data API v3 key — restrict to YouTube API + your domain in Google Cloud Console
8  YT_HANDLE: '@CambsNewsLive',                   // YouTube channel handle (used only if YT_PLAYLIST_ID is empty)
9  YT_CHANNEL_ID: '',                             // optional: paste channel UC… id to skip handle lookup
10  YT_PLAYLIST_ID: 'PLziiiFRX4IFD9m_B-SZYVrYnHYRPHzbC3', // explicit playlist to pull videos from (sorted newest first by publish date)
11  YT_FALLBACK_VIDEOS: [],                        // intentionally empty — if API fails we show a clean channel CTA instead of fake videos
12  CORS_PROXY: 'https://corsproxy.io/?',          // fallback for cross-origin (not needed when uploaded to cambsnews)
13  POSTS_PER_PAGE: 10,
14  HERO_TAG: 'homepage',                          // posts tagged with this slug get pinned to the hero (set '' to disable)
15  PLUS_TAG:   'cn-plus',                         // WP tag slug that triggers the premium "CN+" badge (long reads / club content)
16  PLUS_BRAND: 'CN+',                             // Label shown on the premium badge (e.g. 'CN+', 'Cambs+', 'CN Insider')
17
18  /* --- Ad integration ---
19   * Each slot value can be either:
20   *   (a) a NUMBER — looked up from CONFIG.QUADS_ENDPOINT (needs quads-rest.php mu-plugin), OR
21   *   (b) an OBJECT — {code:'<...AdSense or HTML...>', fallback:'<...HTML shown when ad-blocker detected...>'}
22   * Use (b) to skip the WordPress plugin entirely and paste your ad code right here.
23   */
24  QUADS_ENDPOINT: '/wp-json/quads/v1/ads',
25  QUADS_SLOTS: {
26    /* Each value can be EITHER:
27     *   (a) a NUMBER  → QUADS ad ID looked up via QUADS_ENDPOINT (needs the mu-plugin)
28     *   (b) an OBJECT → { code: '<your ad HTML>', fallback: '<HTML if ad-blocker on>' }
29     *
30     * Defaults below are working house-ad placeholders so the slots are visible.
31     * Replace `code` with AdSense / direct deal HTML when ready, keep `fallback` as a house ad. */
32
33    after_hero: {
34      // 728x90 leaderboard — Support CambsNews (brand red)
35      code: '<a class="ad-house ad-728 ad-red" href="#/page/support-our-work" target="_blank" rel="noopener"><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/><\/svg><\/div><div class="ad-text"><div class="ad-eyebrow">House Ad<\/div><div class="ad-title">Help keep local news free<\/div><div class="ad-tagline">Independent reporting for Peterborough &amp; Cambridgeshire<\/div><\/div><div class="ad-cta">Support →<\/div><\/a>',
36      fallback: '<a class="ad-house ad-728 ad-red ad-fallback" href="#/page/support-our-work" target="_blank" rel="noopener"><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/><\/svg><\/div><div class="ad-text"><div class="ad-eyebrow">House Ad<\/div><div class="ad-title">Help keep local news free<\/div><\/div><div class="ad-cta">Support →<\/div><\/a>'
37    },
38
39    sidebar: {
40      // 300x250 — Discover Peterborough (green, place-pin icon)
41      code: '<a class="ad-house ad-300 ad-green" href="https://discoverpeterborough.co.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/><\/svg><\/div><div class="ad-title">Discover<br>Peterborough<\/div><div class="ad-tagline">More than you imagine — plan your visit<\/div><div class="ad-cta">Visit →<\/div><\/a>',
42      fallback: '<a class="ad-house ad-300 ad-green ad-fallback" href="https://discoverpeterborough.co.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/><\/svg><\/div><div class="ad-title">Discover<br>Peterborough<\/div><div class="ad-tagline">More than you imagine<\/div><div class="ad-cta">Visit →<\/div><\/a>'
43    },
44
45    sidebar2: {
46      // 300x250 — Peterborough Cathedral (brown, church icon)
47      code: '<a class="ad-house ad-300 ad-brown" href="https://peterborough-cathedral.org.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 9V7.13c0-1.18-.86-2.19-2.05-2.34L13 4.3V3c0-.55-.45-1-1-1s-1 .45-1 1v1.3l-3.95.49C5.86 4.94 5 5.95 5 7.13V9H2v12h20V9h-3zm1 10h-5v-3c0-1.66-1.34-3-3-3s-3 1.34-3 3v3H4V11h4V7.16l4-.5 4 .5V11h4v8z"/><\/svg><\/div><div class="ad-title">Peterborough<br>Cathedral<\/div><div class="ad-tagline">900 years of history — visit today<\/div><div class="ad-cta">Plan your visit →<\/div><\/a>',
48      fallback: '<a class="ad-house ad-300 ad-brown ad-fallback" href="https://peterborough-cathedral.org.uk/" target="_blank" rel="noopener"><div class="ad-eyebrow">Partner<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 9V7.13c0-1.18-.86-2.19-2.05-2.34L13 4.3V3c0-.55-.45-1-1-1s-1 .45-1 1v1.3l-3.95.49C5.86 4.94 5 5.95 5 7.13V9H2v12h20V9h-3zm1 10h-5v-3c0-1.66-1.34-3-3-3s-3 1.34-3 3v3H4V11h4V7.16l4-.5 4 .5V11h4v8z"/><\/svg><\/div><div class="ad-title">Peterborough<br>Cathedral<\/div><div class="ad-tagline">900 years of history<\/div><div class="ad-cta">Visit →<\/div><\/a>'
49    },
50
51    sidebar3: {
52      // 300x250 — Got a tip? (navy, envelope icon)
53      code: '<a class="ad-house ad-300 ad-navy" href="mailto:[email protected]"><div class="ad-eyebrow">Editorial · Confidential<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/><\/svg><\/div><div class="ad-title">Got a tip?<\/div><div class="ad-tagline">If you know something we should be reporting, tell us — in confidence.<\/div><div class="ad-cta">[email protected]<\/div><\/a>',
54      fallback: '<a class="ad-house ad-300 ad-navy ad-fallback" href="mailto:[email protected]"><div class="ad-eyebrow">Editorial<\/div><div class="ad-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/><\/svg><\/div><div class="ad-title">Got a tip?<\/div><div class="ad-tagline">Email us in confidence<\/div><div class="ad-cta">[email protected]<\/div><\/a>'
55    }
56
57    // When you want to use real AdSense, replace the above with:
58    // after_hero: {
59    //   code: '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-XXXX" data-ad-slot="1234567890" data-ad-format="auto"><\/ins><script>(adsbygoogle = window.adsbygoogle || []).push({});<\/script>',
60    //   fallback: '<a href="..."><img src="..." style="max-width:100%;height:auto"><\/a>'
61    // }
62  },
63  ADS_DEBUG: true,  // set false to silence the console diagnostics
64
65  /* --- Local direct-sold ads (CambsNews-Local-Ads mu-plugin) --- */
66  LOCAL_ADS_ENDPOINT: '/wp-json/cn/v1/ads',           // returns active ads for a slot
67  LOCAL_ADS_TRACK:    '/wp-json/cn/v1/track',         // /click/<id> and /impression
68
69  /* --- 360 map (CambsNews map API mu-plugin) ---
70   * GET returns posts pinned on the /360/ map:
71   *   [ {"id":123, "slug":"story-slug", "url":"/360/index.html#p123"}, ... ]
72   * `url` optional - front end defaults to /360/index.html?post=<id>.
73   * Set to '' to disable the whole map-links feature. */
74  MAP_POSTS_ENDPOINT: '/wp-json/cn/v1/map-posts',
75
76  /* --- Newsletter (CambsNews-Newsletter mu-plugin) --- */
77  NEWSLETTER_ENDPOINT: '/wp-json/cn/v1/subscribe',
78
79  /* --- Google AdSense ---
80   * SETUP (per slot you want to fill with AdSense instead of house ads):
81   *   1. AdSense Dashboard → Ads → By ad unit → Display ads → Create new ad unit
82   *   2. Name it (e.g. "CN After Hero"), choose "Responsive" size
83   *   3. Save — copy the 10-digit number from the data-ad-slot in the snippet
84   *   4. Paste it below as a STRING (keep the quotes) — uncomment the line
85   *
86   * Priority order at render time, per slot:
87   *   1. Local direct-sold ad (WP Admin → CambsNews Local Ads, if active for that slot)
88   *   2. AdSense slot ID below (if set)
89   *   3. House ad from CONFIG.QUADS_SLOTS[slot].code
90   *   4. House ad fallback from CONFIG.QUADS_SLOTS[slot].fallback (when ad-blocker detected)
91   *
92   * Leave all SLOTS commented out until your AdSense account is approved + has slot IDs.
93   * House ads will fill the slots in the meantime.
94   */
95  ADSENSE_PUB_ID:    'ca-pub-1369754178697614',
96  ADSENSE_AUTO_ADS:  true,           // also let Google auto-place anywhere it sees fit
97  ADSENSE_SLOTS: {
98    after_hero: '7829301393',     // CN After Hero — responsive leaderboard under the hero
99    sidebar:    '9493736854',     // CN Sidebar — responsive first sidebar
100    sidebar2:   '1427848490',     // CN Sidebar 2 — responsive second sidebar
101    sidebar3:   '6628204283',     // CN Sidebar 3 — responsive third sidebar
102    /* in_article: '0000000000'   — CN Article Rail — responsive rectangle inside posts.
103                                    If empty, the post sidebar reuses the homepage `sidebar` slot ID above. */
104    in_article: ''                // ← Create "CN Article Rail" in AdSense and paste the slot ID here
105  },
106
107  /* --- Local sponsor rail (rotating in post sidebar) ---
108   * Each entry shows once-per-article-load (deterministic per post.id so the
109   * same sponsor doesn't keep flickering between renders).
110   *
111   * Image-based (preferred): set `img` to a 600x500-ish creative URL.
112   * Text-based:              omit `img` — name + tagline render instead.
113   *
114   * `display` controls device targeting:
115   *   'mobile'  → only shown on viewports < 768px
116   *   'desktop' → only shown on viewports >= 768px
117   *   'both'    (or omitted) → shown everywhere
118   */
119  SPONSORS_POOL: [
120    {
121      name:'Peterborough Cathedral — Titanosaur',
122      url:'https://peterborough-cathedral.org.uk/',
123      // Square 1:1 creative — sized for mobile. Upload the Titanosaur image to
124      // WP → Media → Add New, then paste the full URL below.
125      img:'/wp-content/uploads/2026/05/Titanosaur-Peterborough-Cathedral-01-350x350.jpg',
126      tagline:'Experience the world’s biggest known dinosaur — 13 April – 31 May',
127      cta:'Book tickets →',
128      display:'mobile'
129    },
130    {
131      name:'Canopy UK — Is your restaurant festival ready?',
132      url:'https://canopyuk.com/',
133      // Wide 8:1 leaderboard creative — sized for desktop rail.
134      img:'',  // TODO: upload a Canopy creative to WP Media and paste its URL here
135      tagline:'01733 700 468 — local installer, trusted by Cambridgeshire',
136      cta:'Get a quote →',
137      display:'desktop'
138    },
139    {
140      // "Both" fallback so the rail never goes empty if the device-specific creatives miss
141      name:'Discover Peterborough',
142      url:'https://discoverpeterborough.co.uk/',
143      img:'',
144      tagline:'Restaurants, events, things to do — all in one place',
145      cta:'Explore →',
146      display:'both'
147    }
148  ],
149
150  /* --- Weather --- */
151  WEATHER_API_KEY:  'b13fdd5d1f463bff84bef3690017efb6',
152  WEATHER_LOCATION: 'Peterborough,GB',
153
154  /* --- Google Analytics 4 ---
155   * Site Kit normally injects this on WP pages, but the SPA bypasses WP's
156   * render, so we add gtag.js manually here.
157   *
158   * Get your Measurement ID from:  WP Admin → Site Kit → Analytics →
159   * Settings → "Measurement ID" (looks like G-XXXXXXXXXX).
160   * Paste it below to enable client-side page-view tracking on the SPA. */
161  GA4_MEASUREMENT_ID: 'G-WE1QYKPS88',   // CambsNews GA4 via Site Kit
162
163  /* --- Plausible self-hosted analytics ---
164   * Self-hosted at analytics.cambsnews.co.uk (Docker on lucy-ai → Plesk
165   * nginx reverse-proxy → port 8022). Editors/authors can log in directly
166   * to view stats without WordPress permission tangles.
167   *
168   * Uses the `script.manual.js` variant so the SPA controls when each
169   * page-view fires (route changes call plausible('pageview') from
170   * gaPageView() below). Set PLAUSIBLE_DOMAIN to '' to disable. */
171  PLAUSIBLE_DOMAIN: 'cambsnews.co.uk',
172  PLAUSIBLE_HOST:   'https://analytics.cambsnews.co.uk',
173
174  /* --- Internal analytics endpoint (Independent Analytics + Most Read) ---
175   * POSTs to a tiny mu-plugin (cn-spa-helpers.php) so the SPA's article opens
176   * still increment view counts. Throttled server-side per IP per minute. */
177  VIEW_TRACK_ENDPOINT: '/wp-json/cn/v1/view',
178
179  /* --- WP user info endpoint — for "Edit Post" buttons when an editor is logged in --- */
180  ME_ENDPOINT: '/wp-json/cn/v1/me'
181};
182
183/* ============================================================
184 * Google Analytics 4 (Site Kit measurement ID).
185 * Loaded only if a real-looking GA4 ID is set in CONFIG — the placeholder
186 * "G-XXXXXXXXXX" is treated as unset so we don't blast fake traffic.
187 * ============================================================ */
188(function loadGA4(){
189  var id = (CONFIG.GA4_MEASUREMENT_ID || '').trim();
190  if (!id || /XXXXX/i.test(id)) return;
191  var s = document.createElement('script');
192  s.async = true;
193  s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id);
194  document.head.appendChild(s);
195  window.dataLayer = window.dataLayer || [];
196  window.gtag = function(){ dataLayer.push(arguments); };
197  gtag('js', new Date());
198  gtag('config', id, { send_page_view: false });   // SPA fires page_view manually on route changes
199})();
200
201/* ============================================================
202 * Plausible self-hosted analytics — loads the MANUAL variant
203 * so route changes fire pageviews via plausible('pageview').
204 *
205 * The tiny shim (window.plausible = …) queues calls made BEFORE
206 * the script.manual.js bundle finishes loading; once loaded it
207 * processes the queue. Means we can call plausible('pageview')
208 * from gaPageView() even on the first paint without races.
209 * ============================================================ */
210(function loadPlausible(){
211  var dom  = (CONFIG.PLAUSIBLE_DOMAIN || '').trim();
212  var host = (CONFIG.PLAUSIBLE_HOST   || '').trim().replace(/\/+$/,'');
213  if (!dom || !host) return;
214  var s = document.createElement('script');
215  s.defer = true;
216  s.setAttribute('data-domain', dom);
217  s.src = host + '/js/script.manual.js';
218  document.head.appendChild(s);
219  // Shim so plausible('pageview') calls queue up until the script loads
220  window.plausible = window.plausible || function(){
221    (window.plausible.q = window.plausible.q || []).push(arguments);
222  };
223})();
224
225/* Fire a GA4 page_view AND Plausible pageview — call on every SPA navigation.
226 * Both run in parallel: GA4 for cross-site reporting + Search Console, Plausible
227 * for editors/authors to see real numbers without WP permission tangles. */
228function gaPageView(){
229  // GA4
230  if (typeof window.gtag === 'function'){
231    try{
232      gtag('event', 'page_view', {
233        page_path:     location.pathname + location.search,
234        page_title:    document.title,
235        page_location: location.href
236      });
237    }catch(e){}
238  }
239  // Plausible — manual mode: explicit pageview per route. Includes the
240  // current URL so SPA route changes are tracked under the right path
241  // (without this it'd report the URL the script first loaded at).
242  if (typeof window.plausible === 'function'){
243    try{
244      window.plausible('pageview', { u: location.href });
245    }catch(e){}
246  }
247}
248
249/* ============================================================
250 * Internal view tracking — POST to cn-spa-helpers mu-plugin so
251 * Independent Analytics + Most Read get updated when a post opens
252 * in the SPA. Fires once per article view; the server rate-limits
253 * per IP per minute so refresh-spammers can't inflate counts.
254 * ============================================================ */
255var _seenViews = {};
256function recordPostView(postId){
257  if (!postId || !CONFIG.VIEW_TRACK_ENDPOINT) return;
258  if (_seenViews[postId]) return;     // already tracked in this session
259  _seenViews[postId] = true;
260  try{
261    var body = JSON.stringify({ post_id: parseInt(postId, 10) });
262    var url  = CONFIG.CN_HOST + CONFIG.VIEW_TRACK_ENDPOINT;
263    if (navigator.sendBeacon) {
264      navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
265    } else {
266      fetch(url, { method:'POST', body:body, headers:{'Content-Type':'application/json'}, keepalive:true });
267    }
268  }catch(e){}
269}
270
271/* ============================================================
272 * Auth detection — call /wp-json/cn/v1/me to find out if the visitor
273 * is a logged-in WP user. If so, show Edit Post links throughout the SPA.
274 * Cached for the session.
275 * ============================================================ */
276var _currentUser = null;
277function detectLoggedInUser(){
278  if (!CONFIG.ME_ENDPOINT) return Promise.resolve(null);
279  // Append a cache-buster so neither browser nor Cloudflare can serve a stale anon response
280  var url = CONFIG.CN_HOST + CONFIG.ME_ENDPOINT + '?_=' + Date.now();
281  console.log('[CN] checking auth at', url);
282  return fetch(url, {
283    credentials:    'include',                          // send WP login cookies
284    cache:          'no-store',                         // browser cache bypass
285    headers: { 'Cache-Control': 'no-cache' }
286  })
287    .then(function(r){
288      console.log('[CN] /me HTTP', r.status, 'cf-cache:', r.headers.get('cf-cache-status'));
289      return r.json();
290    })
291    .then(function(data){
292      console.log('[CN] /me response:', data);
293      if (data && data.logged_in) {
294        _currentUser = data;
295        document.body.classList.add('cn-editor');     // CSS hook for showing Edit buttons
296        console.log('[CN] ✓ admin mode on — body.cn-editor added, can_edit=' + data.can_edit);
297      } else {
298        console.log('[CN] not logged in (or response missing logged_in flag)');
299      }
300      return _currentUser;
301    })
302    .catch(function(err){
303      console.warn('[CN] /me fetch failed:', err && err.message);
304      return null;
305    });
306}
307/* Build an "Edit Post" link HTML if the current visitor can edit. */
308function editLinkHtml(post){
309  if (!_currentUser || !_currentUser.can_edit || !post || !post.id) return '';
310  var href = (_currentUser.admin_url || '/wp-admin/') + 'post.php?post=' + post.id + '&action=edit';
311  return '<a class="edit-link" href="' + esc(href) + '" target="_blank" rel="noopener" title="Edit in WordPress">✎ Edit</a>';
312}
313
314/* ============================================================
315 * Tiny helpers
316 * ============================================================ */
317var $ = function(s){ return document.querySelector(s); };
318var esc = function(s){ var d=document.createElement('div'); d.textContent=s||''; return d.innerHTML; };
319var stripHtml = function(s){ var d=document.createElement('div'); d.innerHTML=s||''; return d.textContent||''; };
320var fmtDate = function(s){ var d=new Date(s); return isNaN(d)?'':d.toLocaleDateString('en-GB',{year:'numeric',month:'short',day:'numeric'}); };
321var fmtRel = function(s){
322  var d = new Date(s), now = new Date();
323  if(isNaN(d)) return '';
324  var diff = Math.floor((now-d)/1000);
325  if(diff < 60) return 'just now';
326  if(diff < 3600) return Math.floor(diff/60)+' min ago';
327  if(diff < 86400) return Math.floor(diff/3600)+' hours ago';
328  if(diff < 604800) return Math.floor(diff/86400)+' days ago';
329  return fmtDate(s);
330};
331var toast = function(msg){
332  var t = document.createElement('div');
333  t.className='toast'; t.textContent=msg;
334  document.body.appendChild(t);
335  setTimeout(function(){ t.remove(); }, 4000);
336};
337
338/* ============================================================
339 * Smart fetch — tries direct, falls back to CORS proxy
340 * ============================================================ */
341function smartFetch(url){
342  return fetch(url, {mode:'cors', credentials:'omit'})
343    .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
344    .catch(function(){
345      return fetch(CONFIG.CORS_PROXY + encodeURIComponent(url))
346        .then(function(r){ if(!r.ok) throw new Error('proxy HTTP '+r.status); return r.json(); });
347    });
348}
349
350/* ============================================================
351 * Cache helper
352 * ============================================================ */
353function cacheGet(key, ttlMs){
354  try{
355    var raw = localStorage.getItem(key); if(!raw) return null;
356    var o = JSON.parse(raw);
357    if(Date.now() - o.t > ttlMs) return null;
358    return o.v;
359  }catch(e){ return null; }
360}
361/* Return cached value regardless of age — used for stale-while-revalidate first paint. */
362function cacheGetStale(key){
363  try{
364    var raw = localStorage.getItem(key); if(!raw) return null;
365    return JSON.parse(raw).v;
366  }catch(e){ return null; }
367}
368function cacheSet(key, v){
369  try{ localStorage.setItem(key, JSON.stringify({t:Date.now(), v:v})); }
370  catch(e){
371    // Likely QuotaExceededError — proactively trim and retry once.
372    pruneCache(true);
373    try{ localStorage.setItem(key, JSON.stringify({t:Date.now(), v:v})); }catch(_){}
374  }
375}
376
377/* ============================================================
378 * localStorage LRU eviction — prevents the cache growing unbounded
379 * across a long browsing session. Without this the tab gets slower
380 * after a few hours because:
381 *   - Hover-prefetch + viewed-article caching adds an entry per story
382 *   - Archive/tag views add their own per-section keys
383 *   - Reading + parsing JSON gets slower as the cache grows
384 *
385 * Strategy: cap each bucket (cn-post-*, cn-arch-*) at MAX_ENTRIES,
386 * evicting the oldest by timestamp. Runs on boot + every 5 min.
387 * ============================================================ */
388var CACHE_LIMITS = {
389  'cn-post-': 60,    // ~60 most-recently-viewed/prefetched articles
390  'cn-arch-': 20,    // ~20 most-recently-visited archives/tags/categories
391  'cn-page-': 20,    // static pages
392  'cn-tagid-': 100,  // slug → id mappings are tiny but multiply fast
393  'cn-catid-': 100
394};
395function pruneCache(force){
396  try{
397    Object.keys(CACHE_LIMITS).forEach(function(prefix){
398      var limit = CACHE_LIMITS[prefix];
399      var entries = [];
400      for(var i = 0; i < localStorage.length; i++){
401        var k = localStorage.key(i);
402        if(!k || k.indexOf(prefix) !== 0) continue;
403        var t = 0;
404        try{ t = (JSON.parse(localStorage.getItem(k)) || {}).t || 0; }catch(_){}
405        entries.push({ k: k, t: t });
406      }
407      if(entries.length <= limit && !force) return;
408      // Sort oldest-first, drop everything beyond the limit.
409      entries.sort(function(a, b){ return a.t - b.t; });
410      var toRemove = entries.length - limit;
411      if(force) toRemove = Math.max(toRemove, Math.ceil(entries.length * 0.3));  // forcefully drop 30%
412      for(var j = 0; j < toRemove; j++){
413        localStorage.removeItem(entries[j].k);
414      }
415    });
416  }catch(e){ /* localStorage might be disabled; do nothing */ }
417}
418// Boot prune + periodic prune (every 5 minutes while tab is open)
419pruneCache();
420setInterval(pruneCache, 5 * 60 * 1000);
421
422/* ============================================================
423 * Social meta — updates Open Graph + Twitter Card tags on
424 * client-side route changes so modern crawlers see the right
425 * image/title/description per article.
426 *
427 * For crawlers that DON'T execute JS (older Facebook, some Slack),
428 * the .htaccess bot-detection rule routes them to WordPress where
429 * Yoast / RankMath emit the same tags server-side.
430 * ============================================================ */
431function updateSocialMeta(m){
432  function ensureMeta(attr, key){
433    var sel = 'meta['+attr+'="'+key+'"]';
434    var el = document.head.querySelector(sel);
435    if(!el){
436      el = document.createElement('meta');
437      el.setAttribute(attr, key);
438      document.head.appendChild(el);
439    }
440    return el;
441  }
442  function set(attr, key, val){ if(val) ensureMeta(attr, key).setAttribute('content', val); }
443
444  var title = m.title       ? (m.title + ' — CambsNews') : 'CambsNews';
445  var desc  = m.description || 'Independent local news for Peterborough and Cambridgeshire.';
446  var img   = m.image       || (location.origin + '/favicon.svg');
447  var url   = m.url         || location.href;
448  var type  = m.type        || 'website';
449
450  // Open Graph (Facebook, LinkedIn, WhatsApp, Discord, Slack)
451  set('property', 'og:type',        type);
452  set('property', 'og:site_name',   'CambsNews');
453  set('property', 'og:locale',      'en_GB');
454  set('property', 'og:title',       title);
455  set('property', 'og:description', desc);
456  set('property', 'og:image',       img);
457  set('property', 'og:url',         url);
458
459  // Twitter / X
460  set('name',     'twitter:card',        'summary_large_image');
461  set('name',     'twitter:site',        '@cambsnewsonline');
462  set('name',     'twitter:title',       title);
463  set('name',     'twitter:description', desc);
464  set('name',     'twitter:image',       img);
465
466  // Canonical link helps Google index the right URL even for SPA routes
467  var canonical = document.head.querySelector('link[rel="canonical"]');
468  if(!canonical){
469    canonical = document.createElement('link');
470    canonical.rel = 'canonical';
471    document.head.appendChild(canonical);
472  }
473  canonical.href = url;
474}
475
476/* ============================================================
477 * Extract featured image from a WP REST post (with _embed)
478 * ============================================================ */
479function featuredImg(p, prefer){
480  try{
481    // 1) Native WordPress featured image (the proper way)
482    var m = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0];
483    if(m){
484      var sizes = m.media_details && m.media_details.sizes;
485      if(sizes){
486        var order = prefer==='large' ? ['large','medium_large','full','medium'] : ['medium_large','medium','large','full'];
487        for(var i=0;i<order.length;i++){ if(sizes[order[i]]) return sizes[order[i]].source_url; }
488      }
489      if(m.source_url) return m.source_url;
490    }
491    // 2) Fallback: first <img> in the post content. A lot of older posts have no
492    //    featured-image set but include their hero image inline in the body.
493    var html = (p.content && p.content.rendered) || (p.excerpt && p.excerpt.rendered) || '';
494    if(html){
495      var match = html.match(/<img[^>]+(?:data-src|src)=["']([^"']+)["']/i);
496      if(match && match[1] && /^https?:\/\//.test(match[1])) return match[1];
497    }
498    return '';
499  }catch(e){ return ''; }
500}
501
502/* Build a responsive srcset for the hero image so the browser picks the right
503 * size for the viewport instead of downloading the biggest version. Massive
504 * mobile LCP win on slow connections. Returns { src, srcset, sizes } or null. */
505function featuredImgResponsive(p){
506  try{
507    var m = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0];
508    var sizes = m && m.media_details && m.media_details.sizes;
509    if(!sizes) return null;
510    var candidates = [];
511    ['medium','medium_large','large','full'].forEach(function(name){
512      var s = sizes[name];
513      if(s && s.source_url && s.width) candidates.push({url: s.source_url, w: s.width});
514    });
515    if(!candidates.length) return null;
516    // De-duplicate by URL (some sizes share the same file)
517    var seen = {};
518    candidates = candidates.filter(function(c){ if(seen[c.url]) return false; seen[c.url]=1; return true; });
519    candidates.sort(function(a,b){ return a.w - b.w; });
520    var srcset = candidates.map(function(c){ return c.url + ' ' + c.w + 'w'; }).join(', ');
521    // Default src = the medium one for slow connections; srcset will override
522    var def = candidates[1] || candidates[0];
523    return { src: def.url, srcset: srcset, sizes: '(max-width: 1000px) 100vw, 820px' };
524  }catch(e){ return null; }
525}
526function postCategory(p){
527  try{
528    var terms = p._embedded && p._embedded['wp:term'];
529    if(!terms) return 'News';
530    var fallback = null;
531    // Prefer any non-"Exclusive" category, since "Exclusive" is conveyed by the badge
532    for(var i = 0; i < terms.length; i++){
533      for(var j = 0; j < terms[i].length; j++){
534        var t = terms[i][j];
535        if(!t || t.taxonomy !== 'category') continue;
536        if(t.name && t.name.toLowerCase() === 'exclusive'){
537          if(!fallback) fallback = t.name; // remember as last resort
538          continue;
539        }
540        return t.name;
541      }
542    }
543    return fallback || 'News';
544  }catch(e){}
545  return 'News';
546}
547/* Renders the category pill (<span class="cat">...</span>) but returns ''
548 * if the chosen category is "Exclusive" — avoids visually duplicating the
549 * EXCLUSIVE badge that's shown next to the headline. */
550function catHtml(p, tag){
551  tag = tag || 'span';
552  var c = postCategory(p);
553  if(!c || c.toLowerCase() === 'exclusive') return '';
554  var slug = c.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/(^-|-$)/g,'');
555  return '<' + tag + ' class="cat" data-c="'+slug+'">' + esc(c) + '</' + tag + '>';
556}
557function postAuthor(p){
558  try{
559    var a = p._embedded && p._embedded.author && p._embedded.author[0];
560    return a ? a.name : '';
561  }catch(e){ return ''; }
562}
563/* Prefer slug for nicer URLs; fall back to numeric ID if a post somehow has no slug */
564function postRef(p){ return (p && (p.slug || p.id)) || ''; }
565
566/* True if the post has a tag or category with the given name (case-insensitive) */
567function postHasTerm(p, name){
568  if(!p || !name) return false;
569  try{
570    var terms = p._embedded && p._embedded['wp:term'];
571    if(!terms) return false;
572    var target = String(name).toLowerCase();
573    for(var i = 0; i < terms.length; i++){
574      for(var j = 0; j < terms[i].length; j++){
575        var t = terms[i][j];
576        if(t && t.name && String(t.name).toLowerCase() === target) return true;
577        if(t && t.slug && String(t.slug).toLowerCase() === target) return true;
578      }
579    }
580  }catch(e){}
581  return false;
582}
583/* Build the EXCLUSIVE badge HTML if the post qualifies, else empty string */
584function exclusiveBadge(p, big){
585  return postHasTerm(p, 'Exclusive')
586    ? '<span class="excl'+(big?' lg':'')+'">Exclusive</span>'
587    : '';
588}
589/* Build the CN+ club badge if the post has the configured premium tag */
590function cnPlusBadge(p, big){
591  if(!CONFIG.PLUS_TAG || !postHasTerm(p, CONFIG.PLUS_TAG)) return '';
592  return '<span class="cnplus'+(big?' lg':'')+'">'+esc(CONFIG.PLUS_BRAND || 'CN+')+'</span>';
593}
594/* Combined badges block — order matters: CN+ first (premium), then EXCLUSIVE */
595function postBadges(p, big){
596  return cnPlusBadge(p, big) + exclusiveBadge(p, big);
597}
598/* Hot-score ranking — combines recency boost with popularity.
599 * - Brand-new posts (<6h old) get a "fresh news" boost so they land high
600 *   immediately, regardless of views.
601 * - As they age, the boost fades and the post needs view-count to stay up.
602 * - Posts much older than HOT_HALF_LIFE_H decay fast unless they're viral.
603 *
604 * Tunable: HOT_HALF_LIFE_H controls how aggressively old posts drop.
605 */
606var HOT_HALF_LIFE_H = 48;    // half-life in hours for the view-score decay
607function hotScore(p){
608  var views = (p.meta && typeof p.meta.iawp_total_views === 'number') ? p.meta.iawp_total_views : 0;
609  var ts = p.date ? new Date(p.date).getTime() : Date.now();
610  var ageH = Math.max(0.5, (Date.now() - ts) / (1000*60*60));
611  var freshBoost = ageH < 6 ? (6 - ageH) * 60 : 0;    // first 6 hours: big boost
612  var decay = Math.pow(0.5, ageH / HOT_HALF_LIFE_H);  // exponential decay over time
613  return (views * decay) + freshBoost;
614}
615function sortByHot(posts){
616  return posts.slice().sort(function(a,b){ return hotScore(b) - hotScore(a); });
617}
618
619/* Author name, linked to their author archive when available.
620 * Guest authors (via cn-spa-helpers' _cn_byline custom field) take precedence
621 * over the WP user — that's the editor's intent when they set a guest byline. */
622function authorHtml(p){
623  try{
624    // 1. Guest byline (custom field via cn-spa-helpers mu-plugin)
625    if (p._cn_byline && p._cn_byline.name) {
626      var g = p._cn_byline;
627      var inner = (g.image ? '<img class="author-pic" loading="lazy" src="'+esc(g.image)+'" alt="">' : '')
628                + esc(g.name);
629      return '<span class="author-link guest-author">' + inner + '</span>';
630    }
631    // 2. WP user
632    var a = p._embedded && p._embedded.author && p._embedded.author[0];
633    if(!a || !a.name) return '';
634    var pic = '';
635    if (a.avatar_urls && (a.avatar_urls['48'] || a.avatar_urls['96'])) {
636      pic = '<img class="author-pic" loading="lazy" src="'+esc(a.avatar_urls['48'] || a.avatar_urls['96'])+'" alt="">';
637    }
638    if(a.link){
639      return '<a class="author-link" href="'+esc(a.link)+'" target="_blank" rel="noopener">' + pic + esc(a.name) + '</a>';
640    }
641    return '<span class="author-link">' + pic + esc(a.name) + '</span>';
642  }catch(e){ return ''; }
643}
644
645/* WordPress lazy-load plugins (LiteSpeed Cache, WP Rocket, A3 Lazy Load,
646 * Jetpack Lazy Images, etc.) rewrite <img src> in different ways. This walks
647 * the rendered article and tries to restore the real image URL from whatever
648 * data-* attribute the plugin used.
649 *
650 * Strategies (run in order):
651 *   1. Promote any <noscript><img...></noscript> fallback into a real <img>.
652 *   2. For each <img>, if src is empty / a tiny placeholder / a data: SVG,
653 *      scan a wide list of data-* attrs for a real URL and copy it to src.
654 *   3. Same for srcset.
655 *   4. Strip "lazy" class names that re-trigger lazy behaviour.
656 *
657 * Set CONFIG.ADS_DEBUG = true to log unresolved images to the console.
658 */
659function activateLazyImages(scope){
660  if(!scope) scope = document;
661
662  // 1) Promote noscript image fallbacks
663  var noscripts = scope.querySelectorAll('noscript');
664  for(var n = 0; n < noscripts.length; n++){
665    var ns = noscripts[n];
666    var txt = ns.textContent || ns.innerHTML;
667    if(/<img[\s\S]*?>/i.test(txt)){
668      var wrap = document.createElement('span');
669      wrap.innerHTML = txt;
670      if(ns.parentNode) ns.parentNode.replaceChild(wrap, ns);
671    }
672  }
673
674  // Candidate data-* attribute names plugins use for the real URL
675  var SRC_ATTRS = [
676    'data-src', 'data-lazy-src', 'data-original', 'data-lazysrc',
677    'data-lazy', 'data-actual-src', 'data-orig-src', 'data-cfsrc',
678    'data-large-file', 'data-medium-file', 'data-full-src'
679  ];
680  var SRCSET_ATTRS = ['data-srcset', 'data-lazy-srcset', 'data-orig-srcset'];
681
682  function looksLikePlaceholder(src){
683    if(!src) return true;
684    if(src.indexOf('data:image/svg') === 0) return true;
685    if(src.indexOf('data:image/gif;base64,R0lG') === 0) return true; // 1x1 gif
686    if(/\b(blank|placeholder|loading)\.(gif|png|svg)/i.test(src)) return true;
687    return false;
688  }
689
690  var imgs = scope.querySelectorAll('img');
691  for(var i = 0; i < imgs.length; i++){
692    var img = imgs[i];
693    var currentSrc = img.getAttribute('src') || '';
694    var isPlaceholder = looksLikePlaceholder(currentSrc);
695
696    // src
697    for(var a = 0; a < SRC_ATTRS.length; a++){
698      var v = img.getAttribute(SRC_ATTRS[a]);
699      if(v && /^(https?:\/\/|\/)/.test(v)){
700        if(isPlaceholder || !currentSrc) img.setAttribute('src', v);
701        img.removeAttribute(SRC_ATTRS[a]);
702        isPlaceholder = false;
703        break;
704      }
705    }
706    // srcset
707    for(var b = 0; b < SRCSET_ATTRS.length; b++){
708      var ss = img.getAttribute(SRCSET_ATTRS[b]);
709      if(ss){
710        img.setAttribute('srcset', ss);
711        img.removeAttribute(SRCSET_ATTRS[b]);
712        break;
713      }
714    }
715    // Strip lazyload triggers
716    ['lazyload','lazy','lazyloading','b-lazy','jetpack-lazy-image'].forEach(function(cls){
717      img.classList.remove(cls);
718    });
719
720    // Native loading="lazy" can fail to trigger reliably when innerHTML is
721    // injected into a SPA — remove it so the browser fetches immediately.
722    img.removeAttribute('loading');
723
724    if(!img.hasAttribute('decoding')) img.setAttribute('decoding','async');
725
726    // Diagnostic — surface error vs. success per image
727    if(CONFIG && CONFIG.ADS_DEBUG){
728      var finalSrc = img.getAttribute('src');
729      if(looksLikePlaceholder(finalSrc)){
730        console.log('[img] still placeholder after activation:', img.outerHTML.slice(0,260));
731      } else {
732        img.addEventListener('error', function(){
733          console.warn('[img] failed to load:', img.src, '(status unknown — check Network tab)');
734        }, {once:true});
735      }
736    }
737
738    // Force the browser to (re-)issue the network request by replacing the
739    // element with a clone. Browsers may not refetch if the src attribute
740    // is unchanged textually, even though the element is freshly inserted.
741    if(img.parentNode && img.getAttribute('src')){
742      var fresh = img.cloneNode(false);
743      img.parentNode.replaceChild(fresh, img);
744    }
745  }
746}
747
748/* ============================================================
749 * HOME — render
750 * ============================================================ */
751/* Global set of post IDs already rendered anywhere on the home page —
752 * prevents duplicates when infinite scroll loads more pages. */
753var renderedPostIds = {};
754
755function renderHome(posts){
756  if(!posts || !posts.length){
757    $('#news-grid').innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories available right now.</div>';
758    return;
759  }
760
761  // Pin the hero (posts[0]) and side stack (posts[1..3]) — these reflect
762  // editorial decisions (homepage-tagged hero + latest few). Everything from
763  // index 4 onwards gets reranked by hot-score so fresh + popular wins.
764  var pinned = posts.slice(0, 4);
765  var pool   = posts.slice(4);
766  var ranked = sortByHot(pool);
767  posts = pinned.concat(ranked);
768
769  // Record every ID we're about to render so infinite scroll can skip them
770  renderedPostIds = {};
771  posts.forEach(function(p){ if(p && p.id) renderedPostIds[p.id] = true; });
772
773  // LEAD
774  var p0 = posts[0];
775  // Build a responsive srcset for the hero so mobile downloads a 480w/768w
776  // variant instead of the full 1024w "large" — slashes mobile LCP on Slow 4G.
777  var heroResp = featuredImgResponsive(p0);
778  var leadImg  = (heroResp && heroResp.src)
779              || featuredImg(p0, 'large')
780              || 'https://images.unsplash.com/photo-1543872084-c7bd3822856f?w=1200&q=70';
781  var heroSrcSetAttr = heroResp
782    ? ' srcset="' + esc(heroResp.srcset) + '" sizes="' + esc(heroResp.sizes) + '"'
783    : '';
784  $('#lead').classList.remove('sk','sk-hero');
785
786  $('#lead').innerHTML =
787    '<div class="img"><img src="'+leadImg+'"'+heroSrcSetAttr+' alt="" fetchpriority="high" decoding="async"></div>'
788    + '<div class="grad"></div>'
789    + '<div class="copy">'
790    +   catHtml(p0)
791    +   '<h1>'+postBadges(p0, true)+esc(stripHtml(p0.title.rendered))+'</h1>'
792    +   '<div class="meta">'+(authorHtml(p0)?'by '+authorHtml(p0)+' · ':'')+esc(fmtRel(p0.date))+'</div>'
793    +   editLinkHtml(p0)
794    + '</div>';
795  $('#lead').onclick = function(e){
796    if (e.target.closest('.edit-link')) return;   // don't navigate when editor clicks Edit
797    go('#/post/'+postRef(p0));
798  };
799
800  // SIDE (next 3)
801  $('#side').innerHTML = posts.slice(1,4).map(function(p){
802    var img = featuredImg(p) || '';
803    return '<div class="card" data-id="'+postRef(p)+'">'
804      + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>'
805      + '<div>'
806      +   catHtml(p, 'div')
807      +   '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>'
808      +   '<time>'+esc(fmtRel(p.date))+'</time>'
809      + '</div></div>';
810  }).join('');
811  $('#side').onclick = function(e){
812    var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id);
813  };
814
815  // NEWS GRID (next 9 — 1 full-width featured + 8 in 2-col grid)
816  $('#news-grid').innerHTML = posts.slice(4,13).map(function(p){
817    var img = featuredImg(p) || '';
818    var excerpt = stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0,160);
819    return '<article class="story" data-id="'+postRef(p)+'">'
820      + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>'
821      + catHtml(p)
822      + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>'
823      + '<div class="meta">'+esc(fmtDate(p.date))+(authorHtml(p)?' · '+authorHtml(p):'')+'</div>'
824      + (excerpt?'<p>'+esc(excerpt)+'…</p>':'')
825      + editLinkHtml(p)
826      + '</article>';
827  }).join('');
828  $('#news-grid').onclick = function(e){
829    if (e.target.closest('.edit-link')) return;     // Edit link opens in new tab, don't navigate SPA
830    var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id);
831  };
832
833  // EDITOR'S CHOICE (next 4 — 2 rows of 2)
834  $('#editors-grid').innerHTML = posts.slice(13,17).map(function(p){
835    var img = featuredImg(p) || '';
836    return '<article class="story" data-id="'+postRef(p)+'">'
837      + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>'
838      + catHtml(p)
839      + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>'
840      + '<div class="meta">'+esc(fmtDate(p.date))+'</div>'
841      + '</article>';
842  }).join('');
843  $('#editors-grid').onclick = function(e){
844    var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id);
845  };
846
847  // Most Read is populated by loadMostRead() — real IAWP view counts.
848}
849
850/* ============================================================
851 * MOST READ — uses Independent Analytics (iawp_total_views) when available
852 * Falls back to comment_count, then to recency.
853 * ============================================================ */
854function renderMostRead(posts){
855  if(!posts || !posts.length) return;
856  var withViews = posts.filter(function(p){
857    return p.meta && typeof p.meta.iawp_total_views === 'number' && p.meta.iawp_total_views > 0;
858  });
859  var sorted, useViews = false;
860  if(withViews.length >= 3){
861    sorted = withViews.sort(function(a,b){
862      return (b.meta.iawp_total_views||0) - (a.meta.iawp_total_views||0);
863    }).slice(0,5);
864    useViews = true;
865  } else {
866    sorted = posts.slice().sort(function(a,b){
867      return (b.comment_count||0) - (a.comment_count||0);
868    }).slice(0,5);
869    if(sorted.every(function(p){ return !p.comment_count; })) sorted = posts.slice(0,5);
870  }
871  $('#most-read').innerHTML = sorted.map(function(p){
872    // Ranking (1, 2, 3…) is still shown via the .most-read::before counter in CSS.
873    // We only render the publish DATE underneath the headline — never the raw view count,
874    // so the public never sees internal traffic numbers.
875    var sub = esc(fmtDate(p.date));
876    var img = featuredImg(p) || '';
877    return '<li data-id="'+postRef(p)+'">'
878      + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">
878':'')+'</div>'
879      + '<div><h4>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h4><time>'+sub+'</time></div></li>';
880  }).join('');
881  $('#most-read').onclick = function(e){
882    var c = e.target.closest('[data-id]'); if(c) go('#/post/'+c.dataset.id);
883  };
884}
885
886function loadMostRead(){
887  // 30 most recent posts with full embed (need wp:term so the EXCLUSIVE badge can detect the tag)
888  var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=30&_embed=1';
889  var cached = cacheGetStale('cn-mostread-v3');
890  if(cached) renderMostRead(cached);
891  smartFetch(url).then(function(posts){
892    renderMostRead(posts);
893    cacheSet('cn-mostread-v3', posts);
894  }).catch(function(){ /* silent fallback — main grid will still load */ });
895}
896
897/* ============================================================
898 * QUADS ADS — fetch ad codes from WP REST endpoint, swap to
899 * QUADS fallback ad if an ad blocker is detected.
900 * Requires the mu-plugin shown in the docs to be installed on
901 * the WordPress origin. Slots/IDs are configured in CONFIG.
902 * ============================================================ */
903function detectAdBlock(){
904  return new Promise(function(resolve){
905    var done = false;
906    var settle = function(blocked){
907      if(done) return; done = true;
908      try{ bait.remove(); }catch(e){}
909      resolve(!!blocked);
910    };
911
912    // Method 1 — bait class (catches in-browser blockers: uBlock, AdBlock Plus, Brave shields)
913    var bait = document.createElement('div');
914    bait.className = 'ads adsbox doubleclick ad-placement carbon-ads pub_300x250 text-ads';
915    bait.style.cssText = 'position:absolute;left:-9999px;top:-9999px;height:1px;width:1px';
916    bait.innerHTML = '&nbsp;';
917    document.body.appendChild(bait);
918
919    setTimeout(function(){
920      var cs = getComputedStyle(bait);
921      var baitBlocked = bait.offsetHeight === 0 || bait.offsetParent === null
922        || cs.display === 'none' || cs.visibility === 'hidden';
923      if(baitBlocked){ settle(true); return; }
924
925      // Method 2 — network test (catches DNS-level blockers: Pi-hole, NextDNS, AdGuard DNS).
926      // Using fetch w/ mode:'no-cors' — resolves on ANY successful TCP response,
927      // rejects ONLY on network/DNS failure (= blocked).
928      fetch('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?_=' + Date.now(), {
929        method: 'GET',
930        mode: 'no-cors',
931        cache: 'no-store',
932        credentials: 'omit',
933        redirect: 'follow'
934      }).then(function(){ settle(false); })
935        .catch(function(){ settle(true); });
936
937      // Safety timeout — default to NOT blocked. False negative (showing an ad
938      // that ends up getting blocked anyway) is harmless; false positive
939      // (showing fallback when ads would have shown) loses revenue.
940      setTimeout(function(){ settle(false); }, 2500);
941    }, 100);
942  });
943}
944
945function injectAdHtml(slot, html, isFallback){
946  if(!html){ slot.innerHTML = ''; slot.style.display = 'none'; return; }
947  slot.classList.add('loaded');
948  if(isFallback) slot.classList.add('ad-fallback-active');
949  slot.innerHTML = isFallback
950    ? '<div class="ad-fallback">'+html+'</div>'
951    : html;
952  // re-execute any inline scripts so AdSense actually runs
953  var scripts = slot.querySelectorAll('script');
954  for(var i=0;i<scripts.length;i++){
955    var oldS = scripts[i];
956    var newS = document.createElement('script');
957    for(var j=0;j<oldS.attributes.length;j++){
958      newS.setAttribute(oldS.attributes[j].name, oldS.attributes[j].value);
959    }
960    newS.text = oldS.textContent;
961    oldS.parentNode.replaceChild(newS, oldS);
962  }
963}
964
965/**
966 * Build the AdSense <ins> tag HTML for a given slot, when a specific slot ID
967 * is configured in CONFIG.ADSENSE_SLOTS. Responsive display ad unit format —
968 * works for both leaderboard (728x90) and rectangle (300x250) placements.
969 * Returns '' if AdSense isn't configured for this slot.
970 */
971function adsenseInsHtml(slotName){
972  if(!CONFIG.ADSENSE_PUB_ID || !CONFIG.ADSENSE_SLOTS) return '';
973  var slotId = CONFIG.ADSENSE_SLOTS[slotName];
974  // Fallback map — each in-article placement reuses a DIFFERENT homepage slot ID
975  // so AdSense serves a different creative to each (rather than the same Temu spray-gun
976  // ad in every slot, which happens when 3 placements share one slot ID).
977  if(!slotId){
978    var fallbacks = {
979      in_article:     CONFIG.ADSENSE_SLOTS.sidebar,   // sticky rail
980      in_article_top: CONFIG.ADSENSE_SLOTS.sidebar2,  // first inline ad
981      in_article_mid: CONFIG.ADSENSE_SLOTS.sidebar3   // second inline ad
982    };
983    slotId = fallbacks[slotName];
984  }
985  if(!slotId) return '';
986  // IMPORTANT: even inside a JS comment, a literal end-script tag will close the
987  // host script tag, because the HTML parser doesn't understand JS comments.
988  // Both the string below AND this comment must use the escaped form '<\/script>'.
989  return '<ins class="adsbygoogle" '
990    +    'style="display:block" '
991    +    'data-ad-client="' + esc(CONFIG.ADSENSE_PUB_ID) + '" '
992    +    'data-ad-slot="'   + esc(String(slotId)) + '" '
993    +    'data-ad-format="auto" '
994    +    'data-full-width-responsive="true"></ins>'
995    + '<script>(adsbygoogle = window.adsbygoogle || []).push({});<\/script>';
996}
997
998function loadAds(){
999  var slots = document.querySelectorAll('.ad-slot');
1000  var dbg = function(){ if(CONFIG.ADS_DEBUG && window.console) console.log.apply(console, ['[ads]'].concat([].slice.call(arguments))); };
1001
1002  if(!slots.length){ dbg('no .ad-slot elements found on page'); return; }
1003  if(!CONFIG.QUADS_SLOTS){ dbg('CONFIG.QUADS_SLOTS missing'); return; }
1004
1005  // Decide whether we need the endpoint at all (only if any slot value is a numeric ID)
1006  var needsEndpoint = false;
1007  Object.keys(CONFIG.QUADS_SLOTS).forEach(function(k){
1008    var v = CONFIG.QUADS_SLOTS[k];
1009    if(typeof v === 'number' || typeof v === 'string') needsEndpoint = true;
1010  });
1011
1012  // Snapshot what each slot wants — and inject the primary code IMMEDIATELY
1013  // for any inline-configured slot, so the user never sees an empty placeholder
1014  // while waiting for ad-block detection / endpoint fetch.
1015  var slotRenderState = []; // {slot, slotName, cfgKind: 'inline'|'id', codeRendered: 'code'|'fallback'|'none'}
1016
1017  slots.forEach(function(slot){
1018    var slotName = slot.dataset.slot;
1019    var cfg = CONFIG.QUADS_SLOTS[slotName];
1020    var entry = { slot: slot, slotName: slotName, kind: null, code: null, fallback: null, current: 'none' };
1021
1022    if(cfg == null){
1023      dbg('slot "'+slotName+'" not configured in CONFIG.QUADS_SLOTS');
1024      slotRenderState.push(entry);
1025      return;
1026    }
1027
1028    if(typeof cfg === 'object'){
1029      entry.kind = 'inline';
1030      // Priority for the "code" slot, picked LAZILY (so AdSense slot IDs added
1031      // to CONFIG after page load can still be picked up on the next render):
1032      //   1. AdSense <ins> tag — if CONFIG.ADSENSE_SLOTS has an ID for this slot
1033      //   2. The inline cfg.code (house ad) — fallback when AdSense isn't configured
1034      // Either way, the cfg.fallback (house ad) is used when an ad-blocker is detected.
1035      var adsenseCode = adsenseInsHtml(slotName);
1036      entry.code = adsenseCode || cfg.code;
1037      entry.usingAdsense = !!adsenseCode;
1038      entry.fallback = cfg.fallback;
1039
1040      // PAINT THE HOUSE AD IMMEDIATELY so the user never sees an empty placeholder
1041      // even if /wp-json/cn/v1/ads is slow or 404s.
1042      if(entry.code){
1043        injectAdHtml(slot, entry.code, false);
1044        entry.current = 'code';
1045        dbg('slot "'+slotName+'" → injected ' + (entry.usingAdsense ? 'AdSense unit (' + CONFIG.ADSENSE_SLOTS[slotName] + ')' : 'house ad') + ' (immediate)');
1046      }
1047
1048      // Then ASYNC try to upgrade to a local direct-sold ad if one exists.
1049      // The callback is only invoked when no local ad rendered — and at that
1050      // point we've already injected the house ad, so it's a no-op.
1051      loadLocalAdsForSlot(slot, function(){ /* already painted */ });
1052    } else {
1053      entry.kind = 'id';
1054      entry.adId = String(cfg);
1055    }
1056    slotRenderState.push(entry);
1057  });
1058
1059  // Fetch endpoint ads + detect ad-block in parallel — but don't block first paint
1060  var fetchAds = needsEndpoint && CONFIG.QUADS_ENDPOINT
1061    ? smartFetch(CONFIG.CN_HOST + CONFIG.QUADS_ENDPOINT).catch(function(err){ dbg('endpoint fetch failed:', err && err.message); return null; })
1062    : Promise.resolve([]);
1063
1064  Promise.all([fetchAds, detectAdBlock()]).then(function(res){
1065    var ads = res[0];
1066    var blocked = res[1];
1067    var cached = cacheGet('quads-ads-v2', 60*60*1000);
1068    if(!ads && cached){ dbg('endpoint failed, using cached ads'); ads = cached; }
1069    if(ads && ads.length) cacheSet('quads-ads-v2', ads);
1070    dbg('blocker detected:', blocked, '· ads from endpoint:', (ads||[]).length);
1071
1072    var byId = {};
1073    (ads||[]).forEach(function(a){ byId[String(a.id)] = a; });
1074
1075    slotRenderState.forEach(function(entry){
1076      var slot = entry.slot;
1077      var code, fallback;
1078
1079      if(entry.kind === 'inline'){
1080        code = entry.code; fallback = entry.fallback;
1081      } else if(entry.kind === 'id'){
1082        var ad = byId[entry.adId];
1083        if(!ad){ dbg('slot "'+entry.slotName+'" -> ad id '+entry.adId+' not in endpoint response. Available IDs:', Object.keys(byId)); return; }
1084        code = ad.code; fallback = ad.fallback;
1085      } else {
1086        return; // unconfigured slot
1087      }
1088
1089      // If blocked and we have a fallback — swap to it (or render if we hadn't yet)
1090      if(blocked && fallback){
1091        if(entry.current !== 'fallback'){
1092          dbg('slot "'+entry.slotName+'" → blocker detected, swapping to fallback');
1093          injectAdHtml(slot, fallback, true);
1094        }
1095      } else if(code){
1096        // For ID-based slots that we couldn't render synchronously, render now
1097        if(entry.current !== 'code'){
1098          dbg('slot "'+entry.slotName+'" → injecting primary code');
1099          injectAdHtml(slot, code, false);
1100        }
1101      } else if(!code && !fallback){
1102        dbg('slot "'+entry.slotName+'" → no code available, hiding');
1103        slot.style.display = 'none';
1104      }
1105    });
1106  });
1107}
1108
1109/* Resolve a tag slug -> tag ID. Cached for a month (tags rarely move). */
1110function getTagId(slug){
1111  if(!slug) return Promise.resolve(null);
1112  var key = 'cn-tagid-' + slug;
1113  var cached = cacheGet(key, 30*24*3600*1000);
1114  if(cached) return Promise.resolve(cached);
1115  return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/tags?slug=' + encodeURIComponent(slug))
1116    .then(function(tags){
1117      var id = tags && tags[0] && tags[0].id;
1118      if(id) cacheSet(key, id);
1119      return id || null;
1120    }).catch(function(){ return null; });
1121}
1122/* Resolve a category slug -> category ID. Cached for a month. */
1123function getCategoryId(slug){
1124  if(!slug) return Promise.resolve(null);
1125  var key = 'cn-catid-' + slug;
1126  var cached = cacheGet(key, 30*24*3600*1000);
1127  if(cached) return Promise.resolve(cached);
1128  return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/categories?slug=' + encodeURIComponent(slug))
1129    .then(function(cats){
1130      var id = cats && cats[0] && cats[0].id;
1131      if(id) cacheSet(key, id);
1132      return id || null;
1133    }).catch(function(){ return null; });
1134}
1135
1136/* ============================================================
1137 * ARCHIVE — renders posts for a category or tag inside the SPA
1138 * ============================================================ */
1139function loadArchive(kind, slug){
1140  $('#home-view').classList.add('hidden');
1141  $('#post-view').classList.remove('hidden');
1142  $('#post-view').classList.add('is-archive');
1143  $('#post-view').classList.remove('has-rail');
1144  var view = $('#post-view');
1145
1146  // Pretty label from slug
1147  var label = decodeURIComponent(slug).replace(/[-_]/g,' ')
1148    .replace(/\b\w/g, function(c){ return c.toUpperCase(); });
1149
1150  view.innerHTML = '<a class="back" href="/">← Back to home</a>'
1151    + '<h1 style="font-family:var(--serif);font-size:28px;margin:8px 0 4px">' + esc(label) + '</h1>'
1152    + '<p style="color:var(--muted);margin-bottom:18px">Latest stories ' + (kind==='tag'?'tagged ':'in ')
1153    +    '<strong>' + (kind==='tag'?'#':'') + esc(label) + '</strong></p>'
1154    + '<div class="news-grid" id="archive-grid">'
1155    +   '<div class="sk sk-card"></div><div class="sk sk-card"></div>'
1156    +   '<div class="sk sk-card"></div><div class="sk sk-card"></div>'
1157    + '</div>'
1158    + '<div class="load-more" id="archive-more"></div>';
1159  document.title = label + ' — CambsNews';
1160  window.scrollTo({top:0, behavior:'instant'});
1161
1162  var resolver = kind==='tag' ? getTagId : getCategoryId;
1163  var queryKey = kind==='tag' ? 'tags'   : 'categories';
1164  var cacheKey = 'cn-arch-' + kind + '-' + slug;
1165  var cachedList = cacheGetStale(cacheKey);
1166  if(cachedList && cachedList.length){
1167    $('#archive-grid').innerHTML = cachedList.map(renderResultCard).join('');
1168  }
1169
1170  resolver(slug).then(function(id){
1171    if(!id){
1172      var grid = $('#archive-grid');
1173      if(grid) grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">Section <strong>' + esc(label) + '</strong> not found on the site.</div>';
1174      return;
1175    }
1176    var page = 1, totalPages = 1, totalCount = 0;
1177    function loadPage(){
1178      var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?' + queryKey + '=' + id + '&per_page=12&page=' + page + '&_embed=1';
1179      return fetchWithTotals(url).then(function(res){
1180        var posts = res.data || [];
1181        var grid = $('#archive-grid');
1182        if(!grid) return;
1183        if(page === 1){
1184          if(!posts.length){
1185            grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories found in <strong>' + esc(label) + '</strong>.</div>';
1186            $('#archive-more').innerHTML = '';
1187            return;
1188          }
1189          grid.innerHTML = '';
1190          cacheSet(cacheKey, posts);
1191        }
1192        grid.insertAdjacentHTML('beforeend', posts.map(renderResultCard).join(''));
1193        totalPages = res.pages || totalPages;
1194        totalCount = res.total || (page * posts.length);
1195        renderMoreButton('archive-more', page, totalPages, totalCount, function(){ page++; loadPage(); });
1196      });
1197    }
1198    loadPage();
1199  }).catch(function(err){
1200    if(!cachedList){
1201      var grid = $('#archive-grid');
1202      if(grid) grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">Could not load section: ' + esc(err.message) + '</div>';
1203    }
1204  });
1205
1206  $('#archive-grid').onclick = function(e){
1207    var c = e.target.closest('.story[data-id]');
1208    if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); }
1209  };
1210}
1211
1212function loadCambsNews(){
1213  var listUrl = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=22&_embed=1';
1214  // Stale-while-revalidate: paint any cached version instantly, then refresh
1215  var cached = cacheGetStale('cn-posts-v3');
1216  if(cached) renderHome(cached);
1217
1218  // Resolve the hero tag, then fetch list + latest hero-tagged post in parallel.
1219  var heroPromise = CONFIG.HERO_TAG
1220    ? getTagId(CONFIG.HERO_TAG).then(function(tid){
1221        if(!tid) return null;
1222        return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?tags=' + tid + '&per_page=1&_embed=1')
1223          .then(function(arr){ return (arr && arr[0]) || null; })
1224          .catch(function(){ return null; });
1225      })
1226    : Promise.resolve(null);
1227
1228  Promise.all([
1229    smartFetch(listUrl).catch(function(){ return null; }),
1230    heroPromise
1231  ]).then(function(results){
1232    var posts = results[0];
1233    var heroPost = results[1];
1234
1235    if(!posts && !heroPost){
1236      if(!cached){
1237        $('#news-grid').innerHTML = '<div class="error-banner" style="grid-column:1/-1">Sorry, we couldn’t load the latest stories — please refresh in a moment.</div>';
1238        $('#lead').classList.remove('sk','sk-hero');
1239        $('#lead').innerHTML = '<div class="copy" style="position:static;padding:40px;color:#fff;background:#222;border-radius:4px"><span class="cat">Connection issue</span><h1>Latest stories will be back shortly</h1><div class="meta">Try refreshing the page in a moment.</div></div>';
1240      }
1241      return;
1242    }
1243
1244    // Pin the hero-tagged post to the front and dedupe
1245    if(heroPost){
1246      posts = posts || [];
1247      posts = [heroPost].concat(posts.filter(function(p){ return p.id !== heroPost.id; }));
1248    }
1249
1250    renderHome(posts);
1251    cacheSet('cn-posts-v3', posts);
1252  });
1253
1254  loadMostRead();
1255}
1256
1257/* ============================================================
1258 * ARTICLE — single post view
1259 * ============================================================ */
1260/* SVG icons (inline so the file is fully self-contained) */
1261var SHARE_ICONS = {
1262  fb:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22.675 0H1.325C.593 0 0 .593 0 1.325v21.351C0 23.407.593 24 1.325 24H12.82v-9.294H9.692v-3.622h3.128V8.413c0-3.1 1.893-4.788 4.659-4.788 1.325 0 2.464.099 2.795.143v3.24l-1.918.001c-1.504 0-1.795.715-1.795 1.763v2.313h3.587l-.467 3.622h-3.12V24h6.116c.73 0 1.323-.593 1.323-1.325V1.325C24 .593 23.407 0 22.675 0z"/></svg>',
1263  tw:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
1264  wa:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.59 5.392 1.591 5.448 0 9.887-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.652 3.891 1.746 5.634l-.999 3.648 3.742-.98zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.471-.148-.669.15-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.371-.025-.52-.075-.149-.669-1.611-.916-2.206-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.71.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413z"/></svg>',
1265  share: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg>'
1266};
1267
1268function shareBarHtml(title, url, position){
1269  var t = encodeURIComponent(title);
1270  var u = encodeURIComponent(url);
1271  var tEsc = esc(title), uEsc = esc(url);
1272  return '<div class="share-bar' + (position==='bottom'?' bottom':'') + '">'
1273    + '<a class="sb-fb" href="https://www.facebook.com/sharer/sharer.php?u='+u+'" target="_blank" rel="noopener" aria-label="Share on Facebook">'+SHARE_ICONS.fb+'<span>Share on Facebook</span></a>'
1274    + '<a class="sb-tw" href="https://twitter.com/intent/tweet?url='+u+'&text='+t+'" target="_blank" rel="noopener" aria-label="Share on X">'+SHARE_ICONS.tw+'<span>Share on Twitter</span></a>'
1275    + '<a class="sb-wa" href="https://api.whatsapp.com/send?text='+t+'%20'+u+'" target="_blank" rel="noopener" aria-label="Share on WhatsApp">'+SHARE_ICONS.wa+'</a>'
1276    + '<button class="sb-more" data-share-url="'+uEsc+'" data-share-title="'+tEsc+'" aria-label="More share options">'+SHARE_ICONS.share+'</button>'
1277    + '</div>';
1278}
1279
1280/* ============================================================
1281 * Article rail — sticky right column shown inside each post.
1282 * Contents: AdSense unit + "Coming up" events card + rotating sponsor.
1283 * Falls back gracefully if the events cache is empty or no sponsors exist.
1284 * ============================================================ */
1285function cachedEventsList(){
1286  // Two caches store the same events under different keys, written by different
1287  // views (the /whats-on page writes 'cn-whats-on-v2', the homepage DP widget
1288  // writes 'dp-cache-v3'). Read both — whichever has data wins — so the article
1289  // rail isn't empty just because the visitor landed on the article directly
1290  // without hitting the homepage first.
1291  function pullFrom(key, path){
1292    try{
1293      var raw = localStorage.getItem(key); if(!raw) return [];
1294      var parsed = JSON.parse(raw);
1295      // Walk the path (e.g. ['v','events'] for cn-whats-on-v2, ['v','events'] for dp-cache-v3)
1296      var cur = parsed;
1297      for(var i=0;i<path.length;i++){ cur = cur && cur[path[i]]; }
1298      return Array.isArray(cur) ? cur : [];
1299    }catch(e){ return []; }
1300  }
1301  // cn-whats-on-v2 shape: {t, v:{events:[...]}}
1302  // dp-cache-v3      shape: {t, v:{posts:[...], events:[...]}}
1303  var events = pullFrom('cn-whats-on-v2', ['v','events']);
1304  if(!events.length) events = pullFrom('dp-cache-v3', ['v','events']);
1305  // localStorage can be full (setItem fails silently) - fall back to the in-memory
1306  // copy kept by prefetchEventsForRail so the rail still works without storage.
1307  if(!events.length) events = window.__cnRailEvents || [];
1308  if(!events.length) return [];
1309  // Only future events (including today), sorted ascending by date
1310  var now = Date.now();
1311  return events.filter(function(ev){
1312    var d = new Date((ev.start_date||'').replace(' ','T'));
1313    return !isNaN(d) && d.getTime() >= now - 86400000;
1314  }).sort(function(a,b){
1315    return new Date(a.start_date) - new Date(b.start_date);
1316  });
1317}
1318/* Background-load DP events if there's no cached copy yet (so the rail isn't empty on a fresh visit).
1319 * Uses smartFetch so CORS errors on Discover Peterborough fall back through the CORS proxy. */
1320function prefetchEventsForRail(){
1321  if(cachedEventsList().length) return;          // already cached
1322  if(!CONFIG.DP_HOST) return;
1323  var url = CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=50';
1324  smartFetch(url).then(function(data){
1325    var events = (data && data.events) || [];
1326    if(!events.length) return;
1327    window.__cnRailEvents = events;
1328    try{ localStorage.setItem('cn-whats-on-v2', JSON.stringify({t:Date.now(), v:{events:events}})); }catch(_){}
1329    // If we're already on a post view with an empty rail, fill it in now
1330    var slot = document.querySelector('.article-rail .rail-events-slot');
1331    if(slot){
1332      var newBlock = railEventsBlockHtml();
1333      var temp = document.createElement('div');
1334      temp.innerHTML = newBlock;
1335      if(temp.firstElementChild){ slot.replaceWith(temp.firstElementChild); }
1336    }
1337  }).catch(function(){
1338    // If even the proxy failed, leave the placeholder as a simple link to the full What's On page
1339  });
1340}
1341function railEventsBlockHtml(){
1342  var events = cachedEventsList().slice(0, 4);
1343  // No events yet - branded CTA card (green DP panel with calendar icon)
1344  if(!events.length){
1345    return '<div class="rail-block rail-events rail-events-slot">'
1346      +    '<div class="rail-eyebrow">Coming up &middot; Peterborough</div>'
1347      +    '<a class="rail-ev-fb" href="/whats-on">'
1348      +      '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2z"/></svg>'
1349      +      '<div class="fb-title">What&#39;s on this week</div>'
1350      +      '<div class="fb-tag">Concerts, festivals and family days out across Peterborough</div>'
1351      +      '<div class="fb-cta">Browse all events &rarr;</div>'
1352      +    '</a>'
1353      + '</div>';
1354  }
1355  var MONTHS_S = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1356  var DOW_S    = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
1357  var rows = events.map(function(ev){
1358    var d = new Date((ev.start_date||'').replace(' ','T'));
1359    var venue = (ev.venue && (ev.venue.venue || ev.venue.address)) || '';
1360    var url   = ev.url || ev.website || ('https://discoverpeterborough.co.uk' + (ev.slug ? '/event/'+ev.slug+'/' : '/events/'));
1361    var title = stripHtml(ev.title || '');
1362    var thumb = '';
1363    try{
1364      thumb = (ev.image && ((ev.image.sizes && ((ev.image.sizes.thumbnail && ev.image.sizes.thumbnail.url) || (ev.image.sizes.medium && ev.image.sizes.medium.url))) || ev.image.url)) || '';
1365    }catch(_){}
1366    return '<a class="rail-event" href="'+esc(url)+'" target="_blank" rel="noopener">'
1367      +    '<div class="rd">'
1368      +      '<div class="rd-mo">'+MONTHS_S[d.getMonth()]+'</div>'
1369      +      '<div class="rd-d">
1369'+d.getDate()+'</div>'
1370      +      '<div class="rd-dow">'+DOW_S[d.getDay()]+'</div>'
1371      +    '</div>'
1372      +    '<div class="rt">'
1373      +      '<div class="rt-title">'+esc(title)+'</div>'
1374      +      (venue ? '<div class="rt-venue">'+esc(venue)+'</div>' : '')
1375      +    '</div>'
1376      +    (thumb ? '<div class="re-th"><img loading="lazy" src="'+esc(thumb)+'" alt=""></div>' : '')
1377      + '</a>';
1378  }).join('');
1379  return '<div class="rail-block rail-events">'
1380    +    '<div class="rail-eyebrow">Coming up · Peterborough</div>'
1381    +    rows
1382    +    '<a class="rail-more" href="/whats-on">See all events →</a>'
1383    + '</div>';
1384}
1385function railSponsorBlockHtml(postId){
1386  var pool = (CONFIG.SPONSORS_POOL || []).filter(function(s){ return s && s.name && s.url; });
1387  if(!pool.length) return '';
1388  // Deterministic pick from post.id — same sponsor for the same article every visit (better tracking, no flicker)
1389  var idx = Math.abs(parseInt(postId, 10) || 0) % pool.length;
1390  var s = pool[idx];
1391  // Use a real <img> element (not CSS background) so we can detect failure with onerror
1392  // and hide the broken-image grey box if the URL 404s. The IMG falls inside .rs-img-wrap
1393  // so the layout matches the original CSS placeholder size.
1394  var img = s.img
1395    ? '<div class="rs-img-wrap"><img class="rs-img-tag" src="'+esc(s.img)+'" alt="" '
1396      +    'onload="this.parentNode&&this.parentNode.classList.add(\'loaded\')" '
1397      +    'onerror="this.parentNode&&(this.parentNode.style.display=\'none\')" /></div>'
1398    : '';
1399  return '<div class="rail-block rail-sponsor">'
1400    +    '<div class="rail-eyebrow" style="color:#666;border-bottom-color:#999">Sponsor</div>'
1401    +    '<a href="'+esc(s.url)+'" target="_blank" rel="noopener sponsored">'
1402    +      img
1403    +      '<div class="rs-name">'+esc(s.name)+'</div>'
1404    +      (s.tagline ? '<div class="rs-tag">'+esc(s.tagline)+'</div>' : '')
1405    +      (s.cta ? '<div class="rs-cta">'+esc(s.cta)+'</div>' : '')
1406    +    '</a>'
1407    + '</div>';
1408}
1409/* Inject up to 2 inline ad slots into article HTML.
1410 *
1411 * Strategy: walk the paragraphs counting WORDS (not paragraph count), insert
1412 * an ad after the paragraph that crosses each threshold. This keeps spacing
1413 * sensible regardless of paragraph length — short news flashes won't have ads
1414 * stacked on top of each other, and long features get one early + one mid-way.
1415 *
1416 * Thresholds:
1417 *   - First ad after ~200 words read   (catches engaged readers early)
1418 *   - Second ad after ~600 words read  (catches readers in for the long haul)
1419 *
1420 * Each ad uses a DIFFERENT data-slot key (in_article_top / in_article_mid) so
1421 * AdSense's fallback map serves a different creative to each — fixes the
1422 * "same Temu ad twice" bug.
1423 *
1424 * Skipped entirely on stories with fewer than 4 substantive paragraphs so we
1425 * don't bury short flashes in ads.
1426 */
1427function injectInArticleAds(htmlString){
1428  if(!htmlString) return htmlString;
1429  var temp = document.createElement('div');
1430  temp.innerHTML = htmlString;
1431  var paragraphs = Array.prototype.slice.call(temp.children).filter(function(el){
1432    return el.tagName === 'P' && el.textContent.trim().length > 40;  // ignore tiny <p> wrappers around images
1433  });
1434  if(paragraphs.length < 4) return htmlString;
1435
1436  var THRESHOLDS = [200, 600];                                 // word counts at which to inject
1437  var SLOT_KEYS  = ['in_article_top', 'in_article_mid'];       // unique keys -> different AdSense slot IDs
1438  var MAX_ADS    = 2;
1439
1440  var wordsSeen = 0;
1441  var inserted  = 0;
1442  var nextIdx   = 0;
1443
1444  for(var i = 0; i < paragraphs.length && inserted < MAX_ADS; i++){
1445    var pEl = paragraphs[i];
1446    wordsSeen += (pEl.textContent.trim().match(/\S+/g) || []).length;
1447    if(nextIdx < THRESHOLDS.length && wordsSeen >= THRESHOLDS[nextIdx]){
1448      var slot = document.createElement('div');
1449      slot.className = 'ad-slot ad-inline';
1450      slot.setAttribute('data-slot', SLOT_KEYS[inserted]);
1451      slot.innerHTML = 'Advertisement';
1452      if(pEl.parentNode){
1453        pEl.parentNode.insertBefore(slot, pEl.nextSibling);
1454      }
1455      inserted++;
1456      nextIdx++;
1457    }
1458  }
1459  return temp.innerHTML;
1460}
1461
1462function articleRailHtml(post){
1463  // 1) AdSense placeholder — populated by activateAdSlot in the post-render pass.
1464  //    Using a plain placeholder (instead of pre-embedding the <ins>) makes the activation
1465  //    path identical for rail + inline ads, and gives the rail a visible "Advertisement"
1466  //    eyebrow if AdSense fails to fill rather than collapsing silently.
1467  var adBlock = '<div class="ad-slot ad-inarticle ad-sidebar" data-slot="in_article">Advertisement</div>';
1468  // 2) Events card from cached DP events (always renders SOMETHING — see railEventsBlockHtml)
1469  var eventsBlock = railEventsBlockHtml();
1470  // 3) Rotating sponsor card
1471  var sponsorBlock = railSponsorBlockHtml(post.id);
1472  return '<aside class="article-rail">'
1473    + adBlock
1474    + eventsBlock
1475    + sponsorBlock
1476    + '</aside>';
1477}
1478
1479function renderPost(p, related){
1480  var img = featuredImg(p,'large');
1481  var cat = postCategory(p);
1482  var author = postAuthor(p);
1483  var title = stripHtml(p.title.rendered);
1484  // Share URL = bare slug (NOT WP's long /category/subcategory/slug/ID/ permalink).
1485  // Bots fetching /slug/ get rewritten by .htaccess to /index.php?name=slug so WP
1486  // can still serve them the post with proper og:image meta tags.
1487  var shareUrl = p.slug
1488    ? CONFIG.CN_HOST + '/' + p.slug + '/'
1489    : p.link;       // fall back to WP permalink if for some reason slug is missing
1490  var tags = [];
1491  try{
1492    var terms = p._embedded && p._embedded['wp:term'];
1493    if(terms){
1494      for(var i=0;i<terms.length;i++){
1495        for(var j=0;j<terms[i].length;j++){
1496          if(terms[i][j].taxonomy==='post_tag') tags.push(terms[i][j].name);
1497        }
1498      }
1499    }
1500  }catch(e){}
1501
1502  // If the category itself is "Exclusive", suppress the category pill so we
1503  // don't show the same red EXCLUSIVE label twice (the H1 badge handles it).
1504  var showCat = cat && cat.toLowerCase() !== 'exclusive';
1505  // Inner = the actual article body. Wrapped in .article-main so the rail can sit beside it.
1506  var inner = '<a class="back" href="#/">← Back to home</a>'
1507    + (showCat ? '<span class="cat">'+esc(cat)+'</span>' : '')
1508    + '<h1>'+postBadges(p, true)+esc(title)+'</h1>'
1509    + '<div class="byline">'
1510    +   (authorHtml(p)?'<span class="author">'+authorHtml(p)+'</span> · ':'')
1511    +   '<span>'+esc(fmtDate(p.date))+'</span>'
1512    +   (p.comment_count?' · <span>'+p.comment_count+' comments</span>':'')
1513    +   editLinkHtml(p)
1514    + '</div>';
1515  if(img) {
1516    // Read caption + alt text from the embedded featured-media object.
1517    // WordPress stores image captions on the media library item itself; they
1518    // arrive via the REST API's _embed=1 query parameter as wp:featuredmedia.
1519    var media = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0];
1520    var capRaw = (media && media.caption && media.caption.rendered) || '';
1521    var caption = stripHtml(capRaw).trim();
1522    // Some sites use the media "description" field for credit lines instead of caption
1523    if(!caption && media && media.description && media.description.rendered){
1524      caption = stripHtml(media.description.rendered).trim();
1525    }
1526    var altText = (media && media.alt_text) || title || '';
1527    // Header video (cn-story-video plugin): play it in place of the hero image.
1528    // Muted looping autoplay with the featured image as poster - captions are
1529    // burned into the video so sound-off is the intended experience.
1530    var heroVideo = (p.meta && p.meta._cn_header_video) ? p.meta._cn_header_video : '';
1531    inner += '<figure class="hero-img">'
1532         +    (heroVideo
1533                ? '<video src="'+esc(heroVideo)+'" poster="'+img+'" muted autoplay loop playsinline preload="metadata" style="width:100%;display:block;background:#000"></video>'
1534                : '<img src="'+img+'" alt="'+esc(altText)+'">')
1535         +    (caption ? '<figcaption>'+esc(caption)+'</figcaption>' : '')
1536         + '</figure>';
1537  }
1538  inner += shareBarHtml(title, shareUrl, 'top');
1539  // Inject up to 2 inline ad slots into the article body — every 2 paragraphs, max 2 ads.
1540  // Slots are <div class="ad-slot ad-inline" data-slot="in_article_N"> placeholders;
1541  // they're swapped for the real AdSense <ins> tag in the post-render pass below.
1542  inner += '<div class="prose">'+ injectInArticleAds(p.content && p.content.rendered || '') +'</div>';
1543  inner += shareBarHtml(title, shareUrl, 'bottom');
1544  if(tags.length){
1545    inner += '<div class="tags">'+tags.map(function(t){ return '<a href="#/tag/'+encodeURIComponent(t.toLowerCase().replace(/\s+/g,'-'))+'">#'+esc(t)+'</a>'; }).join('')+'</div>';
1546  }
1547
1548  if(related && related.length){
1549    inner += '<h3 class="related-head">Related Stories</h3>'
1550      + '<div class="news-grid">'
1551      + related.slice(0,4).map(function(rp){
1552          var ri = featuredImg(rp) || '';
1553          return '<article class="story" data-id="'+postRef(rp)+'">'
1554            + '<div class="img">'+(ri?'<img loading="lazy" src="'+ri+'" alt="">':'')+'</div>'
1555            + catHtml(rp)
1556            + '<h3>'+postBadges(rp)+esc(stripHtml(rp.title.rendered))+'</h3>'
1557            + '<div class="meta">'+esc(fmtDate(rp.date))+'</div>'
1558            + '</article>';
1559        }).join('')
1560      + '</div>';
1561  }
1562
1563  // Compose final HTML with main column + sticky right rail
1564  var html = '<div class="article-main">' + inner + '</div>' + articleRailHtml(p);
1565
1566  var view = $('#post-view');
1567  view.classList.add('has-rail');                           // enables the 2-col grid layout
1568  view.innerHTML = html;
1569
1570  // Reusable helper: take an ad-slot div, drop the AdSense <ins> tag into it,
1571  // and re-execute any nested <script> so the push() actually fires
1572  // (innerHTML doesn't execute inline <script>).
1573  function activateAdSlot(slot, slotKey){
1574    if(!slot) return;
1575    var code = adsenseInsHtml(slotKey);
1576    if(!code){
1577      slot.style.display = 'none';
1578      return;
1579    }
1580    // AdSense crashes with "No slot size for availableWidth=0" if we push the
1581    // <ins> tag before the parent has actually laid out. Wait for the slot to
1582    // have a real width — poll every 80ms up to 2s, give up after that.
1583    var tries = 0;
1584    function tryActivate(){
1585      if (slot.offsetWidth >= 80) {
1586        slot.innerHTML = code;
1587        Array.prototype.forEach.call(slot.querySelectorAll('script'), function(oldS){
1588          var newS = document.createElement('script');
1589          for(var i=0;i<oldS.attributes.length;i++) newS.setAttribute(oldS.attributes[i].name, oldS.attributes[i].value);
1590          newS.text = oldS.textContent;
1591          oldS.parentNode.replaceChild(newS, oldS);
1592        });
1593        slot.classList.add('loaded');
1594        return;
1595      }
1596      if (++tries < 25) {     // up to ~2s of waiting
1597        setTimeout(tryActivate, 80);
1598      }
1599    }
1600    tryActivate();
1601  }
1602
1603  // DEFER all AdSense activation + event prefetch to AFTER the article paints.
1604  // Each AdSense push triggers Google's ad script (sync DOM + layout work) and
1605  // collectively they were adding ~200-400ms of perceived delay between the
1606  // article skeleton swap and the article being visible. Running them via
1607  // requestAnimationFrame means the browser paints the article first, then
1608  // does the ad work on the next frame — the article feels instant again,
1609  // and ads still fill within a beat.
1610  var deferAdSetup = function(){
1611    // 1) Sticky rail AdSense slot
1612    var railSlot = view.querySelector('.ad-slot[data-slot="in_article"]');
1613    if(railSlot) activateAdSlot(railSlot, 'in_article');
1614
1615    // 2) Inline in-article ad slots — each has its own data-slot key so AdSense
1616    //    serves a different creative to each (fixes the "same Temu ad twice" bug).
1617    Array.prototype.forEach.call(view.querySelectorAll('.ad-slot.ad-inline[data-slot^="in_article_"]'), function(slot){
1618      activateAdSlot(slot, slot.getAttribute('data-slot'));
1619    });
1620  };
1621  if(window.requestAnimationFrame){
1622    requestAnimationFrame(function(){ requestAnimationFrame(deferAdSetup); });
1623  } else {
1624    setTimeout(deferAdSetup, 0);
1625  }
1626  // Defer DP events fetch to idle time — don't compete with the first-paint critical path.
1627  if(window.requestIdleCallback){
1628    requestIdleCallback(function(){ prefetchEventsForRail(); }, {timeout: 2000});
1629  } else {
1630    setTimeout(prefetchEventsForRail, 200);
1631  }
1632  // Swap any WP lazy-load placeholders back to real image URLs
1633  activateLazyImages(view);
1634  view.onclick = function(e){
1635    // Share-bar "more" button: native share sheet on mobile, copy link on desktop
1636    var moreBtn = e.target.closest('.sb-more');
1637    if(moreBtn){
1638      e.preventDefault();
1639      var sUrl = moreBtn.getAttribute('data-share-url');
1640      var sTitle = moreBtn.getAttribute('data-share-title');
1641      if(navigator.share){
1642        navigator.share({title: sTitle, url: sUrl}).catch(function(){});
1643      } else if(navigator.clipboard && navigator.clipboard.writeText){
1644        navigator.clipboard.writeText(sUrl).then(function(){ toast('Link copied to clipboard'); });
1645      } else {
1646        try{ window.prompt('Copy this link:', sUrl); }catch(_){}
1647      }
1648      return;
1649    }
1650    var c = e.target.closest('.story[data-id]');
1651    if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); }
1652  };
1653  document.title = stripHtml(p.title.rendered) + ' — CambsNews';
1654  // Record the view for Independent Analytics + Most Read, and ping GA4
1655  recordPostView(p.id);
1656  gaPageView();
1657  // Update Open Graph + Twitter Card meta tags for modern social crawlers.
1658  // (Most crawlers prefer pre-rendered tags but Twitter/X, LinkedIn, Discord etc.
1659  // do execute JS and re-scrape. WordPress serves the same tags pre-rendered for
1660  // crawlers that don't — see the .htaccess bot-detection rule.)
1661  updateSocialMeta({
1662    title:       stripHtml(p.title.rendered),
1663    description: stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0, 220),
1664    image:       featuredImg(p, 'large') || '',
1665    url:         location.href,
1666    type:        'article'
1667  });
1668  window.scrollTo({top:0, behavior:'instant'});
1669}
1670
1671function loadPost(idOrSlug){
1672  $('#home-view').classList.add('hidden');
1673  $('#post-view').classList.remove('hidden');
1674  $('#post-view').classList.remove('is-archive');
1675  $('#post-view').classList.remove('is-page');
1676  $('#post-view').classList.remove('has-rail');  // cleared until renderPost re-adds it
1677  $('#post-view').innerHTML = '<a class="back" href="/">← Back to home</a>'
1678    + '<div class="sk" style="height:30px;width:120px;margin-bottom:10px"></div>'
1679    + '<div class="sk" style="height:48px;margin-bottom:12px"></div>'
1680    + '<div class="sk" style="height:18px;width:50%;margin-bottom:20px"></div>'
1681    + '<div class="sk" style="height:340px;margin-bottom:20px"></div>'
1682    + '<div class="sk" style="height:20px;margin-bottom:8px"></div>'
1683    + '<div class="sk" style="height:20px;margin-bottom:8px"></div>'
1684    + '<div class="sk" style="height:20px;margin-bottom:8px;width:80%"></div>';
1685
1686  var cacheKey = 'cn-post-'+idOrSlug;
1687  var cached = cacheGetStale(cacheKey);
1688
1689  // If it's all digits it's an old numeric ID URL — keep working for backward compat.
1690  // Otherwise treat as a slug and use ?slug= which returns an array.
1691  var isNumeric = /^\d+$/.test(idOrSlug);
1692  var postUrl = isNumeric
1693    ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts/' + encodeURIComponent(idOrSlug) + '?_embed=1'
1694    : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?slug=' + encodeURIComponent(idOrSlug) + '&_embed=1';
1695
1696  function loadRelatedAndRender(post){
1697    var catId = null;
1698    try{ catId = post.categories && post.categories[0]; }catch(e){}
1699    var rUrl = catId
1700      ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts?categories='+catId+'&per_page=5&exclude='+post.id+'&_embed=1'
1701      : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=5&exclude='+post.id+'&_embed=1';
1702    smartFetch(rUrl).then(function(rel){ renderPost(post, rel); })
1703      .catch(function(){ renderPost(post, []); });
1704  }
1705
1706  if(cached){ loadRelatedAndRender(cached); return; }
1707
1708  smartFetch(postUrl).then(function(result){
1709    // ?slug= returns an array; /posts/:id returns a single object
1710    var post = Array.isArray(result) ? result[0] : result;
1711    if(post && post.id){
1712      cacheSet(cacheKey, post);
1713      // Also cache under the slug for next time someone hits it that way
1714      if(post.slug && post.slug !== idOrSlug) cacheSet('cn-post-'+post.slug, post);
1715      loadRelatedAndRender(post);
1716      return;
1717    }
1718    // Posts API returned nothing — slug is probably a Page (Contact, Support Us, About,
1719    // Privacy, etc.), not an Article. Delegate to loadPage which queries /wp/v2/pages.
1720    if(!isNumeric){
1721      loadPage(idOrSlug);
1722      return;
1723    }
1724    throw new Error('Story not found');
1725  }).catch(function(err){
1726    // Last-resort fallback: try pages even after a fetch error (in case smartFetch threw on 404).
1727    if(!isNumeric){
1728      try { loadPage(idOrSlug); return; } catch(_){}
1729    }
1730    $('#post-view').innerHTML = '<a class="back" href="/">← Back to home</a>'
1731      + '<div class="error-banner">Could not load "'+esc(idOrSlug)+'": '+esc(err.message)+'</div>';
1732  });
1733}
1734
1735/* ============================================================
1736 * Router — PATHNAME-BASED (History API)
1737 *
1738 * WordPress-style URLs handled natively:
1739 *   /                            → home
1740 *   /SLUG/                       → tries WP posts API first, falls back to pages API
1741 *   /category/SLUG/              → category archive
1742 *   /tag/SLUG/                   → tag archive
1743 *   /author/SLUG/                → author archive
1744 *   /?s=QUERY                    → search
1745 *
1746 * Backward compat: any old #/post/slug, #/cat/slug etc. style URL (from
1747 * Google's index or bookmarks) is converted to its pathname equivalent
1748 * via history.replaceState on initial load.
1749 * ============================================================ */
1750
1751/** Map a URL pathname (e.g. "/category/news/") to a route descriptor. */
1752function pathToRoute(pathname){
1753  // Strip leading/trailing slashes; treat empty path as home
1754  var p = (pathname || '/').replace(/^\/+|\/+$/g, '');
1755  if(!p) return { type:'home' };
1756
1757  // SPA file itself — if someone hits /cnh.html (or index.html / index.php) directly,
1758  // render the home view, not a 404 attempt to look up a story called "cnh.html".
1759  if(p === 'cnh.html' || p === 'index.html' || p === 'index.php') return { type:'home' };
1760
1761  // Any other .html/.php file path is also not a content slug.
1762  if(/\.(html?|php)$/i.test(p)) return { type:'home' };
1763
1764  var parts = p.split('/');
1765
1766  if(parts[0] === 'category' && parts[1]) return { type:'cat',    slug: parts[1] };
1767  if(parts[0] === 'tag'      && parts[1]) return { type:'tag',    slug: parts[1] };
1768  if(parts[0] === 'author'   && parts[1]) return { type:'author', slug: parts[1] };
1769  if(parts[0] === 'page'     && parts[1]) return { type:'home',   page: parseInt(parts[1],10) };  // /page/2/ paginated home
1770  if(parts[0] === 'whats-on' || parts[0] === 'events') return { type:'whats-on' };
1771  if(parts[0] === 'send-a-tip' || parts[0] === 'tip' || parts[0] === 'contact') return { type:'send-tip' };
1772
1773  // WordPress permalink structure: /news/%postname%/ — the canonical URL for every
1774  // CambsNews story Google has ever indexed. The slug is the last non-numeric part.
1775  // (Preview URLs look like /news/POST_ID/POST_ID/ — those won't reach here because
1776  // the .htaccess preview-skip routes them to WordPress for auth.)
1777  if(parts[0] === 'news' && parts.length >= 2){
1778    // Walk backwards from the last segment, skip any purely-numeric ID segments,
1779    // and take the first textual segment as the slug. Falls back to last segment.
1780    for(var k = parts.length - 1; k >= 1; k--){
1781      if(parts[k] && !/^\d+$/.test(parts[k])) return { type:'post-or-page', slug: parts[k] };
1782    }
1783    return { type:'post-or-page', slug: parts[parts.length - 1] };
1784  }
1785
1786  // Ignore feed/atom/etc — let WP handle those (won't reach this code anyway, .htaccess routes them around)
1787  // Anything else: treat the first segment as a post-or-page slug.
1788  return { type:'post-or-page', slug: parts[0] };
1789}
1790
1791/** Convert a legacy hash route (#/post/slug) to a clean pathname (/slug/). */
1792function hashToPath(hash){
1793  var m;
1794  if((m = hash.match(/^#\/post\/([^/?#]+)/))) return '/' + decodeURIComponent(m[1]) + '/';
1795  if((m = hash.match(/^#\/page\/([^/?#]+)/))) return '/' + decodeURIComponent(m[1]) + '/';
1796  if((m = hash.match(/^#\/cat\/([^/?#]+)/)))  return '/category/' + decodeURIComponent(m[1]) + '/';
1797  if((m = hash.match(/^#\/tag\/([^/?#]+)/)))  return '/tag/' + decodeURIComponent(m[1]) + '/';
1798  if((m = hash.match(/^#\/search\/(.+)/)))    return '/?s=' + encodeURIComponent(decodeURIComponent(m[1]));
1799  if(hash === '#/' || hash === '#') return '/';
1800  return null;
1801}
1802
1803/** SPA-style navigation: pushState + run router. */
1804function navigate(path){
1805  var here = location.pathname + location.search;
1806  if(here !== path){
1807    history.pushState({}, '', path);
1808  }
1809  router();
1810}
1811
1812/** Backward-compat helper kept under the old name so any legacy callers still work. */
1813function go(hash){
1814  var path = (hash && hash.charAt(0) === '#') ? hashToPath(hash) : hash;
1815  if(path) navigate(path);
1816  else router();
1817}
1818
1819/* Logo click → always return to the top of the home page (even if already home). */
1820document.addEventListener('click', function(e){
1821  var brand = e.target.closest && e.target.closest('.brand');
1822  if(!brand) return;
1823  e.preventDefault();
1824  e.stopPropagation();               // beat the generic SPA link handler
1825  if(location.pathname !== '/' || location.search) navigate('/');
1826  window.scrollTo({top:0, behavior:'smooth'});
1827}, true);                            // capture phase, so this runs first
1828
1829function router(){
1830  // Search via ?s=query
1831  var qs = new URLSearchParams(location.search);
1832  var s  = qs.get('s');
1833  if(s){ loadSearch(s); return; }
1834
1835  var route = pathToRoute(location.pathname);
1836
1837  if(route.type === 'cat')          { loadArchive('category', route.
1837slug); return; }
1838  if(route.type === 'tag')          { loadArchive('tag',      route.slug); return; }
1839  if(route.type === 'author')       { loadAuthor(route.slug); return; }
1840  if(route.type === 'whats-on')     { loadWhatsOn(); return; }
1841  if(route.type === 'send-tip')     { loadSendTip(); return; }
1842  if(route.type === 'post-or-page') { loadPostOrPage(route.slug); return; }
1843
1844  // Default: home
1845  $('#post-view').classList.add('hidden');
1846  $('#home-view').classList.remove('hidden');
1847  document.title = 'CambsNews — News for Peterborough and Cambridgeshire';
1848}
1849
1850/**
1851 * Single-slug URLs like /article-slug/ could be a post OR a page in WordPress.
1852 * Try posts API first (newest article fast-path), fall back to pages.
1853 */
1854function loadPostOrPage(slug){
1855  // loadPost already handles both API attempts under the hood — reuse it.
1856  // (It tries posts?slug= first; if that returns nothing it tries pages?slug=.)
1857  loadPost(slug);
1858}
1859
1860/** What's On — pulls upcoming events from Discover Peterborough's Events Calendar API. */
1861function loadWhatsOn(){
1862  $('#home-view').classList.add('hidden');
1863  $('#post-view').classList.remove('hidden');
1864  $('#post-view').classList.add('is-archive');
1865  $('#post-view').classList.remove('has-rail');
1866  var view = $('#post-view');
1867
1868  // Inject scoped styles for the events layout. Defined inline so they ship
1869  // with this view only and don't pollute the global stylesheet.
1870  var styles = ''
1871    + '<style>'
1872    + '.wo-intro{font-size:15px;color:#444;line-height:1.6;margin:8px 0 22px;max-width:780px}'
1873    + '.wo-filters{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 22px;align-items:center}'
1874    + '.wo-chip{background:#fff;border:1px solid #d0d0d0;padding:6px 14px;border-radius:999px;font-size:13px;font-weight:600;color:#1a3a40;cursor:pointer;transition:all .15s ease;font-family:Helvetica,Arial,sans-serif}'
1875    + '.wo-chip:hover{border-color:var(--discover);color:var(--discover)}'
1876    + '.wo-chip.active{background:var(--discover);color:#fff;border-color:var(--discover)}'
1877    + '.wo-reset{background:transparent;border:0;padding:6px 8px;cursor:pointer;font-size:13px;font-weight:600;color:var(--discover);display:inline-flex;align-items:center;gap:4px}'
1878    + '.wo-month{font-family:Helvetica,Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#1a3a40;padding:18px 0 8px;border-bottom:2px solid #eee;margin-top:18px}'
1879    + '.wo-month:first-of-type{margin-top:0}'
1880    + '.wo-event{display:grid;grid-template-columns:100px 260px 1fr;gap:22px;padding:22px 0;border-bottom:1px solid #eee;align-items:start}'
1881    + '.wo-date{background:var(--discover);color:#fff;border-radius:6px;padding:14px 8px;text-align:center;font-family:Helvetica,Arial,sans-serif}'
1882    + '.wo-date .day{display:block;font-size:30px;font-weight:800;line-height:1}'
1883    + '.wo-date .mon{display:block;font-size:13px;font-weight:600;margin-top:4px;letter-spacing:.04em}'
1884    + '.wo-img{width:260px;height:170px;border-radius:6px;overflow:hidden;background:#f0f0f0;display:flex;align-items:center;justify-content:center}'
1885    + '.wo-img img{width:100%;height:100%;object-fit:cover}'
1886    + '.wo-img.no-image{background:linear-gradient(135deg,#1f6f4a,#0d4a26);color:rgba(255,255,255,.5);font-family:var(--serif);font-size:42px;font-weight:900}'
1887    + '.wo-info h3{font-family:var(--serif);font-size:22px;color:#1a3a40;margin:0 0 12px;line-height:1.25}'
1888    + '.wo-meta{font-family:Helvetica,Arial,sans-serif;font-size:13px;color:#333;line-height:1.7}'
1889    + '.wo-meta .row{display:flex;gap:6px;align-items:baseline;margin-bottom:3px}'
1890    + '.wo-meta .ico{color:var(--discover);min-width:18px;font-size:14px}'
1891    + '.wo-meta strong{font-weight:700;color:#1a3a40}'
1892    + '.wo-cta{display:inline-block;background:var(--red);color:#fff;padding:9px 22px;border-radius:5px;text-decoration:none;font-weight:700;font-size:13px;margin-top:14px;font-family:Helvetica,Arial,sans-serif;transition:background .15s}'
1893    + '.wo-cta:hover{background:#8f1212;color:#fff}'
1894    + '@media(max-width:760px){'
1895    +   '.wo-event{grid-template-columns:80px 1fr;gap:14px}'
1896    +   '.wo-img{display:none}'  /* image hidden on mobile to keep rows scannable; tap title for full event */
1897    +   '.wo-date .day{font-size:24px}'
1898    +   '.wo-date .mon{font-size:11px}'
1899    +   '.wo-info h3{font-size:17px;margin-bottom:8px}'
1900    +   '.wo-meta{font-size:12px}'
1901    + '}'
1902    + '</style>';
1903
1904  view.innerHTML = styles
1905    + '<a class="back" href="/">← Back to home</a>'
1906    + '<h1 style="font-family:var(--serif);font-size:38px;margin:14px 0 4px;
1906color:#1a3a40">Peterborough Events</h1>'
1907    + '<p class="wo-intro">Peterborough\'s events calendar is packed with experiences throughout the year — from major festivals and live performances to exhibitions, family activities, heritage tours, markets and community celebrations. Listings updated live from <a href="https://discoverpeterborough.co.uk/events/" target="_blank" rel="noopener" style="color:var(--discover);font-weight:600">Discover Peterborough</a>.</p>'
1908    + '<div class="wo-filters" id="wo-filters"></div>'
1909    + '<div id="whats-on-list">'
1910    +   '<div class="sk" style="height:170px;margin-bottom:14px"></div>'
1911    +   '<div class="sk" style="height:170px;margin-bottom:14px"></div>'
1912    +   '<div class="sk" style="height:170px"></div>'
1913    + '</div>';
1914  document.title = "Peterborough Events — CambsNews";
1915  window.scrollTo({top:0, behavior:'instant'});
1916
1917  // ---------- Helpers ----------
1918  function eventImg(ev){
1919    if(!ev.image) return '';
1920    if(typeof ev.image === 'string') return ev.image;
1921    if(ev.image.sizes){
1922      var s = ev.image.sizes;
1923      var pick = s.large || s.medium_large || s.medium || s.full;
1924      if(pick) return (typeof pick === 'string') ? pick : (pick.url || '');
1925    }
1926    return ev.image.url || '';
1927  }
1928  var MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
1929  function parseDate(s){ if(!s) return null; var d = new Date(s.replace(' ','T')); return isNaN(d) ? null : d; }
1930  function fmtShort(d){ return d ? (MONTHS[d.getMonth()].slice(0,3) + ' ' + d.getDate() + ', ' + d.getFullYear()) : ''; }
1931  function fmtTime(d){
1932    if(!d) return '';
1933    var h = d.getHours(), m = d.getMinutes();
1934    var ampm = h >= 12 ? 'pm' : 'am';
1935    var hh = h % 12; if(hh === 0) hh = 12;
1936    return hh + (m ? ':' + (m < 10 ? '0' : '') + m : '') + ampm;
1937  }
1938  function dateRange(ev){
1939    var s = parseDate(ev.start_date), e = parseDate(ev.end_date);
1940    if(!s) return '';
1941    if(!e || e.toDateString() === s.toDateString()) return fmtShort(s);
1942    return fmtShort(s) + ' – ' + fmtShort(e);
1943  }
1944  function timeText(ev){
1945    if(ev.all_day) return 'All Day Event';
1946    var s = parseDate(ev.start_date), e = parseDate(ev.end_date);
1947    if(!s) return '';
1948    if(!e || e.getTime() === s.getTime()) return fmtTime(s);
1949    return fmtTime(s) + ' – ' + fmtTime(e);
1950  }
1951  function categoryNames(ev){
1952    if(!ev.categories || !ev.categories.length) return [];
1953    return ev.categories.map(function(c){ return c.name || ''; }).filter(Boolean);
1954  }
1955  function organiserName(ev){
1956    if(ev.organizer && ev.organizer.length) return ev.organizer.map(function(o){ return o.organizer || ''; }).filter(Boolean).join(', ');
1957    if(ev.organiser && ev.organiser.length) return ev.organiser.map(function(o){ return o.organiser || o.organizer || ''; }).filter(Boolean).join(', ');
1958    return '';
1959  }
1960
1961  // ---------- Cache + fetch ----------
1962  var cacheKey = 'cn-whats-on-v2';
1963  var cached = (typeof cacheGetStale === 'function') ? cacheGetStale(cacheKey) : null;
1964  var activeCategory = null;
1965  var lastEvents = [];
1966
1967  if(cached && cached.events){ lastEvents = cached.events; render(); }
1968
1969  fetch(CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=100')
1970    .then(function(r){ return r.json(); })
1971    .then(function(data){
1972      lastEvents = (data && data.events) || [];
1973      try { localStorage.setItem(cacheKey, JSON.stringify({t:Date.now(), v:{events:lastEvents}})); } catch(_){}
1974      render();
1975    })
1976    .catch(function(){
1977      if(!lastEvents.length) $('#whats-on-list').innerHTML = '<div class="empty">Could not reach Discover Peterborough\'s events feed. Try refreshing in a moment.</div>';
1978    });
1979
1980  // ---------- Render ----------
1981  function render(){
1982    var box = $('#whats-on-list');
1983    var filtersBox = $('#wo-filters');
1984    if(!box) return;
1985    if(!lastEvents.length){
1986      box.innerHTML = '<div class="empty">No upcoming events listed at the moment.</div>';
1987      return;
1988    }
1989
1990    // Build category filter chips from the actual data
1991    var allCats = {};
1992    lastEvents.forEach(function(ev){
1993      categoryNames(ev).forEach(function(c){ allCats[c] = (allCats[c] || 0) + 1; });
1994    });
1995    var catList = Object.keys(allCats).sort();
1996    filtersBox.innerHTML = catList.map(function(c){
1997      var on = c === activeCategory ? ' active' : '';
1998      return '<button type="button" class="wo-chip'+on+'" data-cat="'+esc(c)+'">'+esc(c)+'</button>';
1999    }).join('') + (activeCategory ? '<button type="button" class="wo-reset" id="wo-reset">
1999↻ Reset</button>' : '');
2000
2001    // Wire chip clicks (once — relying on innerHTML re-render to refresh)
2002    Array.prototype.forEach.call(filtersBox.querySelectorAll('.wo-chip'), function(btn){
2003      btn.addEventListener('click', function(){
2004        var c = btn.getAttribute('data-cat');
2005        activeCategory = (activeCategory === c) ? null : c;
2006        render();
2007      });
2008    });
2009    var resetBtn = filtersBox.querySelector('#wo-reset');
2010    if(resetBtn) resetBtn.addEventListener('click', function(){ activeCategory = null; render(); });
2011
2012    // Apply filter
2013    var events = activeCategory
2014      ? lastEvents.filter(function(ev){ return categoryNames(ev).indexOf(activeCategory) !== -1; })
2015      : lastEvents.slice();
2016
2017    // Sort by start date ascending
2018    events.sort(function(a,b){
2019      var da = parseDate(a.start_date), db = parseDate(b.start_date);
2020      return (da ? da.getTime() : 0) - (db ? db.getTime() : 0);
2021    });
2022
2023    if(!events.length){
2024      box.innerHTML = '<div class="empty">No events found in this category.</div>';
2025      return;
2026    }
2027
2028    // Group by month-year of start date
2029    var groups = [];
2030    var currentMonthKey = null;
2031    events.forEach(function(ev){
2032      var d = parseDate(ev.start_date) || new Date();
2033      var key = d.getFullYear() + '-' + d.getMonth();
2034      if(key !== currentMonthKey){
2035        groups.push({ label: MONTHS[d.getMonth()] + ' ' + d.getFullYear(), items: [] });
2036        currentMonthKey = key;
2037      }
2038      groups[groups.length - 1].items.push(ev);
2039    });
2040
2041    box.innerHTML = groups.map(function(g){
2042      return '<div class="wo-month">' + esc(g.label) + '</div>' +
2043        g.items.map(renderEvent).join('');
2044    }).join('')
2045    + '<p style="text-align:center;margin:36px 0 20px;color:#666;font-size:13px">For the full events listing visit <a href="https://discoverpeterborough.co.uk/events/" target="_blank" rel="noopener" style="color:var(--discover);font-weight:600">Discover Peterborough →</a></p>';
2046  }
2047
2048  function renderEvent(ev){
2049    var sd     = parseDate(ev.start_date);
2050    var day    = sd ? sd.getDate() : '';
2051    var mon    = sd ? MONTHS[sd.getMonth()] : '';
2052    var img    = eventImg(ev);
2053    var url    = ev.url || ev.website || '#';
2054    var venue  = (ev.venue && ev.venue.venue) ? ev.venue.venue : '';
2055    var org    = organiserName(ev);
2056    var cats   = categoryNames(ev).join(', ');
2057    var dRange = dateRange(ev);
2058    var tText  = timeText(ev);
2059
2060    var imgEl = img
2061      ? '<div class="wo-img"><img loading="lazy" src="'+esc(img)+'" alt=""></div>'
2062      : '<div class="wo-img no-image">DP</div>';
2063
2064    return ''
2065      + '<article class="wo-event">'
2066      +   '<div class="wo-date"><span class="day">'+esc(String(day))+'</span><span class="mon">'+esc(mon)+'</span></div>'
2067      +   imgEl
2068      +   '<div class="wo-info">'
2069      +     '<h3>'+esc(stripHtml(ev.title || ''))+'</h3>'
2070      +     '<div class="wo-meta">'
2071      +       (dRange ? '<div class="row"><span class="ico">📅</span><span><strong>Date</strong> '+esc(dRange)+'</span></div>' : '')
2072      +       (tText  ? '<div class="row"><span class="ico">🕐</span><span><strong>Time</strong> '+esc(tText)+'</span></div>'  : '')
2073      +       (venue  ? '<div class="row"><span class="ico">📍</span><span><strong>Venue</strong> '+esc(venue)+'</span></div>' : '')
2074      +       (org    ? '<div class="row"><span class="ico">👤</span><span><strong>Organiser</strong> '+esc(org)+'</span></div>' : '')
2075      +       (cats   ? '<div class="row"><span class="ico">🏷️</span><span><strong>Category</strong> '+esc(cats)+'</span></div>' : '')
2076      +     '</div>'
2077      +     '<a class="wo-cta" href="'+esc(url)+'" target="_blank" rel="noopener">More Info</a>'
2078      +   '</div>'
2079      + '</article>';
2080  }
2081}
2082
2083/** Send a Tip — in-design contact form. Replaces the broken /contact/ page.
2084 *  POSTs to /wp-json/cn/v1/tip (provided by cn-tip.php mu-plugin) which emails
2085 *  [email protected] and archives the tip as a CPT for admin reference. */
2086function loadSendTip(){
2087  $('#home-view').classList.add('hidden');
2088  $('#post-view').classList.remove('hidden');
2089  $('#post-view').classList.add('is-page');
2090  $('#post-view').classList.remove('is-archive');
2091  $('#post-view').classList.remove('has-rail');
2092  document.title = "Send us a story — CambsNews";
2093  window.scrollTo({top:0, behavior:'instant'});
2094
2095  var view = $('#post-view');
2096  view.innerHTML = ''
2097    + '<style>'
2098    +   '.tip-wrap{max-width:680px;margin:0 auto}'
2099    +   '.tip-intro{font-size:16px;line-height:1.6;color:#333;margin:10px 0 26px}'
2100    +   '.tip-form{background:#fff;border:1px solid #e6e6e6;border-radius:8px;padding:26px;box-shadow:0 1px 4px rgba(0,0,0,.03)}'
2101    +   '.tip-row{display:block;margin-bottom:18px}'
2102    +   '.tip-row label{display:block;font-weight:700;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:#1a1a1a;margin-bottom:6px}'
2103    +   '.tip-row label .opt{font-weight:400;text-transform:none;letter-spacing:0;color:#888;font-size:12px;margin-left:6px}'
2104    +   '.tip-row input,.tip-row textarea{width:100%;padding:11px 14px;border:1px solid #d0d0d0;border-radius:4px;font-size:15px;font-family:inherit;color:#1a1a1a;background:#fafafa;box-sizing:border-box;transition:border-color .15s,background .15s}'
2105    +   '.tip-row input:focus,.tip-row textarea:focus{outline:0;border-color:var(--red);background:#fff}'
2106    +   '.tip-row textarea{min-height:140px;resize:vertical;line-height:1.5}'
2107    +   '.tip-row .hint{font-size:12px;color:#888;margin-top:4px}'
2108    +   '.tip-honeypot{position:absolute;
2108left:-10000px;width:1px;height:1px;overflow:hidden}'  /* hidden honey-pot */
2109    +   '.tip-submit{display:inline-flex;align-items:center;gap:8px;background:var(--red);color:#fff;border:0;padding:13px 28px;border-radius:5px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit;letter-spacing:.02em;transition:background .15s}'
2110    +   '.tip-submit:hover{background:#8f1212}'
2111    +   '.tip-submit:disabled{background:#888;cursor:not-allowed}'
2112    +   '.tip-msg{margin-top:16px;padding:12px 14px;border-radius:4px;font-size:14px;line-height:1.5}'
2113    +   '.tip-msg.ok{background:#e8f5ee;color:#0d4a26;border:1px solid #1f6f4a}'
2114    +   '.tip-msg.err{background:#fdecec;color:#c62828;border:1px solid #c62828}'
2115    +   '.tip-side{background:#f7f7f7;border-left:4px solid var(--red);padding:14px 18px;border-radius:4px;margin-top:30px;font-size:14px;line-height:1.55;color:#333}'
2116    +   '.tip-side strong{display:block;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--red);margin-bottom:4px}'
2117    + '</style>'
2118    + '<a class="back" href="/">← Back to home</a>'
2119    + '<div class="tip-wrap">'
2120    +   '<h1 class="page-title" style="margin:14px 0 8px">Send us a story</h1>'
2121    +   '<p class="tip-intro">Got something we should know about? A news tip, a community story, a council document, a photo from the scene? Send it here. We read every message. We treat sources in confidence by default — if you want to talk off the record, just say so.</p>'
2122    +   '<form class="tip-form" id="cn-tip-form" autocomplete="on" novalidate>'
2123    +     '<div class="tip-row"><label for="tip-name">Your name</label>'
2124    +       '<input id="tip-name" name="name" type="text" required autocomplete="name" placeholder="So we know who we\'re talking to"></div>'
2125    +     '<div class="tip-row"><label for="tip-email">Email address</label>'
2126    +       '<input id="tip-email" name="email" type="email" required autocomplete="email" placeholder="[email protected]">'
2127    +       '<div class="hint">We\'ll reply to this address. Never shared.</div></div>'
2128    +     '<div class="tip-row"><label for="tip-phone">Phone <span class="opt">(optional)</span></label>'
2129    +       '<input id="tip-phone" name="phone" type="tel" autocomplete="tel" placeholder="If we can call you to check details"></div>'
2130    +     '<div class="tip-row"><label for="tip-story">Story / subject <span class="opt">(optional)</span></label>'
2131    +       '<input id="tip-story" name="story" type="text" maxlength="120" placeholder="e.g. \'Roadworks on Bourges Boulevard\'"></div>'
2132    +     '<div class="tip-row"><label for="tip-message">Your message</label>'
2133    +       '<textarea id="tip-message" name="message" required minlength="20" maxlength="8000" placeholder="Tell us what happened, when, where — and how you know about it. Anything you\'d like to keep on background, just mention it."></textarea>'
2134    +       '<div class="hint" id="tip-charcount">0 characters</div></div>'
2135    +     '<div class="tip-honeypot" aria-hidden="true"><label>Don\'t fill this in <input name="website" tabindex="-1" autocomplete="off"></label></div>'
2136    +     '<button class="tip-submit" type="submit" id="tip-submit-btn">📨 Send tip</button>'
2137    +     '<div id="tip-msg-out"></div>'
2138    +   '</form>'
2139    +   '<div class="tip-side">'
2140    +     '<strong>🔒 Confidential</strong>'
2141    +     'We protect our sources. If you want to talk strictly off-record, write that in your message and we\'ll honour it. For the most sensitive material, email us directly at <a href="mailto:[email protected]" style="color:var(--red);font-weight:700">[email protected]</a> from a personal address.'
2142    +   '</div>'
2143    + '</div>';
2144
2145  var form    = $('#cn-tip-form');
2146  var msgOut  = $('#tip-msg-out');
2147  var btn     = $('#tip-submit-btn');
2148  var msgArea = $('#tip-message');
2149  var counter = $('#tip-charcount');
2150
2151  // Character counter
2152  msgArea.addEventListener('input', function(){
2153    var n = msgArea.value.length;
2154    counter.textContent = n + ' character' + (n === 1 ? '' : 's');
2155    if(n > 7800) counter.style.color = '#c62828';
2156    else if(n < 20) counter.style.color = '#888';
2157    else counter.style.color = '#0d4a26';
2158  });
2159
2160  form.addEventListener('submit', function(e){
2161    e.preventDefault();
2162    msgOut.innerHTML = '';
2163    btn.disabled = true;
2164    btn.innerHTML = '⏳ Sending…';
2165
2166    var payload = {
2167      name:    $('#tip-name').value.trim(),
2168      email:   $('#tip-email').value.trim(),
2169      phone:   $('#tip-phone').value.trim(),
2170      story:   $('#tip-story').value.trim(),
2171      message: msgArea.value.trim(),
2172      website: form.querySelector('input[name="website"]').value   // honey-pot
2173    };
2174
2175    fetch(CONFIG.CN_HOST + '/wp-json/cn/v1/tip', {
2176      method: 'POST',
2177      headers: { 'Content-Type': 'application/json' },
2178      body: JSON.stringify(payload)
2179    }).then(function(r){
2180      return r.text().then(function(txt){
2181        var j = null;
2182        try { j = JSON.parse(txt); } catch(_) { /* response wasn't JSON */ }
2183        return { status: r.status, ok: r.ok, json: j, raw: txt };
2184      });
2185    }).then(function(res){
2186      btn.disabled = false;
2187      btn.innerHTML = '📨 Send tip';
2188
2189      // Happy path
2190      if(res.json && res.json.ok){
2191        msgOut.innerHTML = '<div class="tip-msg ok">✓ Thanks — your message has been sent to the newsroom. We\'ll be in touch.</div>';
2192        form.reset();
2193        counter.textContent = '0 characters';
2194        counter.style.color = '#888';
2195        return;
2196      }
2197
2198      // Server responded with structured error — surface it verbatim.
2199      // Look at common WP REST error shapes: {error:'…'} (our plugin),
2200      // {message:'…'} (WP rest_no_route etc.), {code:'…',message:'…'}.
2201      var serverMsg = res.json && (res.json.error || res.json.message);
2202
2203      // Endpoint missing entirely — tell the editor exactly what to do
2204      if(res.status === 404 || (res.json && res.json.code === 'rest_no_route')){
2205        msgOut.innerHTML = '<div class="tip-msg err">'
2206          + '<strong>✗ The tip endpoint isn\'t installed on WordPress.</strong><br>'
2207          + 'Upload <code>cn-tip.php</code> to <code>wp-content/mu-plugins/</code> on the server, '
2208          + 'then try again. (Must-use plugins don\'t need activation — the route comes alive as '
2209          + 'soon as the file exists.)'
2210          + '</div>';
2211        return;
2212      }
2213
2214      // Generic: show what the server said + HTTP status so we can diagnose
2215      msgOut.innerHTML = '<div class="tip-msg err">✗ '
2216        + esc(serverMsg || ('Server returned HTTP ' + res.status + (res.raw ? ' — ' + res.raw.slice(0,200) : '')))
2217        + '</div>';
2218    }).catch(function(err){
2219      btn.disabled = false;
2220      btn.innerHTML = '📨 Send tip';
2221      msgOut.innerHTML = '<div class="tip-msg err">✗ Could not reach the server: '
2222        + esc(err && err.message ? err.message : String(err))
2223        + '. Please try again, or email <a href="mailto:[email protected]">[email protected]</a> directly.</div>';
2224    });
2225  });
2226
2227  updateSocialMeta({
2228    title: 'Send us a story',
2229    description: 'Got a news tip for CambsNews? Send it here, in confidence.',
2230    type: 'website'
2231  });
2232}
2233
2234/** Author archive — fetch user by slug, then their posts. */
2235function loadAuthor(slug){
2236  $('#home-view').classList.add('hidden');
2237  $('#post-view').classList.remove('hidden');
2238  $('#post-view').classList.add('is-archive');
2239  $('#post-view').classList.remove('has-rail');
2240  var view = $('#post-view');
2241  view.innerHTML = '<a class="back" href="/">← Back to home</a><div class="sk" style="height:42px;margin-bottom:20px;width:60%"></div>';
2242  document.title = 'Author — CambsNews';
2243  fetch(CONFIG.CN_HOST + '/wp-json/wp/v2/users?slug=' + encodeURIComponent(slug))
2244    .then(function(r){ return r.json(); })
2245    .then(function(users){
2246      if(!users || !users.length){ view.innerHTML = '<a class="back" href="/">← Back to home</a><h1>Author not found</h1>'; return; }
2247      var u = users[0];
2248      document.title = u.name + ' — CambsNews';
2249      return fetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?author=' + u.id + '&per_page=20&_embed=1')
2250        .then(function(r){ return r.json(); })
2251        .then(function(posts){
2252          var html = '<a class="back" href="/">← Back to home</a>'
2253                   + '<h1 style="font-size:32px;margin:14px 0 4px">' + esc(u.name) + '</h1>'
2254                   + '<p style="color:#666;margin:0 0 24px">All stories by this author</p>';
2255          if(!posts || !posts.length){ html += '<p>No stories found.</p>'; }
2256          else { html += '<div class="grid">' + posts.map(renderResultCard).join('') + '</div>'; }
2257          view.innerHTML = html;
2258        });
2259    })
2260    .catch(function(){ view.innerHTML = '<a class="back" href="/">← Back to home</a><h1>Could not load author</h1>'; });
2261}
2262
2263/* ============================================================
2264 * Click interceptor — handles BOTH legacy #/-style hash links
2265 * AND clean pathname links in one place, so we don't have to
2266 * rewrite every link template across the page.
2267 * ============================================================ */
2268document.addEventListener('click', function(e){
2269  // Respect modifier-click (open in new tab, etc.)
2270  if(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
2271  var link = e.target.closest && e.target.closest('a');
2272  if(!link) return;
2273  var href = link.getAttribute('href');
2274  if(!href) return;
2275  // Explicit opt-outs
2276  if(link.target === '_blank' || link.hasAttribute('download') || link.getAttribute('rel') === 'external') return;
2277  // Pure on-page anchors (e.g. href="#footnote-1") — leave the browser to scroll
2278  if(href.charAt(0) === '#' && href.charAt(1) !== '/') return;
2279
2280  // Legacy hash route — convert to pathname and navigate
2281  if(href.indexOf('#/') === 0){
2282    var p = hashToPath(href);
2283    if(p){ e.preventDefault(); navigate(p); }
2284    return;
2285  }
2286
2287  // Otherwise interpret as a URL relative to this document
2288  var url;
2289  try { url = new URL(href, location.origin); } catch(_) { return; }
2290  // External — let the browser handle
2291  if(url.origin !== location.origin) return;
2292  // WordPress admin / REST / uploads / login — let the browser load WP normally
2293  if(/^\/(wp-admin|wp-includes|wp-json|wp-content|wp-login\.php|xmlrpc\.php)(\/|$)/.test(url.pathname)) return;
2294  // Direct file assets (anything with an extension other than html) — let browser handle
2295  if(/\.[a-z0-9]+$/i.test(url.pathname) && !/\.html?$/i.test(url.pathname)) return;
2296  // Intercept — SPA navigation
2297  e.preventDefault();
2298  navigate(url.pathname + url.search);
2299});
2300
2301/* ============================================================
2302 * STATIC PAGES — fetch WordPress pages (not posts) into the SPA
2303 * Used for Support Us, About, Contact, Advertise, Privacy etc.
2304 * ============================================================ */
2305function loadPage(slug){
2306  $('#home-view').classList.add('hidden');
2307  $('#post-view').classList.remove('hidden');
2308  var view = $('#post-view');
2309  view.innerHTML = '<a class="back" href="#/">← Back to home</a>'
2310    + '<div class="sk" style="height:42px;margin-bottom:20px;width:60%"></div>'
2311    + '<div class="sk" style="height:20px;margin-bottom:8px"></div>'
2312    + '<div class="sk" style="height:20px;margin-bottom:8px"></div>'
2313    + '<div class="sk" style="height:20px;margin-bottom:8px;width:80%"></div>';
2314  document.title = 'Loading… — CambsNews';
2315  window.scrollTo({top:0, behavior:'instant'});
2316
2317  var cacheKey = 'cn-page-'+slug;
2318  var cached = cacheGetStale(cacheKey);
2319  if(cached) renderPage(cached);
2320
2321  var url = CONFIG.CN_HOST + '/wp-json/wp/v2/pages?slug=' + encodeURIComponent(slug) + '&_embed=1';
2322  smartFetch(url).then(function(arr){
2323    var page = Array.isArray(arr) ? arr[0] : arr;
2324    if(!page || !page.id){ throw new Error('Page not found'); }
2325    cacheSet(cacheKey, page);
2326    renderPage(page);
2327  }).catch(function(err){
2328    if(!cached){
2329      view.innerHTML = '<a class="back" href="#/">← Back to home</a>'
2330        + '<div class="error-banner">Couldn\'t load this page: '+esc(err.message)+'</div>';
2331    }
2332  });
2333}
2334/* Re-execute scripts that arrived via innerHTML.
2335 * - External (with src): re-create, then wait for onload
2336 * - Inline: run AFTER all externals have loaded (so SDK is defined first)
2337 * Critical for embedded PayPal donate buttons, Twitter embeds, oEmbeds etc. */
2338function executeScripts(scope){
2339  if(!scope) return Promise.resolve();
2340  var oldScripts = Array.prototype.slice.call(scope.querySelectorAll('script'));
2341  var inlineScripts = [];
2342  var loadPromises = [];
2343
2344  oldScripts.forEach(function(oldS){
2345    if(oldS.src){
2346      // External — re-create with same attrs and track its load
2347      var newS = document.createElement('script');
2348      for(var i = 0; i < oldS.attributes.length; i++){
2349        newS.setAttribute(oldS.attributes[i].name, oldS.attributes[i].value);
2350      }
2351      var p = new Promise(function(resolve){
2352        newS.onload  = resolve;
2353        newS.onerror = resolve; // don't hang the chain on failed externals
2354      });
2355      loadPromises.push(p);
2356      if(oldS.parentNode) oldS.parentNode.replaceChild(newS, oldS);
2357    } else if((oldS.textContent || '').trim()){
2358      // Keep inline scripts aside to run last
2359      inlineScripts.push(oldS);
2360    }
2361  });
2362
2363  return Promise.all(loadPromises).then(function(){
2364    inlineScripts.forEach(function(oldS){
2365      if(!oldS.parentNode) return;
2366      var newS = document.createElement('script');
2367      // copy attributes if any (e.g. type="application/ld+json" — but we wouldn't execute those)
2368      var typeAttr = oldS.getAttribute('type');
2369      if(typeAttr && typeAttr.indexOf('javascript') === -1 && typeAttr !== '') return;
2370      newS.text = oldS.textContent;
2371      oldS.parentNode.replaceChild(newS, oldS);
2372    });
2373  });
2374}
2375
2376function renderPage(p){
2377  var view = $('#post-view');
2378  view.classList.add('is-page');
2379  view.classList.remove('has-rail');
2380  var title = stripHtml(p.title && p.title.rendered || '');
2381  var img = featuredImg(p, 'large');
2382  var content = (p.content && p.content.rendered) || '';
2383  var html = '<a class="back" href="#/">← Back to home</a>'
2384    + '<h1 class="page-title">'+esc(title)+'</h1>';
2385  if(img) {
2386    // Read caption + alt text from the embedded featured-media object.
2387    // WordPress stores image captions on the media library item itself; they
2388    // arrive via the REST API's _embed=1 query parameter as wp:featuredmedia.
2389    var media = p._embedded && p._embedded['wp:featuredmedia'] && p._embedded['wp:featuredmedia'][0];
2390    var capRaw = (media && media.caption && media.caption.rendered) || '';
2391    var caption = stripHtml(capRaw).trim();
2392    // Some sites use the media "description" field for credit lines instead of caption
2393    if(!caption && media && media.description && media.description.rendered){
2394      caption = stripHtml(media.description.rendered).trim();
2395    }
2396    var altText = (media && media.alt_text) || title || '';
2397    html += '<figure class="hero-img">'
2398         +    '<img src="'+img+'" alt="'+esc(altText)+'">'
2399         +    (caption ? '<figcaption>'+esc(caption)+'</figcaption>' : '')
2400         + '</figure>';
2401  }
2402  html += '<div class="prose page-prose">'+content+'</div>';
2403
2404  // Safety-net donate button on the Support page IF the WP page content doesn't already include payment markup.
2405  // CambsNews PayPal hosted_button_id: C5FFWUAUUMACA (live, verified working)
2406  if(/support/i.test(p.slug || '') && !/paypal|stripe|donate|hosted_button/i.test(content)){
2407    html += '<div id="donate-fallback" style="margin:30px 0;text-align:center">'
2408         +    '<div id="donate-button"></div>'
2409         +    '<p style="margin-top:10px;color:var(--muted);font-size:13px">Secure donation via PayPal — no PayPal account required.</p>'
2410         +  '</div>';
2411  }
2412
2413  view.innerHTML = html;
2414  activateLazyImages(view);
2415  document.title = title + ' — CambsNews';
2416
2417  // Run any embedded scripts (PayPal SDK, Twitter, etc) in the WP page content
2418  executeScripts(view).then(function(){
2419    // If the WP page didn't render the PayPal button itself but our fallback container exists,
2420    // render the official PayPal button into it using the SDK we know is now loaded
2421    var fallback = document.getElementById('donate-fallback');
2422    if(fallback && window.PayPal && window.PayPal.Donation){
2423      try{
2424        PayPal.Donation.Button({
2425          env: 'production',
2426          hosted_button_id: 'C5FFWUAUUMACA',
2427          image: {
2428            src: 'https://www.cambsnews.co.uk/wp-content/uploads/2023/11/donate-button.png',
2429            alt: 'Donate with PayPal button',
2430            title: 'PayPal — The safer, easier way to pay online'
2431          }
2432        }).render('#donate-button');
2433      }catch(_){}
2434    } else if(fallback){
2435      // SDK didn't load yet — pull it in explicitly then render
2436      var s = document.createElement('script');
2437      s.src = 'https://www.paypalobjects.com/donate/sdk/donate-sdk.js';
2438      s.onload = function(){
2439        try{
2440          PayPal.Donation.Button({
2441            env: 'production',
2442            hosted_button_id: 'T5RBXDB99ZRFE',
2443            image: {
2444              src: 'https://www.cambsnews.co.uk/wp-content/uploads/2023/11/donate-button.png',
2445              alt: 'Donate with PayPal button',
2446              title: 'PayPal — The safer, easier way to pay online'
2447            }
2448          }).render('#donate-button');
2449        }catch(_){}
2450      };
2451      document.head.appendChild(s);
2452    }
2453  });
2454}
2455// Reset .is-page and .is-archive classes when navigating between view types
2456// (post → archive → post should swap layouts properly each time).
2457window.addEventListener('popstate', function(){
2458  var view = $('#post-view');
2459  if(view){ view.classList.remove('is-page'); view.classList.remove('is-archive'); }
2460});
2461window.addEventListener('popstate', router);
2462
2463// Initial-load hash migration: if someone arrived via an old #/post/slug bookmark
2464// or Google-indexed hash URL, rewrite the URL to the clean pathname equivalent
2465// BEFORE the router runs. This is a single-shot history.replaceState — no extra
2466// network request, no flicker, and the browser bar shows the proper URL forever.
2467(function(){
2468  if(!location.hash || location.hash.indexOf('#/') !== 0) return;
2469  var newPath = hashToPath(location.hash);
2470  if(!newPath) return;
2471  try { history.replaceState({}, '', newPath); } catch(_) {}
2472})();
2473
2474/* ============================================================
2475 * SEARCH — renders WP REST search results inside the design
2476 * ============================================================ */
2477function renderResultCard(p){
2478  var img = featuredImg(p) || '';
2479  var excerpt = stripHtml(p.excerpt && p.excerpt.rendered || '').slice(0,160);
2480  return '<article class="story" data-id="'+postRef(p)+'">'
2481    + '<div class="img">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>'
2482    + catHtml(p)
2483    + '<h3>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h3>'
2484    + '<div class="meta">'+esc(fmtDate(p.date))+(authorHtml(p)?' · '+authorHtml(p):'')+'</div>'
2485    + (excerpt?'<p>'+esc(excerpt)+'…</p>':'')
2486    + '</article>';
2487}
2488/* smartFetch variant that also returns total + page count from X-WP-Total headers */
2489function fetchWithTotals(url){
2490  return fetch(url, {mode:'cors', credentials:'omit'}).then(function(r){
2491    var total = parseInt(r.headers.get('X-WP-Total') || '0', 10);
2492    var pages = parseInt(r.headers.get('X-WP-TotalPages') || '1', 10);
2493    return r.json().then(function(json){ return {data: json, total: total, pages: pages}; });
2494  }).catch(function(){
2495    // CORS proxy fallback (won't expose headers; we just return data and unknown totals)
2496    return fetch(CONFIG.CORS_PROXY + encodeURIComponent(url))
2497      .then(function(r){ return r.json(); })
2498      .then(function(json){ return {data: json, total: 0, pages: 1}; });
2499  });
2500}
2501
2502function loadSearch(q){
2503  $('#home-view').classList.add('hidden');
2504  $('#post-view').classList.remove('hidden');
2505  $('#post-view').classList.add('is-archive');
2506  $('#post-view').classList.remove('has-rail');
2507  var view = $('#post-view');
2508  view.innerHTML = '<a class="back" href="/">← Back to home</a>'
2509    + '<h1 style="font-family:var(--serif);font-size:28px;margin:8px 0 4px">Search results</h1>'
2510    + '<p style="color:var(--muted);margin-bottom:18px">Showing results for <strong>"'+esc(q)+'"</strong></p>'
2511    + '<div class="news-grid" id="search-grid">'
2512    +   '<div class="sk sk-card"></div><div class="sk sk-card"></div>'
2513    +   '<div class="sk sk-card"></div><div class="sk sk-card"></div>'
2514    + '</div>'
2515    + '<div class="load-more" id="search-more"></div>';
2516  document.title = 'Search: ' + q + ' — CambsNews';
2517  window.scrollTo({top:0, behavior:'instant'});
2518
2519  var page = 1, totalPages = 1, totalCount = 0;
2520  function load(){
2521    var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?search=' + encodeURIComponent(q) + '&per_page=12&page=' + page + '&_embed=1';
2522    return fetchWithTotals(url).then(function(res){
2523      var posts = res.data || [];
2524      var grid = $('#search-grid');
2525      if(!grid) return;
2526      if(page === 1 && !posts.length){
2527        grid.innerHTML = '<div class="error-banner" style="grid-column:1/-1">No stories found for "'+esc(q)+'". Try a different keyword.</div>';
2528        $('#search-more').innerHTML = '';
2529        return;
2530      }
2531      if(page === 1) grid.innerHTML = '';
2532      grid.insertAdjacentHTML('beforeend', posts.map(renderResultCard).join(''));
2533      totalPages = res.pages || totalPages;
2534      totalCount = res.total || (page * posts.length);
2535      renderMoreButton('search-more', page, totalPages, totalCount, function(){ page++; load(); });
2536    });
2537  }
2538  load();
2539  $('#search-grid').onclick = function(e){
2540    var c = e.target.closest('.story[data-id]');
2541    if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); }
2542  };
2543}
2544
2545/* Render or update the Load More button + count */
2546function renderMoreButton(containerId, page, totalPages, totalCount, onClick){
2547  var c = document.getElementById(containerId);
2548  if(!c) return;
2549  if(page >= totalPages){
2550    c.innerHTML = totalCount ? '<div class="count">Showing all '+totalCount+' stories</div>' : '';
2551    return;
2552  }
2553  c.innerHTML = '<div><button type="button">Load more stories</button>'
2554    + (totalCount ? '<div class="count">Page '+page+' of '+totalPages+' · '+totalCount+' stories</div>' : '')
2555    + '</div>';
2556  var btn = c.querySelector('button');
2557  btn.onclick = function(){
2558    btn.disabled = true;
2559    btn.textContent = 'Loading…';
2560    onClick();
2561  };
2562}
2563
2564/* Date */
2565document.getElementById('today').textContent =
2566  new Date().toLocaleDateString('en-GB',{weekday:'long',year:'numeric',month:'long',day:'numeric'});
2567
2568/* ============================================================
2569 * YouTube Data API
2570 * ============================================================ */
2571function renderVideos(videos){
2572  if(!videos || !videos.length){
2573    // No real videos available — show a clean "watch on YouTube" CTA instead of empty/fake content
2574    var featured = $('#yt-featured');
2575    var list = $('#yt-list');
2576    if(featured){
2577      featured.classList.remove('sk');
2578      featured.style.background = 'linear-gradient(135deg,#000,#1a1a1a)';
2579      featured.style.display = 'flex';
2580      featured.style.alignItems = 'center';
2581      featured.style.justifyContent = 'center';
2582      featured.innerHTML = '<a href="https://www.youtube.com/@CambsNewsLive" target="_blank" rel="noopener" style="color:#fff;text-decoration:none;text-align:center;padding:30px">'
2583        + '<svg viewBox="0 0 24 24" width="48" height="48" fill="#ff0000" style="margin-bottom:12px"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.546 15.568V8.432L15.818 12l-6.272 3.568z"/></svg>'
2584        + '<div style="font-family:Georgia,serif;font-size:22px;font-weight:700">Watch on YouTube</div>'
2585        + '<div style="font-size:13px;opacity:.8;margin-top:4px">@CambsNewsLive — local reporting on video</div>'
2586        + '</a>';
2587    }
2588    if(list) list.innerHTML = '';
2589    var sidebar = document.getElementById('sidebar-videos');
2590    if(sidebar){
2591      sidebar.innerHTML = '<li style="text-align:center;padding:14px 0"><a href="https://www.youtube.com/@CambsNewsLive" target="_blank" rel="noopener" style="display:inline-block;background:#ff0000;color:#fff;padding:8px 16px;border-radius:4px;font-weight:700;font-size:13px;text-decoration:none">Visit our YouTube channel →</a></li>';
2592    }
2593    return;
2594  }
2595  var feat = videos[0];
2596  $('#yt-featured').classList.remove('sk');
2597  $('#yt-featured').style.background = '#000';
2598  $('#yt-featured').setAttribute('data-yt', feat.id);
2599  $('#yt-featured').innerHTML =
2600      '<img loading="lazy" src="https://i.ytimg.com/vi/'+feat.id+'/hqdefault.jpg" alt="">'
2601    + '<div class="play" aria-hidden="true"></div>'
2602    + '<div class="title">
2602'+esc(feat.title)+'</div>';
2603  $('#yt-list').innerHTML = videos.slice(1,4).map(function(v){
2604    return '<div class="vrow" data-yt="'+v.id+'">'
2605      + '<div class="thumb"><img loading="lazy" src="https://i.ytimg.com/vi/'+v.id+'/mqdefault.jpg" alt=""></div>'
2606      + '<div><h4>'+esc(v.title)+'</h4><small>'+esc(v.date||'YouTube')+'</small></div>'
2607      + '</div>';
2608  }).join('');
2609  // Also populate the sidebar Latest Videos widget with the same data
2610  var sidebar = document.getElementById('sidebar-videos');
2611  if(sidebar){
2612    sidebar.innerHTML = videos.slice(0,4).map(function(v){
2613      return '<li data-yt="'+v.id+'">'
2614        + '<div class="thumb"><img loading="lazy" src="https://i.ytimg.com/vi/'+v.id+'/mqdefault.jpg" alt=""></div>'
2615        + '<div><h4>'+esc(v.title)+'</h4><time>'+esc(v.date||'YouTube')+'</time></div>'
2616        + '</li>';
2617    }).join('');
2618  }
2619}
2620
2621function loadYouTube(){
2622  if(!CONFIG.YT_API_KEY){ renderVideos(CONFIG.YT_FALLBACK_VIDEOS); return; }
2623
2624  var cached = cacheGetStale('yt-videos-v3');
2625  if(cached) renderVideos(cached);
2626
2627  // ----- helpers only used if no YT_PLAYLIST_ID is configured -----
2628  function fetchUploads(channelId){
2629    var cacheKey = 'yt-uploads-'+channelId;
2630    var pl = cacheGet(cacheKey, 7*24*3600*1000);
2631    if(pl) return Promise.resolve(pl);
2632    var u = 'https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id='+channelId+'&key='+CONFIG.YT_API_KEY;
2633    return fetch(u).then(function(r){ return r.json(); }).then(function(j){
2634      var p = j.items && j.items[0] && j.items[0].contentDetails && j.items[0].contentDetails.relatedPlaylists && j.items[0].contentDetails.relatedPlaylists.uploads;
2635      if(p) cacheSet(cacheKey, p);
2636      return p;
2637    });
2638  }
2639  function resolveChannelId(){
2640    if(CONFIG.YT_CHANNEL_ID) return Promise.resolve(CONFIG.YT_CHANNEL_ID);
2641    var cKey = 'yt-cid-'+CONFIG.YT_HANDLE;
2642    var cid = cacheGet(cKey, 30*24*3600*1000);
2643    if(cid) return Promise.resolve(cid);
2644    var handle = CONFIG.YT_HANDLE.replace(/^@/,'');
2645    var u = 'https://www.googleapis.com/youtube/v3/channels?part=id&forHandle='+encodeURIComponent('@'+handle)+'&key='+CONFIG.YT_API_KEY;
2646    return fetch(u).then(function(r){ return r.json(); }).then(function(j){
2647      var id = j.items && j.items[0] && j.items[0].id;
2648      if(id) cacheSet(cKey, id);
2649      return id;
2650    });
2651  }
2652  function resolvePlaylist(){
2653    if(CONFIG.YT_PLAYLIST_ID) return Promise.resolve(CONFIG.YT_PLAYLIST_ID);
2654    return resolveChannelId().then(function(cid){
2655      if(!cid) throw new Error('Channel not found');
2656      return fetchUploads(cid);
2657    });
2658  }
2659
2660  resolvePlaylist().then(function(playlist){
2661    if(!playlist) throw new Error('No playlist available');
2662    // Fetch up to 20 so we have headroom before sorting by real publish date
2663    var u = 'https://www.googleapis.com/youtube/v3/playlistItems'
2664          + '?part=snippet,contentDetails'
2665          + '&playlistId=' + encodeURIComponent(playlist)
2666          + '&maxResults=20'
2667          + '&key=' + CONFIG.YT_API_KEY;
2668    return fetch(u).then(function(r){ return r.json(); });
2669  }).then(function(j){
2670    var items = j.items || [];
2671    var vids = items.map(function(it){
2672      var s = it.snippet || {};
2673      // Prefer the real video publish date over the date added to the playlist
2674      var pub = (it.contentDetails && it.contentDetails.videoPublishedAt) || s.publishedAt;
2675      return {
2676        id:          (s.resourceId && s.resourceId.videoId) || (it.contentDetails && it.contentDetails.videoId),
2677        title:       s.title,
2678        publishedAt: pub,
2679        date:        fmtRel(pub)
2680      };
2681    }).filter(function(v){ return v.id && v.title; });
2682
2683    // Dedupe: a playlist can contain the same video more than once — keep newest occurrence only
2684    var seen = {};
2685    vids = vids
2686      .sort(function(a,b){ return new Date(b.publishedAt) - new Date(a.publishedAt); })
2687      .filter(function(v){ if(seen[v.id]) return false; seen[v.id] = true; return true; })
2688      .slice(0,4);
2689
2690    if(vids.length){ renderVideos(vids); cacheSet('yt-videos-v3', vids); }
2691    else if(!cached) renderVideos(CONFIG.YT_FALLBACK_VIDEOS);
2692  }).catch(function(){
2693    if(!cached) renderVideos(CONFIG.YT_FALLBACK_VIDEOS);
2694  });
2695}
2696
2697/* YouTube facade -> real iframe on click */
2698document.addEventListener('click', function(e){
2699  var el = e.target.closest('[data-yt]');
2700  if(!el) return;
2701  var id = el.getAttribute('data-yt');
2702  if(!id) return;
2703  var iframe = document.createElement('iframe');
2704  iframe.src = 'https://www.youtube.com/embed/' + id + '?autoplay=1&rel=0';
2705  iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
2706  iframe.allowFullscreen = true;
2707  iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;background:#000';
2708  if(el.classList.contains('yt-facade')){
2709    el.innerHTML = ''; el.style.cursor='default'; el.appendChild(iframe);
2710  } else {
2711    var modal = document.createElement('div');
2712    modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:1000;display:grid;place-items:center;padding:20px';
2713    var frame = document.createElement('div');
2714    frame.style.cssText='position:relative;width:min(960px,100%);aspect-ratio:16/9;background:#000;border-radius:6px;overflow:hidden';
2715    frame.appendChild(iframe);
2716    modal.appendChild(frame);
2717    modal.addEventListener('click', function(){ document.body.removeChild(modal); });
2718    document.body.appendChild(modal);
2719  }
2720});
2721
2722/* ============================================================
2723 * Discover Peterborough loader
2724 * Filters posts to actual news (excludes the "Peterborough People"
2725 * biographical category) and pulls What's On events from the
2726 * Events Calendar (tribe/events/v1) API.
2727 * ============================================================ */
2728(function(){
2729  /* Resolve a DP category slug → ID, cached for a month */
2730  function getDPCategoryId(slug){
2731    var key = 'dp-catid-' + slug;
2732    var cached = cacheGet(key, 30*24*3600*1000);
2733    if(cached) return Promise.resolve(cached);
2734    return smartFetch(CONFIG.DP_HOST + '/wp-json/wp/v2/categories?slug=' + encodeURIComponent(slug))
2735      .then(function(cats){
2736        var id = cats && cats[0] && cats[0].id;
2737        if(id) cacheSet(key, id);
2738        return id || null;
2739      }).catch(function(){ return null; });
2740  }
2741  /* Build the right DP posts URL: prefer a "news" category, else exclude "peterborough-people".
2742   * Fetch extra (16) so we have headroom after filtering out posts that have no featured image. */
2743  function getDPPostsUrl(){
2744    return getDPCategoryId('news').then(function(newsId){
2745      if(newsId) return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&categories=' + newsId + '&_embed=1';
2746      return getDPCategoryId('peterborough-people').then(function(peopleId){
2747        if(peopleId) return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&categories_exclude=' + peopleId + '&_embed=1';
2748        return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&_embed=1';
2749      });
2750    }).catch(function(){
2751      return CONFIG.DP_HOST + '/wp-json/wp/v2/posts?per_page=16&_embed=1';
2752    });
2753  }
2754  var DP_EVENTS = CONFIG.DP_HOST + '/wp-json/tribe/events/v1/events?per_page=5';
2755  var rail = $('#dp-rail');
2756  var eventsBox = $('#dp-events');
2757  var statusBox = $('#dp-status');
2758
2759  function renderPosts(posts){
2760    // Drop posts without a featured image — leaving an empty image slot looks broken in the rail
2761    var withImages = (posts || []).filter(function(p){ return !!featuredImg(p); });
2762    if(!withImages.length){
2763      rail.innerHTML = '<p style="color:var(--muted);padding:14px;grid-column:1/-1">No stories available right now.</p>'; return;
2764    }
2765    rail.innerHTML = withImages.slice(0,8).map(function(p){
2766      var img = featuredImg(p);
2767      var cat = 'Discover';
2768      try{ var terms = p._embedded && p._embedded['wp:term']; if(terms && terms[0] && terms[0][0]) cat = terms[0][0].name; }catch(e){}
2769      return '<a class="dp-card" href="'+p.link+'" target="_blank" rel="noopener">'
2770        + '<div class="img"><img loading="lazy" decoding="async" src="'+img+'" alt=""></div>'
2771        + '<div class="body"><div class="cat">'+esc(cat)+'</div><h4>'+esc(stripHtml(p.title.rendered))+'</h4><time>'+esc(fmtDate(p.date))+'</time></div></a>';
2772    }).join('');
2773  }
2774  function eventImage(ev){
2775    if(!ev.image) return '';
2776    if(typeof ev.image === 'string') return ev.image;
2777    if(ev.image.sizes){
2778      var s = ev.image.sizes;
2779      var pick = s.medium || s.thumbnail || s.small || s.full;
2780      if(pick) return (typeof pick === 'string') ? pick : (pick.url || '');
2781    }
2782    return ev.image.url || '';
2783  }
2784  function renderEvents(events){
2785    if(!events || !events.length){ eventsBox.innerHTML = '<p style="color:var(--muted);font-size:13px">No upcoming events listed.</p>'; return; }
2786    eventsBox.innerHTML = '<div class="events-list">' + events.slice(0,4).map(function(ev){
2787      var when = ev.start_date ? fmtDate(ev.start_date) : '';
2788      var venue = (ev.venue && ev.venue.venue) ? ev.venue.venue : '';
2789      var img = eventImage(ev);
2790      var thumb = img
2791        ? '<div class="thumb"><img loading="lazy" src="'+img+'" alt=""></div>'
2792        : '<div class="thumb">EVT</div>';
2793      return '<a class="ev" href="'+(ev.url||ev.website||'#')+'" target="_blank" rel="noopener">'
2794        + thumb
2795        + '<div><h5>'+esc(stripHtml(ev.title))+'</h5>'
2796        +   '<span class="when">'+esc(when)+'</span>'
2797        +   (venue?' <span class="venue">· '+esc(venue)+'</span>':'')
2798        + '</div></a>';
2799    }).join('') + '</div>';
2800  }
2801
2802  var c = cacheGetStale('dp-cache-v3');
2803  if(c){ if(c.posts) renderPosts(c.posts); if(c.events) renderEvents(c.events); }
2804
2805  // First resolve the correct news URL, then fetch posts + events in parallel
2806  getDPPostsUrl().then(function(postsUrl){
2807    return Promise.allSettled([smartFetch(postsUrl), smartFetch(DP_EVENTS)]);
2808  }).then(function(results){
2809    var posts = results[0].status==='fulfilled' ? results[0].value : null;
2810    var events = null;
2811    if(results[1].status==='fulfilled'){
2812      events = results[1].value && results[1].value.events ? results[1].value.events : null;
2813    }
2814    if(posts && posts.length) renderPosts(posts);
2815    else if(!c){
2816      statusBox.className = 'error-banner'; statusBox.classList.remove('hidden');
2817      statusBox.textContent = 'Live Discover Peterborough feed unavailable from this origin.';
2818      rail.innerHTML = '<p style="color:var(--muted);padding:14px;grid-column:1/-1">Discover Peterborough stories will appear here once their API is reachable.</p>';
2819    }
2820    if(events) renderEvents(events);
2821    else if(!c) renderEvents([]);
2822    if(posts || events) cacheSet('dp-cache-v3', {posts:posts, events:events});
2823  });
2824})();
2825
2826/* Search -> stays inside the SPA */
2827$('#search-input').addEventListener('keydown', function(e){
2828  if(e.key === 'Enter'){
2829    var q = e.target.value.trim();
2830    if(q){ go('#/search/' + encodeURIComponent(q)); document.querySelector('.search').classList.remove('expanded'); }
2831  }
2832});
2833/* Mobile search toggle — slides the existing input into a floating bar */
2834var searchToggleBtn = document.getElementById('search-toggle');
2835if(searchToggleBtn){
2836  searchToggleBtn.addEventListener('click', function(){
2837    var s = document.querySelector('.search');
2838    s.classList.toggle('expanded');
2839    if(s.classList.contains('expanded')){
2840      setTimeout(function(){ $('#search-input').focus(); }, 50);
2841    }
2842  });
2843  // Close on outside click
2844  document.addEventListener('click', function(e){
2845    var s = document.querySelector('.search');
2846    if(!s.classList.contains('expanded')) return;
2847    if(s.contains(e.target) || searchToggleBtn.contains(e.target)) return;
2848    s.classList.remove('expanded');
2849  });
2850}
2851
2852/* ---------- Hover-prefetch: fetch post into cache when user hovers a card ---------- */
2853(function(){
2854  var prefetching = {};
2855  function prefetch(idOrSlug){
2856    if(!idOrSlug || prefetching[idOrSlug]) return;
2857    var key = 'cn-post-'+idOrSlug;
2858    if(cacheGet(key, 60*60*1000)) return;     // already cached
2859    prefetching[idOrSlug] = true;
2860    var isNumeric = /^\d+$/.test(idOrSlug);
2861    var url = isNumeric
2862      ? CONFIG.CN_HOST + '/wp-json/wp/v2/posts/' + encodeURIComponent(idOrSlug) + '?_embed=1'
2863      : CONFIG.CN_HOST + '/wp-json/wp/v2/posts?slug=' + encodeURIComponent(idOrSlug) + '&_embed=1';
2864    var run = function(){
2865      smartFetch(url).then(function(result){
2866        var p = Array.isArray(result) ? result[0] : result;
2867        if(p && p.id){
2868          cacheSet(key, p);
2869          if(p.slug) cacheSet('cn-post-'+p.slug, p);
2870        }
2871      }).catch(function(){});
2872    };
2873    if(window.requestIdleCallback) requestIdleCallback(run, {timeout: 1500});
2874    else setTimeout(run, 80);
2875  }
2876  document.addEventListener('mouseover', function(e){
2877    var card = e.target.closest('[data-id]');
2878    if(!card) return;
2879    var id = card.getAttribute('data-id');
2880    if(id) prefetch(id);
2881  });
2882  document.addEventListener('touchstart', function(e){
2883    var card = e.target.closest('[data-id]');
2884    if(!card) return;
2885    var id = card.getAttribute('data-id');
2886    if(id) prefetch(id);
2887  }, {passive:true});
2888})();
2889
2890/* ---------- Hamburger menu toggle ---------- */
2891(function(){
2892  var btn = document.getElementById('ham-btn');
2893  var menu = document.getElementById('mobile-menu');
2894  if(!btn || !menu) return;
2895  function setOpen(open){
2896    btn.classList.toggle('open', open);
2897    menu.classList.toggle('open', open);
2898    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
2899    menu.setAttribute('aria-hidden', open ? 'false' : 'true');
2900    document.body.classList.toggle('menu-open', open);
2901  }
2902  btn.addEventListener('click', function(){ setOpen(!menu.classList.contains('open')); });
2903  menu.addEventListener('click', function(e){
2904    if(e.target.tagName === 'A') setOpen(false);
2905  });
2906  // close on route change too
2907  window.addEventListener('popstate',   function(){ setOpen(false); });
2908  // Also catch SPA navigations (which use pushState, no native event) — listen on link clicks
2909  document.addEventListener('click', function(e){
2910    if(e.target && e.target.closest && e.target.closest('a')) setOpen(false);
2911  }, true);
2912  // close on Escape
2913  document.addEventListener('keydown', function(e){
2914    if(e.key === 'Escape' && menu.classList.contains('open')) setOpen(false);
2915  });
2916})();
2917
2918/* ---------- Sidebar section widgets (Crime, Politics, Business, Sport, Peterborough People) ---------- */
2919var SECTION_STRIPES = [
2920  {label:'Crime',         slug:'crime'},
2921  {label:'Politics',      slug:'politics'},
2922  {label:'Business',      slug:'business'},
2923  {label:'Your Views',    slug:'your_views'},
2924  {label:'Local Council', slug:'local-council'}
2925];
2926
2927function renderSideStoryItem(p){
2928  var img = featuredImg(p) || '';
2929  return '<li data-id="'+postRef(p)+'">'
2930    + '<div class="thumb">'+(img?'<img loading="lazy" src="'+img+'" alt="">':'')+'</div>'
2931    + '<div><h4>'+postBadges(p)+esc(stripHtml(p.title.rendered))+'</h4><time>'+esc(fmtDate(p.date))+'</time></div>'
2932    + '</li>';
2933}
2934
2935function hideSectionWidget(slug){
2936  var widget = document.querySelector('[data-section-widget="'+slug+'"]');
2937  if(widget) widget.style.display = 'none';
2938}
2939function loadSectionStripe(s){
2940  var list = document.querySelector('.side-stories[data-stripe="'+s.slug+'"]');
2941  if(!list) return;
2942  var cacheKey = 'cn-stripe-v2-' + s.slug;
2943  var cached = cacheGetStale(cacheKey);
2944  if(cached && cached.length){
2945    list.innerHTML = cached.slice(0,4).map(renderSideStoryItem).join('');
2946  }
2947  getCategoryId(s.slug).then(function(id){
2948    // Try as a TAG if it's not a category — covers CN's taxonomy quirks
2949    if(!id) return getTagId(s.slug).then(function(tid){
2950      return tid ? smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?tags=' + tid + '&per_page=4&_embed=1') : null;
2951    });
2952    return smartFetch(CONFIG.CN_HOST + '/wp-json/wp/v2/posts?categories=' + id + '&per_page=4&_embed=1');
2953  }).then(function(posts){
2954    if(posts && posts.length){
2955      cacheSet(cacheKey, posts);
2956      list.innerHTML = posts.slice(0,4).map(renderSideStoryItem).join('');
2957    } else if(!cached || !cached.length){
2958      // Nothing now and nothing cached — hide the whole widget rather than show "No posts"
2959      hideSectionWidget(s.slug);
2960    }
2961  }).catch(function(){
2962    if(!cached || !cached.length) hideSectionWidget(s.slug);
2963  });
2964}
2965
2966function loadSectionStripes(){
2967  // Click delegation — single listener catches every widget's items
2968  document.addEventListener('click', function(e){
2969    var item = e.target.closest('.side-stories li[data-id]');
2970    if(item){ e.preventDefault(); go('#/post/'+item.dataset.id); }
2971  });
2972  SECTION_STRIPES.forEach(loadSectionStripe);
2973}
2974
2975/* ---------- Trending Tags widget ---------- */
2976function loadTrendingTags(){
2977  var box = $('#trending-tags');
2978  if(!box) return;
2979  var cached = cacheGetStale('cn-trending-tags-v1');
2980  if(cached) renderTrendingTags(cached);
2981  // Most-used tags from WP — sorted by post count desc
2982  var url = CONFIG.CN_HOST + '/wp-json/wp/v2/tags?per_page=18&orderby=count&order=desc&hide_empty=true&_fields=name,slug,count';
2983  smartFetch(url).then(function(tags){
2984    if(tags && tags.length){
2985      renderTrendingTags(tags);
2986      cacheSet('cn-trending-tags-v1', tags);
2987    }
2988  }).catch(function(){});
2989}
2990function renderTrendingTags(tags){
2991  var box = $('#trending-tags');
2992  if(!box) return;
2993  box.innerHTML = tags.map(function(t){
2994    return '<a href="#/tag/'+esc(t.slug)+'">'+esc(t.name)+'<span class="count">'+t.count+'</span></a>';
2995  }).join('');
2996}
2997
2998/* ---------- Newsletter form — POSTs to CambsNews mu-plugin ---------- */
2999(function(){
3000  var f = document.getElementById('newsletter-form');
3001  if(!f) return;
3002  f.addEventListener('submit', function(e){
3003    e.preventDefault();
3004    var emailInput = document.getElementById('newsletter-email');
3005    var email = emailInput.value.trim();
3006    var msg = document.getElementById('newsletter-msg');
3007    var honeypot = f.querySelector('input[name="website"]').value;
3008    if(!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){
3009      if(msg){ msg.textContent = 'Please enter a valid email address.'; msg.style.color = '#c62828'; }
3010      return;
3011    }
3012    var btn = f.querySelector('button[type="submit"]');
3013    btn.disabled = true;
3014    var originalLabel = btn.textContent;
3015    btn.textContent = 'Signing up…';
3016    if(msg){ msg.style.color = '#999'; msg.textContent = 'Submitting…'; }
3017
3018    fetch(CONFIG.CN_HOST + (CONFIG.NEWSLETTER_ENDPOINT || '/wp-json/cn/v1/subscribe'), {
3019      method: 'POST',
3020      mode: 'cors',
3021      credentials: 'omit',
3022      headers: {'Content-Type': 'application/json'},
3023      body: JSON.stringify({email: email, website: honeypot})
3024    }).then(function(r){
3025      return r.json().then(function(j){ return {ok: r.ok, body: j}; });
3026    }).then(function(res){
3027      if(res.ok && res.body && res.body.ok){
3028        var already = res.body.already;
3029        f.innerHTML = '<div style="padding:14px 0;color:var(--discover);font-weight:700;font-size:14px;text-align:center">'
3030          + (already ? '✓ You\'re already on the list.' : '✓ Thanks — check your inbox to confirm.')
3031          + '</div>';
3032      } else {
3033        var err = (res.body && res.body.error) || 'Sorry — sign-up failed. Please try again.';
3034        if(msg){ msg.textContent = err; msg.style.color = '#c62828'; }
3035        btn.disabled = false; btn.textContent = originalLabel;
3036      }
3037    }).catch(function(){
3038      if(msg){ msg.textContent = 'Network error — please try again in a moment.'; msg.style.color = '#c62828'; }
3039      btn.disabled = false; btn.textContent = originalLabel;
3040    });
3041  });
3042})();
3043
3044/* ============================================================
3045 * WEATHER WIDGET (OpenWeatherMap)
3046 * Caches 30 minutes — Peterborough weather doesn't change second to second
3047 * ============================================================ */
3048function loadWeather(){
3049  var box = $('#weather-body');
3050  if(!box || !CONFIG.WEATHER_API_KEY) return;
3051  var loc = encodeURIComponent(CONFIG.WEATHER_LOCATION || 'Peterborough,GB');
3052  var cached = cacheGetStale('cn-weather-v2');
3053  if(cached) renderWeather(cached);
3054  Promise.all([
3055    fetch('https://api.openweathermap.org/data/2.5/weather?q='+loc+'&units=metric&appid='+CONFIG.WEATHER_API_KEY).then(function(r){return r.json();}),
3056    fetch('https://api.openweathermap.org/data/2.5/forecast?q='+loc+'&units=metric&appid='+CONFIG.WEATHER_API_KEY).then(function(r){return r.json();})
3057  ]).then(function(res){
3058    var now = res[0], fc = res[1];
3059    if(!now || !now.main) return;
3060    // Aggregate forecast intervals by day to derive hi/lo + a midday icon/desc
3061    var byDay = {};
3062    (fc.list || []).forEach(function(it){
3063      var d = new Date(it.dt * 1000);
3064      var key = d.toISOString().slice(0,10);
3065      if(!byDay[key]){
3066        byDay[key] = {day: d, items: [], midday: null, middayScore: 99, hi: -Infinity, lo: Infinity};
3067      }
3068      byDay[key].items.push(it);
3069      if(typeof it.main.temp_max === 'number') byDay[key].hi = Math.max(byDay[key].hi, it.main.temp_max);
3070      if(typeof it.main.temp_min === 'number') byDay[key].lo = Math.min(byDay[key].lo, it.main.temp_min);
3071      // pick the interval closest to 1pm for the icon/desc
3072      var hourScore = Math.abs(d.getHours() - 13);
3073      if(hourScore < byDay[key].middayScore){
3074        byDay[key].midday = it;
3075        byDay[key].middayScore = hourScore;
3076      }
3077    });
3078    var todayKey = new Date().toISOString().slice(0,10);
3079    var days = Object.keys(byDay)
3080      .map(function(k){ return byDay[k]; })
3081      .sort(function(a,b){ return a.day - b.day; })
3082      .filter(function(d){ return d.day.toISOString().slice(0,10) !== todayKey; })
3083      .slice(0, 3)
3084      .map(function(d){
3085        var m = d.midday || d.items[0];
3086        return {
3087          label: d.day.toLocaleDateString('en-GB',{weekday:'short'}),
3088          icon:  m.weather && m.weather[0] && m.weather[0].icon,
3089          desc:  (m.weather && m.weather[0] && m.weather[0].main) || '',
3090          hi:    Math.round(d.hi),
3091          lo:    Math.round(d.lo)
3092        };
3093      });
3094    var payload = {
3095      now: {
3096        temp: Math.round(now.main.temp),
3097        feels: Math.round(now.main.feels_like),
3098        desc: (now.weather && now.weather[0] && now.weather[0].description) || '',
3099        main: (now.weather && now.weather[0] && now.weather[0].main) || '',
3100        icon: (now.weather && now.weather[0] && now.weather[0].icon) || '01d',
3101        wind: Math.round((now.wind && now.wind.speed || 0) * 2.237),  // m/s → mph
3102        humidity: now.main.humidity || 0,
3103        city: now.name || 'Peterborough'
3104      },
3105      days: days
3106    };
3107    cacheSet('cn-weather-v2', payload);
3108    renderWeather(payload);
3109  }).catch(function(){ /* silent — keep cached or skeleton */ });
3110}
3111/* Map OpenWeatherMap icon code → our themed class */
3112function weatherTheme(iconCode){
3113  if(!iconCode) return 'cond-default';
3114  var n = iconCode.endsWith('n');
3115  var c = iconCode.slice(0,2);
3116  if(c === '01') return n ? 'cond-clear-night' : 'cond-clear-day';
3117  if(c === '02' || c === '03' || c === '04') return 'cond-cloud';
3118  if(c === '09' || c === '10') return 'cond-rain';
3119  if(c === '11') return 'cond-thunder';
3120  if(c === '13') return 'cond-snow';
3121  if(c === '50') return 'cond-mist';
3122  return 'cond-default';
3123}
3124/* Map OpenWeatherMap icon code → an emoji that renders natively in colour */
3125function weatherEmoji(iconCode){
3126  if(!iconCode) return '🌡️';
3127  var n = iconCode.endsWith('n');
3128  var c = iconCode.slice(0,2);
3129  if(c === '01') return n ? '🌙' : '☀️';
3130  if(c === '02') return n ? '☁️' : '⛅';
3131  if(c === '03') return '☁️';
3132  if(c === '04') return '☁️';
3133  if(c === '09') return '🌧️';
3134  if(c === '10') return n ? '🌧️' : '🌦️';
3135  if(c === '11') return '⛈️';
3136  if(c === '13') return '❄️';
3137  if(c === '50') return '🌫️';
3138  return '🌡️';
3139}
3140function renderWeather(p){
3141  var box = $('#weather-body');
3142  if(!box || !p || !p.now) return;
3143  var theme = weatherTheme(p.now.icon);
3144  var nowHtml = ''
3145    + '<div class="weather-now '+theme+'">'
3146    +   '<div class="icon" aria-label="'+esc(p.now.desc)+'">'+weatherEmoji(p.now.icon)+'</div>'
3147    +   '<div class="temp-block">'
3148    +     '<div class="temp">'+p.now.temp+'<sup>°C</sup></div>'
3149    +     '<div class="desc">'+esc(p.now.desc)+'</div>'
3150    +     '<div class="meta">Feels like '+p.now.feels+'° · '+p.now.wind+' mph · '+p.now.humidity+'% humidity</div>'
3151    +   '</div>'
3152    + '</div>';
3153  var fcHtml = '';
3154  if(p.days && p.days.length){
3155    fcHtml = '<div class="weather-forecast">'
3156      + p.days.map(function(d){
3157          return '<div>'
3158            +   '<div class="day">'+esc(d.label)+'</div>'
3159            +   '<div class="ico" aria-label="'+esc(d.desc||'')+'">'+weatherEmoji(d.icon)+'</div>'
3160            +   '<div class="hi-lo">'+d.hi+'°<span class="lo">'+d.lo+'°</span></div>'
3161            +   '<div class="desc">'+esc(d.desc||'')+'</div>'
3162            + '</div>';
3163        }).join('')
3164      + '</div>';
3165  }
3166  box.innerHTML = nowHtml + fcHtml;
3167}
3168
3169/* ============================================================
3170 * LOCAL ADS — fetch from cambsnews WP plugin, rotate per pageview
3171 * Falls back to inline CONFIG ads if no active local ads for the slot.
3172 * ============================================================ */
3173function pickWeightedAd(ads){
3174  if(!ads.length) return null;
3175  var total = 0;
3176  ads.forEach(function(a){ total += Math.max(1, a.weight||5); });
3177  var r = Math.random() * total;
3178  for(var i = 0; i < ads.length; i++){
3179    r -= Math.max(1, ads[i].weight||5);
3180    if(r <= 0) return ads[i];
3181  }
3182  return ads[0];
3183}
3184function renderLocalAd(slot, ad){
3185  if(!ad || !ad.image) return false;
3186  var sponsorLine = ad.sponsor ? '<div class="local-ad-sponsor">Sponsored · '+esc(ad.sponsor)+'</div>' : '<div class="local-ad-sponsor">Sponsored</div>';
3187  var click = CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/click/' + ad.id;
3188  slot.classList.add('loaded');
3189  slot.innerHTML = '<div class="local-ad">'
3190    + '<a href="'+esc(click)+'" target="_blank" rel="noopener nofollow sponsored" data-ad-id="'+ad.id+'">'
3191    +   '<img src="'+esc(ad.image)+'" alt="'+esc(ad.name||'Advertisement')+'" loading="lazy">'
3192    + '</a>'
3193    + sponsorLine
3194    + '</div>';
3195  return true;
3196}
3197function loadLocalAdsForSlot(slot, fallbackToInline){
3198  var slotName = slot.dataset.slot;
3199  if(!CONFIG.LOCAL_ADS_ENDPOINT){ fallbackToInline(); return; }
3200  var url = CONFIG.CN_HOST + CONFIG.LOCAL_ADS_ENDPOINT + '?slot=' + encodeURIComponent(slotName);
3201  smartFetch(url).then(function(ads){
3202    if(ads && ads.length){
3203      var chosen = pickWeightedAd(ads);
3204      if(renderLocalAd(slot, chosen)){
3205        // Fire-and-forget impression tracking
3206        try{
3207          navigator.sendBeacon
3208            ? navigator.sendBeacon(CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/impression', JSON.stringify({ids:[chosen.id]}))
3209            : fetch(CONFIG.CN_HOST + CONFIG.LOCAL_ADS_TRACK + '/impression', {method:'POST', body:JSON.stringify({ids:[chosen.id]}), keepalive:true});
3210        }catch(_){}
3211        return;
3212      }
3213    }
3214    // No local ads for this slot — fall back to the inline CONFIG ad
3215    fallbackToInline();
3216  }).catch(function(){ fallbackToInline(); });
3217}
3218
3219/* ============================================================
3220 * INFINITE SCROLL on home page
3221 * Loads next batch of posts when user scrolls near the bottom sentinel.
3222 * Starts from page 2 (page 1 = the 22 posts already loaded in loadCambsNews).
3223 * Caps at MAX pages so we don't loop forever on small content sets.
3224 * ============================================================ */
3225function initInfiniteScroll(){
3226  var sentinel = document.getElementById('home-sentinel');
3227  var container = document.getElementById('home-infinite');
3228  if(!sentinel || !container || !('IntersectionObserver' in window)) return;
3229
3230  var page = 1;          // loadCambsNews already covered page 1 with per_page=22
3231  var loading = false;
3232  var done = false;
3233  var MAX_INFINITE_PAGES = 8;   // hard cap — 22 + (8 × 12) = up to 118 stories on home
3234
3235  function showLoader(on){ document.getElementById('home-infinite-loading').style.display = on ? 'flex' : 'none'; }
3236  function showEnd(){ document.getElementById('home-infinite-end').style.display = 'flex'; }
3237
3238  function loadNext(){
3239    if(loading || done) return;
3240    loading = true;
3241    page++;
3242    showLoader(true);
3243    // Use offset so we never overlap with the 22 posts loadCambsNews already rendered.
3244    // Offset = 22 (initial home posts) + 12 × (pages already added to infinite scroll)
3245    var offset = 22 + Math.max(0, (page - 2)) * 12;
3246    var url = CONFIG.CN_HOST + '/wp-json/wp/v2/posts?per_page=12&offset=' + offset + '&_embed=1';
3247    fetchWithTotals(url).then(function(res){
3248      var posts = res.data || [];
3249      // Filter out anything already on the page (hero/side/grid/editor's/previous batches)
3250      var fresh = posts.filter(function(p){ return p && p.id && !renderedPostIds[p.id]; });
3251      fresh.forEach(function(p){ renderedPostIds[p.id] = true; });
3252
3253      // No posts returned at all → real end of feed
3254      if(!posts.length || page >= MAX_INFINITE_PAGES){
3255        done = true;
3256        showLoader(false);
3257        if(container.children.length) showEnd();
3258        obs.disconnect();
3259        loading = false;
3260        return;
3261      }
3262
3263      if(fresh.length){
3264        var batchId = 'home-batch-' + page;
3265        var html = '<section data-batch="'+page+'" style="margin-top:18px">'
3266          + '<div class="section-head"><h2>More <span>Stories</span></h2><span style="font-size:11px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase">Page '+page+'</span></div>'
3267          + '<div class="news-grid" id="'+batchId+'">'
3268          +   fresh.map(renderResultCard).join('')
3269          + '</div>'
3270          + '</section>';
3271        container.insertAdjacentHTML('beforeend', html);
3272        document.getElementById(batchId).onclick = function(e){
3273          var c = e.target.closest('.story[data-id]');
3274          if(c){ e.preventDefault(); go('#/post/'+c.dataset.id); }
3275        };
3276        showLoader(false);
3277        loading = false;
3278      } else {
3279        // Whole batch was duplicates — skip straight to the next page without showing loader flicker
3280        loading = false;
3281        showLoader(false);
3282        loadNext();
3283      }
3284    }).catch(function(){
3285      loading = false;
3286      showLoader(false);
3287    });
3288  }
3289
3290  var obs = new IntersectionObserver(function(entries){
3291    entries.forEach(function(en){ if(en.isIntersecting) loadNext(); });
3292  }, { rootMargin: '600px 0px 600px 0px' }); // start loading 600px before sentinel comes into view
3293  obs.observe(sentinel);
3294}
3295
3296/* Boot */
3297loadCambsNews();
3298loadYouTube();
3299loadAds();
3300loadTrendingTags();
3301loadSectionStripes();
3302loadWeather();
3303initInfiniteScroll();
3304router();
3305// Detect if visitor is a logged-in WP editor — adds "✎ Edit" links on stories.
3306// Once resolved, re-render the homepage so the new links appear without a refresh.
3307detectLoggedInUser().then(function(user){
3308  if (user) loadCambsNews();
3309});
3310// First-page GA4 hit (article opens fire their own gaPageView from renderPost)
3311gaPageView();
3312// Fire GA4 page_view on every SPA navigation
3313window.addEventListener('popstate',  gaPageView);
3314window.addEventListener('pushstate', gaPageView);   // some browsers fire this on history.pushState
3315(function patchPushState(){
3316  var orig = history.pushState;
3317  history.pushState = function(){
3318    var ret = orig.apply(this, arguments);
3319    try{ window.dispatchEvent(new Event('pushstate')); }catch(e){}
3320    return ret;
3321  };
3322})();
3323
3324/* ============================================================
3325 * Refresh-on-focus — when an editor returns to the tab after
3326 * publishing a story, silently re-fetch the homepage list so the
3327 * new story appears without a manual refresh. Throttled to one
3328 * call per 30s to avoid hammering the API on quick tab swaps.
3329 * ============================================================ */
3330(function(){
3331  var lastRefresh = Date.now();
3332  var REFRESH_INTERVAL_MS = 30 * 1000;
3333  function maybeRefresh(){
3334    if(document.hidden) return;                // tab is in background, do nothing
3335    var now = Date.now();
3336    if(now - lastRefresh < REFRESH_INTERVAL_MS) return;  // throttle
3337    lastRefresh = now;
3338    // Only refresh when we're actually on the home view
3339    var onHome = !$('#home-view').classList.contains('hidden');
3340    if(onHome && typeof loadCambsNews === 'function'){
3341      loadCambsNews();
3342      if(typeof loadMostRead === 'function') loadMostRead();
3343    }
3344  }
3345  document.addEventListener('visibilitychange', maybeRefresh);
3346  window.addEventListener('focus', maybeRefresh);
3347})();
3348
3349/* ---------- Service Worker registration (HTML + API caching) ---------- */
3350if('serviceWorker' in navigator && location.protocol === 'https:'){
3351  window.addEventListener('load', function(){
3352    navigator.serviceWorker.register('sw.js').catch(function(){ /* ignored — SW is enhancement, not required */ });
3353  });
3354}
3355
3356/* ============================================================
3357 * 360 MAP LINKS
3358 * Stories pinned on the interactive /360/ map get a pin badge on
3359 * their cards and a banner link on the article page.
3360 * Data comes from CONFIG.MAP_POSTS_ENDPOINT (10-min cache).
3361 * If the endpoint 404s (API not built yet) the feature stays
3362 * dormant - no errors, no visual change.
3363 * ============================================================ */
3364(function(){
3365  if(!CONFIG.MAP_POSTS_ENDPOINT) return;
3366  var LOOKUP = null;
3367  var PIN = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg>';
3368
3369  function buildLookup(list){
3370    var m = {};
3371    (list || []).forEach(function(e){
3372      if(!e || e.id == null) return;
3373      var url = e.url || ('/360/index.html?post=' + e.id);
3374      m[String(e.id)] = url;
3375      if(e.slug) m[e.slug] = url;
3376    });
3377    return m;
3378  }
3379
3380  function makeBadge(url){
3381    var a = document.createElement('a');
3382    a.className = 'map-badge';
3383    a.href = url;
3384    a.target = '_blank';
3385    a.rel = 'noopener';
3386    a.title = 'See where this story happened - view on the 360 map';
3387    a.innerHTML = '<span>360&deg;</span>';
3388    a.addEventListener('click', function(ev){ ev.stopPropagation(); });
3389    return a;
3390  }
3391
3392  function badgeArticle(){
3393    var pv = document.getElementById('post-view');
3394    if(!pv || pv.classList.contains('hidden')) return;
3395    var fig = pv.querySelector('.hero-img');
3396    if(!fig || fig.querySelector('.map-badge')) return;
3397    var slug = (location.hash.match(/^#\/post\/([^\/?]+)/) || [])[1] || location.pathname.replace(/\//g, '');
3398    var url = LOOKUP[slug];
3399    if(!url) return;
3400    var b = makeBadge(url);
3401    b.className = 'map-badge lg';
3402    fig.appendChild(b);
3403  }
3404
3405  function decorate(){
3406    if(!LOOKUP) return;
3407    var nodes = document.querySelectorAll('.card[data-id], article.story[data-id]');
3408    for(var i = 0; i < nodes.length; i++){
3409      var n = nodes[i];
3410      var url = LOOKUP[n.getAttribute('data-id')];
3411      if(!url || n.querySelector('.map-badge')) continue;
3412      n.classList.add('on-map');
3413      n.appendChild(makeBadge(url));
3414    }
3415    badgeArticle();
3416  }
3417
3418  var scheduled = false;
3419  function scheduleDecorate(){
3420    if(scheduled) return;
3421    scheduled = true;
3422    setTimeout(function(){ scheduled = false; decorate(); }, 150);
3423  }
3424
3425  var started = false;
3426  function start(){
3427    if(started) return;
3428    started = true;
3429    decorate();
3430    setTimeout(decorate, 1000);
3431    setTimeout(decorate, 4000);
3432    new MutationObserver(scheduleDecorate).observe(document.body, {childList: true, subtree: true});
3433    if(CONFIG.ADS_DEBUG && window.console) console.log('[cn-map] active');
3434  }
3435
3436  var KEY = 'cn-map-posts-v1';
3437  var cached = cacheGet(KEY, 10 * 60 * 1000) || cacheGetStale(KEY);
3438  if(cached){
3439    LOOKUP = buildLookup(cached);
3440    start();
3441  }
3442  smartFetch(CONFIG.CN_HOST + CONFIG.MAP_POSTS_ENDPOINT + '?t=' + Math.floor(Date.now() / 300000)).then(function(list){
3443    if(Array.isArray(list) && list.length){
3444      cacheSet(KEY, list);
3445      LOOKUP = buildLookup(list);
3446      start();
3447      decorate();
3448    }
3449  }).catch(function(){
3450    if(CONFIG.ADS_DEBUG && window.console) console.log('[cn-map] endpoint unavailable - dormant');
3451  });
3452})();
3453
3454/* ---------- discreet build number in the footer ----------
3455 * Reads the ?v= off the cnh.js script tag actually running, so it
3456 * always shows the truth (handy when chasing stale caches). */
3457(function(){
3458  var el = document.getElementById('cn-build');
3459  if(!el) return;
3460  var s = document.currentScript || document.querySelector('script[src*="cnh.js"]');
3461  var m = s && s.src && s.src.match(/[?&]v=([A-Za-z0-9._-]+)/);
3462  if(m) el.textContent = ' · v' + m[1];
3463})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.