From 2fd28fa719870a866f6fab5652b2179a332a5adc Mon Sep 17 00:00:00 2001 From: jay Date: Sun, 28 Jun 2026 15:19:36 -0400 Subject: [PATCH] news: track @newsHidden in Caddy snapshot + extract testable feed routing helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/caddy/Caddyfile.snapshot | 6 +++ frontend/src/lib/components/NewsFeed.svelte | 34 +++++--------- frontend/src/lib/feednav.js | 28 ++++++++++++ frontend/src/lib/feednav.test.js | 50 +++++++++++++++++++++ 4 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/feednav.js create mode 100644 frontend/src/lib/feednav.test.js diff --git a/deploy/caddy/Caddyfile.snapshot b/deploy/caddy/Caddyfile.snapshot index 9e6a2b2..33a1eb1 100644 --- a/deploy/caddy/Caddyfile.snapshot +++ b/deploy/caddy/Caddyfile.snapshot @@ -63,6 +63,12 @@ upbeatbytes.com { @hidden path /home2 /home3 /word /word.html /quote /quote.html /onthisday /onthisday.html /admin /admin.html header @hidden X-Robots-Tag "noindex, nofollow" + # /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/* header @immutable Cache-Control "public, max-age=31536000, immutable" diff --git a/frontend/src/lib/components/NewsFeed.svelte b/frontend/src/lib/components/NewsFeed.svelte index 363632f..d271dbd 100644 --- a/frontend/src/lib/components/NewsFeed.svelte +++ b/frontend/src/lib/components/NewsFeed.svelte @@ -17,34 +17,20 @@ import { track } from '$lib/analytics.js'; import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js'; import { ritualState, markBriefSeen } from '$lib/ritual.js'; + import { feedBase, parseView, viewUrl } from '$lib/feednav.js'; let moods = $state([]); let topics = $state([]); let families = $state([]); let lanePool = $state(null); // /api/lanes: { pinned, default, groups } let showLanes = $state(false); - // The URL is the single source of truth for the current view, so the in-page - // Back button and the browser Back button share ONE history. The view is - // derived from query params: /?view=latest, /?tag=clean-energy, /?source=7, - // /?view=, or bare / for Highlights. - 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'; - } - // 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); - } + // The URL is the single source of truth for the current view, so the in-page Back + // button and the browser Back button share ONE history (parseView in $lib/feednav.js). + // The feed renders at both `/` (interim) and `/news`; `base()` picks the path we're on + // so `/` behaves exactly as today and `/news` is self-coherent. At cutover, `/` becomes + // the hub and only `/news` renders the feed. + function base() { return feedBase($page.url.pathname); } + function urlForView(key) { return viewUrl(base(), key); } let selected = $derived(parseView($page.url)); let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim()); let searchOpen = $state(false); @@ -52,7 +38,7 @@ function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; } function runSearch() { 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 brief = $state(null); @@ -691,7 +677,7 @@ autocapitalize="off" autocomplete="off" spellcheck="false" onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} /> - {#if selected === 'search'}{/if} + {#if selected === 'search'}{/if} {/if} diff --git a/frontend/src/lib/feednav.js b/frontend/src/lib/feednav.js new file mode 100644 index 0000000..6b0da56 --- /dev/null +++ b/frontend/src/lib/feednav.js @@ -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); +} diff --git a/frontend/src/lib/feednav.test.js b/frontend/src/lib/feednav.test.js new file mode 100644 index 0000000..91f7826 --- /dev/null +++ b/frontend/src/lib/feednav.test.js @@ -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'); + }); +});