Add SEO and GEO
This commit is contained in:
@@ -15,6 +15,7 @@ The server listens on `http://localhost:8080` by default.
|
|||||||
- `PORT`: HTTP port, default `8080`
|
- `PORT`: HTTP port, default `8080`
|
||||||
- `HOST`: bind address, default `0.0.0.0`
|
- `HOST`: bind address, default `0.0.0.0`
|
||||||
- `DATA_DIR`: disk storage root, default `./data`
|
- `DATA_DIR`: disk storage root, default `./data`
|
||||||
|
- `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, and `llms.txt`
|
||||||
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot
|
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot
|
||||||
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
|
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
|
||||||
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
|
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
|
||||||
@@ -33,6 +34,19 @@ data/
|
|||||||
meta/YYYY/MM/DD/aa/bb/<sha256>.json
|
meta/YYYY/MM/DD/aa/bb/<sha256>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Discovery Metadata
|
||||||
|
|
||||||
|
The app serves crawler and answer-engine metadata without adding visible page copy:
|
||||||
|
|
||||||
|
- `/robots.txt`
|
||||||
|
- `/sitemap.xml` with approved meme image entries
|
||||||
|
- `/feed.json`
|
||||||
|
- `/llms.txt`
|
||||||
|
- `/site.webmanifest`
|
||||||
|
- Open Graph, Twitter card, canonical, and JSON-LD metadata on `/`
|
||||||
|
|
||||||
|
Set `SITE_URL` in production so canonical URLs use the public domain instead of an internal proxy hostname.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+18
-1
@@ -3,11 +3,28 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>The Meme Protocol</title>
|
<title>__SEO_TITLE__</title>
|
||||||
|
<meta name="description" content="__SEO_DESCRIPTION__">
|
||||||
|
<meta name="robots" content="index,follow,max-image-preview:large">
|
||||||
|
<meta name="application-name" content="__SEO_TITLE__">
|
||||||
|
<meta name="theme-color" content="#00ff41">
|
||||||
|
<link rel="canonical" href="__SEO_CANONICAL__">
|
||||||
|
<link rel="manifest" href="/site.webmanifest">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="__SEO_TITLE__">
|
||||||
|
<meta property="og:title" content="__SEO_TITLE__">
|
||||||
|
<meta property="og:description" content="__SEO_DESCRIPTION__">
|
||||||
|
<meta property="og:url" content="__SEO_CANONICAL__">
|
||||||
|
<meta property="og:image" content="__SEO_IMAGE__">
|
||||||
|
<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__">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
<script type="application/ld+json" nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>
|
||||||
<script type="module" src="/assets/app.js"></script>
|
<script type="module" src="/assets/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { parseMultipartUpload } from './src/multipart.js';
|
import { parseMultipartUpload } from './src/multipart.js';
|
||||||
import { sendFile, sendJson, sendText, withSecurityHeaders } from './src/http.js';
|
import { sendFile, sendHtml, sendJson, sendText, withSecurityHeaders } from './src/http.js';
|
||||||
import { createStore } from './src/store.js';
|
import { createStore } from './src/store.js';
|
||||||
import { validateImage } from './src/image.js';
|
import { validateImage } from './src/image.js';
|
||||||
import { normalizeToWebp } from './src/normalize.js';
|
import { normalizeToWebp } from './src/normalize.js';
|
||||||
import { seedDemoMemes } from './src/seed.js';
|
import { seedDemoMemes } from './src/seed.js';
|
||||||
import { moderateImage } from './src/moderation.js';
|
import { moderateImage } from './src/moderation.js';
|
||||||
import { createUploadLimiter } from './src/uploadLimits.js';
|
import { createUploadLimiter } from './src/uploadLimits.js';
|
||||||
|
import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js';
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -18,6 +20,7 @@ const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
|||||||
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
|
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
|
||||||
const events = new Set();
|
const events = new Set();
|
||||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
|
||||||
|
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
|
||||||
|
|
||||||
const store = await createStore({ dataDir: DATA_DIR });
|
const store = await createStore({ dataDir: DATA_DIR });
|
||||||
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
|
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
|
||||||
@@ -33,14 +36,48 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const baseUrl = publicBaseUrl(req);
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/') {
|
if (req.method === 'GET' && url.pathname === '/') {
|
||||||
return sendFile(res, './public/index.html');
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
withSecurityHeaders(res, { scriptNonce: nonce });
|
||||||
|
return sendHtml(res, 200, renderIndex({
|
||||||
|
template: indexTemplate,
|
||||||
|
baseUrl,
|
||||||
|
nonce,
|
||||||
|
approvedCount: store.count('approved')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/robots.txt') {
|
||||||
|
return sendText(res, 200, robotsTxt(baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/llms.txt') {
|
||||||
|
return sendText(res, 200, llmsTxt(baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/site.webmanifest') {
|
||||||
|
return sendJson(res, 200, manifest(baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/feed.json') {
|
||||||
|
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/sitemap.xml') {
|
||||||
|
const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes);
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Content-Length': String(Buffer.byteLength(body))
|
||||||
|
});
|
||||||
|
return res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
|
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
|
||||||
if (req.method === 'GET' && adminPageMatch) {
|
if (req.method === 'GET' && adminPageMatch) {
|
||||||
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
|
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
|
||||||
|
noIndex(res);
|
||||||
return sendFile(res, './public/admin.html');
|
return sendFile(res, './public/admin.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,12 +86,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/memes') {
|
if (req.method === 'GET' && url.pathname === '/api/memes') {
|
||||||
|
noIndex(res);
|
||||||
const page = positiveInt(url.searchParams.get('page'), 1);
|
const page = positiveInt(url.searchParams.get('page'), 1);
|
||||||
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
||||||
return sendJson(res, 200, store.list({ page, pageSize }));
|
return sendJson(res, 200, store.list({ page, pageSize }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/status') {
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
||||||
|
noIndex(res);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
memeCount: store.count('approved'),
|
memeCount: store.count('approved'),
|
||||||
@@ -64,11 +103,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
|
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
|
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
|
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
const body = await readJsonBody(req, 64 * 1024);
|
const body = await readJsonBody(req, 64 * 1024);
|
||||||
const approved = await store.approve(safeIds(body.ids));
|
const approved = await store.approve(safeIds(body.ids));
|
||||||
@@ -76,6 +117,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
|
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
const body = await readJsonBody(req, 64 * 1024);
|
const body = await readJsonBody(req, 64 * 1024);
|
||||||
const deleted = await store.delete(safeIds(body.ids));
|
const deleted = await store.delete(safeIds(body.ids));
|
||||||
@@ -83,6 +125,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/events') {
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
||||||
|
noIndex(res);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
@@ -95,6 +138,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
||||||
|
noIndex(res);
|
||||||
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||||
return sendJson(res, 411, { error: 'Missing upload size.' });
|
return sendJson(res, 411, { error: 'Missing upload size.' });
|
||||||
@@ -162,6 +206,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
|
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && adminMediaMatch) {
|
if (req.method === 'GET' && adminMediaMatch) {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
|
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
|
||||||
const meme = store.get(adminMediaMatch[2]);
|
const meme = store.get(adminMediaMatch[2]);
|
||||||
if (!meme) return sendText(res, 404, 'Not found');
|
if (!meme) return sendText(res, 404, 'Not found');
|
||||||
@@ -174,6 +219,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && downloadMatch) {
|
if (req.method === 'GET' && downloadMatch) {
|
||||||
|
noIndex(res);
|
||||||
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
||||||
const meme = store.get(downloadMatch[1]);
|
const meme = store.get(downloadMatch[1]);
|
||||||
@@ -186,6 +232,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||||
|
noIndex(res);
|
||||||
return sendJson(res, 200, { ok: true });
|
return sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -13,15 +13,16 @@ const TYPES = new Map([
|
|||||||
['.ico', 'image/x-icon']
|
['.ico', 'image/x-icon']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function withSecurityHeaders(res) {
|
export function withSecurityHeaders(res, options = {}) {
|
||||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||||
res.setHeader('Referrer-Policy', 'same-origin');
|
res.setHeader('Referrer-Policy', 'same-origin');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
const scriptSrc = options.scriptNonce ? `script-src 'self' 'nonce-${options.scriptNonce}'; ` : '';
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
"default-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
|
`default-src 'self'; ${scriptSrc}style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,15 @@ export function sendText(res, statusCode, message) {
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sendHtml(res, statusCode, html) {
|
||||||
|
const body = Buffer.from(html);
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Content-Length': String(body.length)
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
export function sendFile(res, filePath, options = {}) {
|
export function sendFile(res, filePath, options = {}) {
|
||||||
const resolved = options.absolute ? path.resolve(filePath) : path.resolve(filePath);
|
const resolved = options.absolute ? path.resolve(filePath) : path.resolve(filePath);
|
||||||
if (!options.absolute && !resolved.startsWith(PUBLIC_ROOT + path.sep) && resolved !== path.join(PUBLIC_ROOT, 'index.html')) {
|
if (!options.absolute && !resolved.startsWith(PUBLIC_ROOT + path.sep) && resolved !== path.join(PUBLIC_ROOT, 'index.html')) {
|
||||||
|
|||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
const SITE_NAME = 'The Meme Protocol';
|
||||||
|
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: square WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
|
||||||
|
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
|
||||||
|
|
||||||
|
export function publicBaseUrl(req) {
|
||||||
|
if (process.env.SITE_URL) return cleanBase(process.env.SITE_URL);
|
||||||
|
const host = process.env.TRUST_PROXY === 'true'
|
||||||
|
? req.headers['x-forwarded-host'] || req.headers.host
|
||||||
|
: req.headers.host;
|
||||||
|
const proto = process.env.TRUST_PROXY === 'true'
|
||||||
|
? req.headers['x-forwarded-proto'] || 'https'
|
||||||
|
: 'http';
|
||||||
|
return cleanBase(`${proto}://${host || 'localhost:8080'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderIndex({ template, baseUrl, nonce, approvedCount }) {
|
||||||
|
const canonical = `${baseUrl}/`;
|
||||||
|
const image = `${baseUrl}/assets/yoonect-logo.png`;
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${canonical}#website`,
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: canonical,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
inLanguage: 'en'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
'@id': `${canonical}#collection`,
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: canonical,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
isPartOf: { '@id': `${canonical}#website` },
|
||||||
|
about: ['memes', 'internet culture', 'image gallery', 'community moderation'],
|
||||||
|
numberOfItems: approvedCount
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: SITE_NAME,
|
||||||
|
applicationCategory: 'MultimediaApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
url: canonical,
|
||||||
|
codeRepository: REPO_URL,
|
||||||
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return template
|
||||||
|
.replaceAll('__SEO_TITLE__', escapeHtml(SITE_NAME))
|
||||||
|
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(SITE_DESCRIPTION))
|
||||||
|
.replaceAll('__SEO_CANONICAL__', canonical)
|
||||||
|
.replaceAll('__SEO_IMAGE__', image)
|
||||||
|
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
|
||||||
|
.replaceAll('__CSP_NONCE__', nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function robotsTxt(baseUrl) {
|
||||||
|
return [
|
||||||
|
'User-agent: *',
|
||||||
|
'Allow: /',
|
||||||
|
'Disallow: /admin/',
|
||||||
|
'Disallow: /admin-media/',
|
||||||
|
'Disallow: /api/',
|
||||||
|
'Disallow: /download/',
|
||||||
|
'',
|
||||||
|
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function llmsTxt(baseUrl) {
|
||||||
|
return [
|
||||||
|
'# The Meme Protocol',
|
||||||
|
'',
|
||||||
|
'> A live, moderated meme gallery. Uploads are square PNG/JPEG inputs normalized to metadata-stripped WebP, scored with MEME_CONSENSUS_SCORE, and published only after AI or admin approval.',
|
||||||
|
'',
|
||||||
|
'Important URLs:',
|
||||||
|
`- Site: ${baseUrl}/`,
|
||||||
|
`- JSON feed: ${baseUrl}/feed.json`,
|
||||||
|
`- Sitemap: ${baseUrl}/sitemap.xml`,
|
||||||
|
`- Source: ${REPO_URL}`,
|
||||||
|
'',
|
||||||
|
'Crawler guidance:',
|
||||||
|
'- Public approved meme images are available under /media/<sha256>.',
|
||||||
|
'- Admin, review, upload, and API mutation routes are not public knowledge sources.',
|
||||||
|
'- The site is intentionally visual and sparse; use metadata, sitemap image entries, and JSON feed for machine summaries.',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifest(baseUrl) {
|
||||||
|
return {
|
||||||
|
name: SITE_NAME,
|
||||||
|
short_name: 'Meme Protocol',
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#050505',
|
||||||
|
theme_color: '#00ff41',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: `${baseUrl}/assets/yoonect-logo.png`,
|
||||||
|
sizes: '1447x712',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedJson(baseUrl, memes) {
|
||||||
|
return {
|
||||||
|
version: 'https://jsonfeed.org/version/1.1',
|
||||||
|
title: SITE_NAME,
|
||||||
|
home_page_url: `${baseUrl}/`,
|
||||||
|
feed_url: `${baseUrl}/feed.json`,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
items: memes.slice(0, 50).map((meme) => ({
|
||||||
|
id: meme.id,
|
||||||
|
url: `${baseUrl}/media/${meme.id}`,
|
||||||
|
image: `${baseUrl}/media/${meme.id}`,
|
||||||
|
title: `Meme ${shortId(meme.id)}`,
|
||||||
|
content_text: `Approved meme with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100.`,
|
||||||
|
date_published: meme.createdAt
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sitemapXml(baseUrl, memes) {
|
||||||
|
const images = memes.slice(0, 1000).map((meme) => [
|
||||||
|
' <image:image>',
|
||||||
|
` <image:loc>${xmlEscape(`${baseUrl}/media/${meme.id}`)}</image:loc>`,
|
||||||
|
` <image:caption>${xmlEscape(`Meme ${shortId(meme.id)} with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100`)}</image:caption>`,
|
||||||
|
' </image:image>'
|
||||||
|
].join('\n')).join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">',
|
||||||
|
' <url>',
|
||||||
|
` <loc>${xmlEscape(`${baseUrl}/`)}</loc>`,
|
||||||
|
' <changefreq>hourly</changefreq>',
|
||||||
|
' <priority>1.0</priority>',
|
||||||
|
images,
|
||||||
|
' </url>',
|
||||||
|
'</urlset>',
|
||||||
|
''
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noIndex(res) {
|
||||||
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanBase(value) {
|
||||||
|
return String(value).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id) {
|
||||||
|
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscape(value) {
|
||||||
|
return escapeHtml(value).replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonScript(value) {
|
||||||
|
return value.replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user