diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..5614acd --- /dev/null +++ b/public/404.html @@ -0,0 +1,70 @@ + + + + + + __SEO_TITLE__ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ THE_MEME_PROTOCOL + RETURN_TO_LIVE_FEED +
+ +
+
+ + ERROR_404 // NODE_UNREACHABLE +
+ +
+
+ Homo Reseticus surrounded by an unreachable node pattern +
+
+
+ + + + diff --git a/public/assets/app.js b/public/assets/app.js index 85e6e84..afe2cef 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -253,6 +253,13 @@ function renderMemes(memes, options = {}) { } async function openSharedContentFromUrl() { + const requestedDialog = new URLSearchParams(window.location.search).get('open'); + if (window.location.pathname === '/' && requestedDialog === 'pet') { + petModal.showModal(); + window.history.replaceState({}, '', '/'); + return; + } + if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') { await openLoreDialog(); return; diff --git a/public/assets/homo-reseticus-404.png b/public/assets/homo-reseticus-404.png new file mode 100644 index 0000000..feced4d Binary files /dev/null and b/public/assets/homo-reseticus-404.png differ diff --git a/public/assets/styles.css b/public/assets/styles.css index a61e493..e17fa74 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -91,6 +91,15 @@ a { letter-spacing: 0.2em; } +.brand-link { + text-decoration: none; +} + +.brand-link:hover, +.brand-link:focus-visible { + color: var(--text); +} + .primary-action, .secondary-action, .icon-action { @@ -925,6 +934,75 @@ dialog::backdrop { text-align: center; } +.not-found-page { + min-height: 100svh; + overflow-x: hidden; +} + +.not-found-shell { + display: grid; + grid-template-rows: auto 1fr; + min-height: 100svh; +} + +.not-found-content { + display: grid; + width: min(100%, 760px); + justify-items: center; + place-self: center; + padding: 24px 0 48px; + text-align: center; +} + +.not-found-visual { + position: relative; + isolation: isolate; + width: clamp(190px, 39vmin, 414px); + max-width: 100%; +} + +.not-found-page .pulse { + background: #ff453a; + box-shadow: 0 0 8px rgb(255 69 58 / 55%); +} + +.not-found-visual::before { + position: absolute; + z-index: -1; + inset: 7% 5% 6%; + border-radius: 50%; + background: + radial-gradient( + ellipse at 50% 47%, + rgb(255 255 255 / 58%) 0%, + rgb(244 248 244 / 36%) 36%, + rgb(211 224 213 / 16%) 58%, + transparent 76% + ), + radial-gradient( + ellipse at 50% 48%, + rgb(192 216 198 / 12%) 0%, + transparent 72% + ); + content: ""; + filter: blur(36px); + transform: scale(1.24); +} + +.not-found-visual img { + display: block; + width: 100%; + height: auto; + filter: contrast(1.06) drop-shadow(0 0 18px rgb(229 226 225 / 14%)); +} + +.not-found-action { + display: inline-flex; + min-height: 40px; + align-items: center; + justify-content: center; +} + [hidden] { display: none !important; } @@ -1048,3 +1126,9 @@ dialog::backdrop { justify-content: space-between; } } + +@media (prefers-reduced-motion: reduce) { + .not-found-page .pulse { + animation: none; + } +} diff --git a/server.js b/server.js index b7accf2..0982421 100644 --- a/server.js +++ b/server.js @@ -11,7 +11,7 @@ import { seedDemoMemes } from './src/seed.js'; import { moderateImage } from './src/moderation.js'; import { createUploadLimiter } from './src/uploadLimits.js'; import { createIpRateLimiter } from './src/rateLimits.js'; -import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js'; +import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, renderNotFound, robotsTxt, sitemapXml } from './src/seo.js'; await loadDotEnv(); @@ -36,6 +36,7 @@ const DISCOVERY_ROUTES = new Set([ ]); const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex'); const indexTemplate = await fs.readFile('./public/index.html', 'utf8'); +const notFoundTemplate = await fs.readFile('./public/404.html', 'utf8'); const openApiSpec = JSON.parse(await fs.readFile('./openapi.json', 'utf8')); const memeApiSkillTemplate = await fs.readFile('./meme-api.skill.md', 'utf8'); @@ -90,7 +91,9 @@ const server = http.createServer(async (req, res) => { const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/); if (req.method === 'GET' && memePageMatch) { const meme = store.get(memePageMatch[1]); - if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found'); + if (!meme || meme.status !== 'approved') { + return sendNotFoundPage(res, { baseUrl, pathname: url.pathname }); + } const nonce = crypto.randomBytes(16).toString('base64'); withSecurityHeaders(res, { scriptNonce: nonce }); return sendHtml(res, 200, renderIndex({ @@ -292,6 +295,9 @@ const server = http.createServer(async (req, res) => { return sendJson(res, 200, { ok: true }); } + if (isBrowserPageRequest(req, url)) { + return sendNotFoundPage(res, { baseUrl, pathname: url.pathname }); + } return sendText(res, 404, 'Not found'); } catch (error) { const status = error.statusCode || 500; @@ -323,6 +329,27 @@ function isDiscoveryRoute(req, url) { return DISCOVERY_ROUTES.has(url.pathname); } +function isBrowserPageRequest(req, url) { + if (req.method !== 'GET') return false; + return ![ + '/api', + '/assets', + '/media', + '/download', + '/admin', + '/admin-media' + ].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)); +} + +function sendNotFoundPage(res, { baseUrl, pathname }) { + noIndex(res); + return sendHtml(res, 404, renderNotFound({ + template: notFoundTemplate, + baseUrl, + pathname + })); +} + async function loadDotEnv() { let content = ''; try { diff --git a/src/seo.js b/src/seo.js index 6aef0f0..c91aca2 100644 --- a/src/seo.js +++ b/src/seo.js @@ -2,6 +2,7 @@ const SITE_NAME = 'The Meme Protocol'; const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.'; const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree'; const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png'; +const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404.png'; const FAVICON_PATH = '/assets/favicon'; const FAVICON_VERSION = '20260814'; @@ -103,6 +104,21 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu .replaceAll('__CSP_NONCE__', nonce); } +export function renderNotFound({ template, baseUrl, pathname }) { + const title = `404 | ${SITE_NAME}`; + const description = 'The requested Meme Protocol node could not be found.'; + const pageUrl = new URL(pathname, `${baseUrl}/`).href; + const image = `${baseUrl}${NOT_FOUND_IMAGE_PATH}`; + const imageAlt = 'Homo Reseticus surrounded by an unreachable node pattern'; + + return template + .replaceAll('__SEO_TITLE__', escapeHtml(title)) + .replaceAll('__SEO_DESCRIPTION__', escapeHtml(description)) + .replaceAll('__SEO_URL__', escapeHtml(pageUrl)) + .replaceAll('__SEO_IMAGE__', escapeHtml(image)) + .replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt)); +} + export function robotsTxt(baseUrl) { return [ 'User-agent: *', diff --git a/tests/notFound.test.js b/tests/notFound.test.js new file mode 100644 index 0000000..8558e92 --- /dev/null +++ b/tests/notFound.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { renderNotFound } from '../src/seo.js'; + +const template = fs.readFileSync(new URL('../public/404.html', import.meta.url), 'utf8'); +const page = renderNotFound({ + template, + baseUrl: 'https://memes.example', + pathname: '/missing-node' +}); +const styles = fs.readFileSync(new URL('../public/assets/styles.css', import.meta.url), 'utf8'); +const artwork = fs.readFileSync(new URL('../public/assets/homo-reseticus-404.png', import.meta.url)); + +test('404 page is noindexed and links visitors back to the live feed', () => { + assert.match(page, /404 \| The Meme Protocol<\/title>/); + assert.match(page, /<meta name="robots" content="noindex,nofollow,noarchive">/); + assert.match(page, /class="primary-action not-found-action" href="\/"/); + assert.doesNotMatch(page, /PAGE_NOT_FOUND/); + assert.doesNotMatch(page, /THE_REQUESTED_NODE_DOES_NOT_EXIST/); +}); + +test('404 page carries adjusted site discovery and social metadata', () => { + assert.match(page, /<meta name="application-name" content="The Meme Protocol">/); + assert.match(page, /<link rel="manifest" href="\/site\.webmanifest\?v=20260814">/); + assert.match(page, /<link rel="service-desc"[^>]+href="\/openapi\.json">/); + assert.match(page, /<link rel="help"[^>]+href="\/meme-api\.skill\.md">/); + assert.match(page, /<meta property="og:url" content="https:\/\/memes\.example\/missing-node">/); + assert.match(page, /<meta property="og:image" content="https:\/\/memes\.example\/assets\/homo-reseticus-404\.png">/); + assert.match(page, /<meta name="twitter:card" content="summary_large_image">/); + assert.doesNotMatch(page, /<link rel="canonical"/); +}); + +test('404 page keeps the homepage protocol menu and replaces its live status', () => { + assert.match(page, /class="status-line"/); + assert.match(page, />DOWNLOAD PET<\/a>/); + assert.match(page, />READ LORE<\/a>/); + assert.match(page, /ERROR_404 \/\/ NODE_UNREACHABLE/); +}); + +test('404 page uses the supplied artwork with a diffused backdrop', () => { + assert.match(page, /src="\/assets\/homo-reseticus-404\.png"/); + assert.match(page, /alt="Homo Reseticus surrounded by an unreachable node pattern"/); + assert.match(styles, /\.not-found-visual::before/); + assert.match(styles, /radial-gradient\(/); + assert.match(styles, /filter: blur\(36px\)/); + assert.match(styles, /transform: scale\(1\.24\)/); + assert.match(styles, /\.not-found-page \.pulse/); + assert.equal(artwork.subarray(1, 4).toString('ascii'), 'PNG'); +});