#6: computed News read-time + per-page OG/canonical for hub detail pages
- News card "3 min read" → computed from our own gist (~200 wpm, floor 1). We summarize, so it's honestly ~"1 min read" — the good news in about a minute. - Generalized the build-time head patch (patch-play-head → patch-static-heads): now also rewrites build/word.html, quote.html, onthisday.html so each ships its own <title>/description/canonical/OG/Twitter tags instead of the homepage head + canonical="/". Non-JS scrapers and canonical dedup are correct before these pages are ever un-noindexed. Same fail-loud guard as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/patch-play-head.mjs",
|
||||
"postbuild": "node scripts/patch-static-heads.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Post-build: give build/play.html its own social/canonical metadata.
|
||||
//
|
||||
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
||||
// HOMEPAGE <head> — meaning a shared /play link previews as the news homepage. Client
|
||||
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
|
||||
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
|
||||
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const FILE = new URL('../build/play.html', import.meta.url);
|
||||
const URL_PLAY = 'https://upbeatbytes.com/play';
|
||||
const TITLE = 'Play · Upbeat Bytes — calm daily games';
|
||||
const DESC =
|
||||
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||
'A friendly little break from the doomscroll.';
|
||||
|
||||
const subs = [
|
||||
[/<title>[\s\S]*?<\/title>/, `<title>${TITLE}</title>`],
|
||||
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${DESC}" />`],
|
||||
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${URL_PLAY}" />`],
|
||||
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${TITLE}" />`],
|
||||
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${DESC}" />`],
|
||||
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${URL_PLAY}" />`],
|
||||
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
|
||||
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
|
||||
];
|
||||
|
||||
let html = await readFile(FILE, 'utf8');
|
||||
const missed = [];
|
||||
for (const [re, repl] of subs) {
|
||||
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
||||
html = html.replace(re, repl);
|
||||
}
|
||||
// Fail loudly if the homepage head drifted — better a broken build than silently
|
||||
// shipping the wrong /play preview again.
|
||||
if (missed.length) {
|
||||
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(FILE, html);
|
||||
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
|
||||
@@ -0,0 +1,72 @@
|
||||
// Post-build: give specific prerendered shells their OWN social/canonical metadata.
|
||||
//
|
||||
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
||||
// HOMEPAGE <head> — meaning a shared /play or /word link previews as the news homepage,
|
||||
// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
|
||||
// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
|
||||
// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const BASE = 'https://upbeatbytes.com';
|
||||
|
||||
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
|
||||
const PAGES = [
|
||||
{
|
||||
file: 'play.html', path: '/play',
|
||||
title: 'Play · Upbeat Bytes — calm daily games',
|
||||
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||
'A friendly little break from the doomscroll.',
|
||||
},
|
||||
{
|
||||
file: 'word.html', path: '/word',
|
||||
title: 'Word of the Day · upbeatBytes',
|
||||
desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
|
||||
},
|
||||
{
|
||||
file: 'quote.html', path: '/quote',
|
||||
title: 'Quote of the Day · upbeatBytes',
|
||||
desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
|
||||
},
|
||||
{
|
||||
file: 'onthisday.html', path: '/onthisday',
|
||||
title: 'On This Day · upbeatBytes',
|
||||
desc: 'One genuinely good thing that happened on this day in history.',
|
||||
},
|
||||
];
|
||||
|
||||
function subsFor(url, title, desc) {
|
||||
return [
|
||||
[/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`],
|
||||
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${desc}" />`],
|
||||
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${url}" />`],
|
||||
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${title}" />`],
|
||||
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${desc}" />`],
|
||||
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${url}" />`],
|
||||
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${title}" />`],
|
||||
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${desc}" />`],
|
||||
];
|
||||
}
|
||||
|
||||
for (const { file, path, title, desc } of PAGES) {
|
||||
const fileUrl = new URL(`../build/${file}`, import.meta.url);
|
||||
let html;
|
||||
try {
|
||||
html = await readFile(fileUrl, 'utf8');
|
||||
} catch {
|
||||
console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const missed = [];
|
||||
for (const [re, repl] of subsFor(BASE + path, title, desc)) {
|
||||
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
||||
html = html.replace(re, repl);
|
||||
}
|
||||
// Fail loudly if the homepage head drifted — better a broken build than silently
|
||||
// shipping the wrong preview/canonical again.
|
||||
if (missed.length) {
|
||||
console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(fileUrl, html);
|
||||
console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
|
||||
}
|
||||
@@ -26,6 +26,11 @@
|
||||
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
|
||||
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
|
||||
|
||||
// Honest read-time from our own gist (~200 wpm, floor 1). We summarize, so this is
|
||||
// usually "1 min read" — a feature, not a bug: the good news in about a minute.
|
||||
const readMins = (t) => Math.max(1, Math.round((t || '').trim().split(/\s+/).filter(Boolean).length / 200));
|
||||
let readTime = $derived(`${readMins(news?.summary)} min read`);
|
||||
|
||||
// small-joys shelf: 3 cells shown two at a time, rotated by the reader (no auto-motion)
|
||||
const JOY_ACCENTS = ['#4f7da8', '#b06a86', '#b06a45'];
|
||||
let joyIdx = $state(0);
|
||||
@@ -125,7 +130,7 @@
|
||||
<p class="summary">{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}</p>
|
||||
</a>
|
||||
<div class="news-foot">
|
||||
<span class="meta">3 min read</span>
|
||||
<span class="meta">{readTime}</span>
|
||||
</div>
|
||||
<hr class="news-div" />
|
||||
<a class="news-more" href="/">Read more good news →</a>
|
||||
|
||||
Reference in New Issue
Block a user