news: track @newsHidden in Caddy snapshot + extract testable feed routing helpers

Housekeeping per Codex:
- Mirror the live @newsHidden rule into deploy/caddy/Caddyfile.snapshot so the
  /news noindex protection is reproducibly recorded.
- Extract the feed's routing helpers (feedBase/parseView/viewUrl) into pure
  $lib/feednav.js and unit-test them (the base-aware URL generation wasn't
  exercised by the prior suite). NewsFeed imports them; behavior unchanged.

(Note: the step-1 commit also swept in data/wotd_audio/renewal.mp3 — a legit
cached pronunciation, not extraction-related; left as-is per Codex.)

32 frontend tests green; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-28 15:19:36 -04:00
parent f4a7a7bcc7
commit 2fd28fa719
4 changed files with 94 additions and 24 deletions
+6
View File
@@ -63,6 +63,12 @@ upbeatbytes.com {
header @hidden X-Robots-Tag "noindex, nofollow" header @hidden X-Robots-Tag "noindex, nofollow"
# /news is the feed's future home — rendered during the relaunch transition but kept # /news is the feed's future home — rendered during the relaunch transition but kept
# noindex (FOLLOW, so link equity still flows) until cutover, when this line is removed
# and /news enters the sitemap. Avoids publishing a duplicate indexable feed.
@newsHidden path /news /news.html
header @newsHidden X-Robots-Tag "noindex, follow"
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/* @immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
+10 -24
View File
@@ -17,34 +17,20 @@
import { track } from '$lib/analytics.js'; import { track } from '$lib/analytics.js';
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js'; import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
import { ritualState, markBriefSeen } from '$lib/ritual.js'; import { ritualState, markBriefSeen } from '$lib/ritual.js';
import { feedBase, parseView, viewUrl } from '$lib/feednav.js';
let moods = $state([]); let moods = $state([]);
let topics = $state([]); let topics = $state([]);
let families = $state([]); let families = $state([]);
let lanePool = $state(null); // /api/lanes: { pinned, default, groups } let lanePool = $state(null); // /api/lanes: { pinned, default, groups }
let showLanes = $state(false); let showLanes = $state(false);
// The URL is the single source of truth for the current view, so the in-page // The URL is the single source of truth for the current view, so the in-page Back
// Back button and the browser Back button share ONE history. The view is // button and the browser Back button share ONE history (parseView in $lib/feednav.js).
// derived from query params: /?view=latest, /?tag=clean-energy, /?source=7, // The feed renders at both `/` (interim) and `/news`; `base()` picks the path we're on
// /?view=<mood|topic>, or bare / for Highlights. // so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes
function parseView(url) { // the hub and only `/news` renders the feed.
const p = url.searchParams; function base() { return feedBase($page.url.pathname); }
if ((p.get('q') || '').trim()) return 'search'; function urlForView(key) { return viewUrl(base(), key); }
if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag');
return p.get('view') || 'today';
}
// The feed renders at both `/` (interim) and `/news`. Generate links for whichever
// base we're on, so `/` behaves exactly as today and `/news` is self-coherent. At
// cutover, `/` becomes the hub and only `/news` renders the feed (base → always /news).
function feedBase() { return $page.url.pathname.startsWith('/news') ? '/news' : '/'; }
function urlForView(key) {
const b = feedBase();
if (key === 'today') return b;
if (key.startsWith('source:')) return b + '?source=' + encodeURIComponent(key.slice(7));
if (key.startsWith('tag:')) return b + '?tag=' + encodeURIComponent(key.slice(4));
return b + '?view=' + encodeURIComponent(key);
}
let selected = $derived(parseView($page.url)); let selected = $derived(parseView($page.url));
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim()); let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
let searchOpen = $state(false); let searchOpen = $state(false);
@@ -52,7 +38,7 @@
function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; } function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; }
function runSearch() { function runSearch() {
const q = searchText.trim(); const q = searchText.trim();
goto(q ? feedBase() + '?q=' + encodeURIComponent(q) : feedBase()); goto(q ? base() + '?q=' + encodeURIComponent(q) : base());
} }
let sourceNames = $state({}); // source id -> name, for an instant header label let sourceNames = $state({}); // source id -> name, for an instant header label
let brief = $state(null); let brief = $state(null);
@@ -691,7 +677,7 @@
autocapitalize="off" autocomplete="off" spellcheck="false" autocapitalize="off" autocomplete="off" spellcheck="false"
onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} /> onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} />
<button class="searchgo" onclick={runSearch}>Search</button> <button class="searchgo" onclick={runSearch}>Search</button>
{#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto(feedBase()); }}>Clear</button>{/if} {#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto(base()); }}>Clear</button>{/if}
</div> </div>
{/if} {/if}
+28
View File
@@ -0,0 +1,28 @@
// Pure routing helpers for the news feed, shared by its two mounts: `/` (interim,
// becomes the hub at cutover) and `/news` (its permanent home). Kept framework-free
// so they're unit-testable without mounting the Svelte component.
// Which base path the feed is rendering at. `/news` is permanent; `/` is the interim
// mount. Anything not under /news is treated as `/`.
export function feedBase(pathname) {
return (pathname || '').startsWith('/news') ? '/news' : '/';
}
// The current view key, derived from query params only (path-agnostic, so a deep link
// works at either base): search > source > tag > explicit view > 'today' (Highlights).
export function parseView(url) {
const p = url.searchParams;
if ((p.get('q') || '').trim()) return 'search';
if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag');
return p.get('view') || 'today';
}
// A link to a view at the given base path. 'today' is the bare base; everything else
// carries its query param so the same parseView() reads it back identically.
export function viewUrl(base, key) {
if (key === 'today') return base;
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
return base + '?view=' + encodeURIComponent(key);
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { feedBase, parseView, viewUrl } from './feednav.js';
const view = (path) => parseView(new URL('https://x' + path));
describe('feedBase', () => {
it('is /news only under the /news path; everything else is /', () => {
expect(feedBase('/news')).toBe('/news');
expect(feedBase('/news?view=latest')).toBe('/news');
expect(feedBase('/')).toBe('/');
expect(feedBase('/?view=latest')).toBe('/');
expect(feedBase('')).toBe('/');
expect(feedBase(undefined)).toBe('/');
});
});
describe('parseView', () => {
it('derives the view from query params, path-agnostic', () => {
expect(view('/')).toBe('today');
expect(view('/news')).toBe('today');
expect(view('/?view=latest')).toBe('latest');
expect(view('/news?view=following')).toBe('following');
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
expect(view('/?source=7')).toBe('source:7');
expect(view('/?q=solar')).toBe('search');
});
it('prioritizes search > source > tag > view', () => {
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
expect(view('/?tag=b&view=latest')).toBe('tag:b');
expect(view('/?q=%20')).toBe('today'); // whitespace-only q is not a search
});
});
describe('viewUrl', () => {
it('builds base-relative links that parseView reads back identically', () => {
for (const base of ['/', '/news']) {
expect(viewUrl(base, 'today')).toBe(base);
expect(viewUrl(base, 'latest')).toBe(base + '?view=latest');
expect(viewUrl(base, 'tag:clean-energy')).toBe(base + '?tag=clean-energy');
expect(viewUrl(base, 'source:7')).toBe(base + '?source=7');
// round-trip: the generated URL resolves back to the same view key
expect(view(viewUrl(base, 'latest').replace(base, '/'))).toBe('latest');
}
});
it('encodes tag/source/view values', () => {
expect(viewUrl('/news', 'tag:good news')).toBe('/news?tag=good%20news');
expect(viewUrl('/', 'view=a&b')).toBe('/?view=view%3Da%26b');
});
});