feat: add branded 404 page
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>__SEO_TITLE__</title>
|
||||
<meta name="description" content="__SEO_DESCRIPTION__">
|
||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||
<meta name="application-name" content="The Meme Protocol">
|
||||
<meta name="theme-color" content="#00ff41">
|
||||
<link rel="manifest" href="/site.webmanifest?v=20260814">
|
||||
<link rel="service-desc" type="application/openapi+json" href="/openapi.json">
|
||||
<link rel="help" type="text/markdown" href="/meme-api.skill.md">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="The Meme Protocol">
|
||||
<meta property="og:title" content="__SEO_TITLE__">
|
||||
<meta property="og:description" content="__SEO_DESCRIPTION__">
|
||||
<meta property="og:url" content="__SEO_URL__">
|
||||
<meta property="og:image" content="__SEO_IMAGE__">
|
||||
<meta property="og:image:type" content="image/png">
|
||||
<meta property="og:image:width" content="1266">
|
||||
<meta property="og:image:height" content="1242">
|
||||
<meta property="og:image:alt" content="__SEO_IMAGE_ALT__">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="__SEO_TITLE__">
|
||||
<meta name="twitter:description" content="__SEO_DESCRIPTION__">
|
||||
<meta name="twitter:image" content="__SEO_IMAGE__">
|
||||
<meta name="twitter:image:alt" content="__SEO_IMAGE_ALT__">
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg?v=20260814">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png?v=20260814">
|
||||
<link rel="shortcut icon" href="/favicon.ico?v=20260814">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png?v=20260814">
|
||||
<meta name="apple-mobile-web-app-title" content="Meme Protocol">
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body class="not-found-page">
|
||||
<header class="topbar">
|
||||
<a class="brand brand-link" href="/" aria-label="The Meme Protocol home">THE_MEME_PROTOCOL</a>
|
||||
<a class="primary-action not-found-action" href="/">RETURN_TO_LIVE_FEED</a>
|
||||
</header>
|
||||
|
||||
<main class="shell not-found-shell">
|
||||
<div class="status-line">
|
||||
<nav class="protocol-menu" aria-label="Protocol menu">
|
||||
<a href="/?open=pet">DOWNLOAD PET</a>
|
||||
<a href="/lore">READ LORE</a>
|
||||
</nav>
|
||||
<span class="live"><span class="pulse" aria-hidden="true"></span>ERROR_404 // NODE_UNREACHABLE</span>
|
||||
</div>
|
||||
|
||||
<section class="not-found-content" aria-label="Page not found">
|
||||
<div class="not-found-visual">
|
||||
<img
|
||||
src="/assets/homo-reseticus-404.png"
|
||||
width="1266"
|
||||
height="1242"
|
||||
alt="Homo Reseticus surrounded by an unreachable node pattern"
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="repo-footer">
|
||||
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
|
||||
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
|
||||
<span>SOURCE</span>
|
||||
</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 653 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+16
@@ -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: *',
|
||||
|
||||
@@ -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, /<title>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');
|
||||
});
|
||||
Reference in New Issue
Block a user