openvibers/openvibe.community Commits

Serve discovery, page heads and cache headers from openvibe-shared (#11)

OP OpenVibers committed 4 Oct 2026 · cef50a42f9 · 22 files, +621 −359

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Community/pull/11

Changes

ddiff --git a/README.md b/README.md
iindex 1459dea..4d2603f 100644
---- a/README.md
++++ b/README.md
@@@ -156,6 +156,7 @@ API and machine endpoints:
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `POST /auth/refresh` | Rotate via refresh token |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
++| `GET /llms.txt`, `GET /llms-full.txt` | The site map for language models, and the latest public threads and text pastes in full |
| `GET /s/feed.xml`, `GET /s/:space/feed.xml` | RSS of the latest threads (public spaces) |
**Readiness and metrics (Track O).** `GET /api/ready` (openvibe-shared/ready) answers 503 only
@@@ -604,12 +605,23 @@ per-address form limits. `test/actor-limits.test.js`.
## SEO
--Every page carries a title, description, canonical, robots, Open Graph + Twitter card and
--JSON-LD (`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished`
--on paste pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere).
++Discovery is written by `openvibe-shared/seo`; Community only supplies the data
++(`server/discovery.js`, `server/render/jsonld.js`). Every page head comes from `seo.headTags`
++(description, canonical, robots, Open Graph + Twitter card, JSON-LD) plus `seo.pageSummary`
++(an `ai-summary` meta and a `WebPage` JSON-LD), with `article:*` times on pastes. JSON-LD:
++`WebSite` on the home page, `Article`/`ImageObject` with author and `datePublished` on paste
++pages, `DiscussionForumPosting` on threads, `BreadcrumbList` everywhere.
Unlisted/private pastes, search result pages and members/staff spaces are `noindex`. The
sitemap lists home, `/pastes`, the latest public pastes, `/s`, `/pulse`, public spaces and
--their latest threads (cached 1 h).
++their latest threads (rebuilt at most hourly). `/robots.txt` welcomes search and AI crawlers
++and keeps them out of `/api/`, `/auth/`, `/my`, `/new`, `/s/*/new` and `?sso=` URLs;
++`/llms.txt` maps the site and `/llms-full.txt` carries the latest public threads and text pastes
++in full (no NSFW, no burn-after-read, nothing members-only).
++
++Cache headers come from `openvibe-shared/cache-policy`: a static file at its current `?v=` hash
++is immutable for a year, any other static file is 5 minutes with a day of stale-while-revalidate,
++pages (rendered for the person reading them), raw and download are `private, no-store`, and
++robots/llms/sitemap are public for 1 h, `/feed.xml` 15 min and the thread feeds 5 min.
## Configuration
@@@ -762,7 +774,8 @@ server/
identity/authors.js author display from subject_projection; the AI label
votes.js race-safe up/down votes (comments, threads)
limits.js per-person write limits
-- render/layout.js page shell: SEO head, shared chrome, hashed assets
++ render/layout.js page shell: SEO head (openvibe-shared/seo), shared chrome, hashed assets
++ render/jsonld.js JSON-LD builders (WebSite, breadcrumbs, paste, thread, authors)
render/pages.js home / browse / paste / new / my / error templates
render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/pulse.js the /pulse page
@@@ -770,7 +783,7 @@ server/
render/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
render/highlight.js highlight.js wrapper, language list, download extensions
-- seo.js robots, sitemap, RSS, JSON-LD builders
++ discovery.js robots disallow set, llms sections, sitemap rows (1 h), feed items
public/ css/community.css, js/community.js, favicon.svg, og-default.png
(openvibe-shared is the pinned OpenVibe.Shared v2.2.0 release, installed by npm)
deploy/ systemd unit, nginx vhost
ddiff --git a/STATUS.json b/STATUS.json
iindex f46e870..78cd8e4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -40,5 +40,5 @@
"N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)"
],
-- "updated": "2026-09-28"
++ "updated": "2026-10-03"
}
ddiff --git a/server/app.js b/server/app.js
iindex 795cf7b..c92de30 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,7 +13,7 @@
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
-- * GET|POST /new create GET /robots.txt, /sitemap.xml, /feed.xml, /s/feed.xml
++ * GET|POST /new create GET /robots.txt, /llms.txt, /llms-full.txt, /sitemap.xml, /feed.xml, /s/feed.xml
* GET /my signed-in user's pastes /auth/login|callback|logout|me|refresh
* GET /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
* GET /pulse the network's public activity
@@@ -34,9 +34,9 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const catalog = require('./pastes/catalog');
const source = require('./pastes/source');
--const seo = require('./seo');
++const discovery = require('./discovery');
const pages = require('./render/pages');
--const { assetVersion } = require('./render/layout');
++const { assetVersion, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
const { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { getDb } = require('./db');
const { createNetworkIdentity } = require('./identity/network');
@@@ -63,11 +63,16 @@ const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const pasteRef = (slug) => ({ service: 'community', type: 'paste', id: String(slug) });
const VERSION = require('../package.json').version;
++// Pages are rendered for the person reading them (and a form post redirects back to them), so no
++// shared or browser cache keeps one.
++const PAGE_CACHE = cache.htmlHeaders({ private: true });
async function createApp(opts = {}) {
const app = express();
@@@ -165,7 +170,7 @@ async function createApp(opts = {}) {
const comments = createCommentService({ db, network, limits: opts.commentLimits });
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
-- seo.useForum(forum);
++ discovery.useForum(forum);
Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow });
if (opts.startRelay !== false) relay.start();
@@@ -196,7 +201,7 @@ async function createApp(opts = {}) {
handler: (req, res) => {
const error = 'Too many anonymous posts — sign in or try again later';
if (req.originalUrl.startsWith('/api/')) return res.status(429).json({ error });
-- res.status(429).type('html').set('Cache-Control', 'no-cache').send(pages.newPage({ user: req.user, error }));
++ res.status(429).type('html').set('Cache-Control', PAGE_CACHE).send(pages.newPage({ user: req.user, error }));
},
});
@@@ -252,17 +257,26 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
-- if (v && v === assetVersion(rel)) res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
-- else if (/\.(svg|png|ico|webmanifest)$/.test(rel)) res.setHeader('Cache-Control', 'public, max-age=86400');
-- else res.setHeader('Cache-Control', 'no-cache');
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
},
}));
// ── Machine endpoints ────────────────────────────────────
-- app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt()));
-- app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt()));
-- app.get('/sitemap.xml', seo.sitemapHandler);
-- app.get('/feed.xml', wrap(seo.feedHandler));
++ // Written by openvibe-shared/seo from server/discovery.js; the sitemap is rebuilt at most hourly.
++ app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(seo.robotsTxt({ sitemaps: [`${config.baseUrl}/sitemap.xml`], disallow: discovery.ROBOTS_DISALLOW })));
++ app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, sections: discovery.llmsSections() })));
++ app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
++ app.get('/sitemap.xml', async (_req, res) => {
++ let rows;
++ try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(rows));
++ });
++ app.get('/feed.xml', wrap(async (_req, res) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 900 }))
++ .send(seo.feedXml({ title: `${SITE_NAME} — latest pastes`, link: `${config.baseUrl}/pastes`, description: DEFAULT_DESCRIPTION,
++ language: 'en', selfUrl: `${config.baseUrl}/feed.xml`, items: await discovery.pasteFeedItems() }, { format: 'rss' }))));
// GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).
if (indexnow.enabled) app.use(indexnow.keyFile);
@@@ -270,7 +284,9 @@ async function createApp(opts = {}) {
// Pages are for browsers: the viewer (subject, staff) is resolved here too.
const withUser = viewers.middleware({ services: false });
const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
-- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', PAGE_CACHE).send(body);
++ // Every page response below (redirects and error pages too) that sets no policy of its own.
++ app.use(cache.applyHtml({ private: true }));
app.get('/', withUser, wrap(async (req, res) => {
const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);
@@@ -355,7 +371,7 @@ async function createApp(opts = {}) {
try {
const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
if (out.redirect) return res.redirect(302, out.redirect);
-- res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
++ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', cache.htmlHeaders({ private: true }));
res.type('text/plain; charset=utf-8').send(out.content);
} catch (err) {
if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
@@@ -381,7 +397,7 @@ async function createApp(opts = {}) {
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
if (paste.type === 'screenshot') return res.redirect(302, paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
-- res.set('Cache-Control', 'private, no-cache');
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
}));
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 01c9912..410178f 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -35,6 +35,7 @@
const express = require('express');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
@@@ -392,7 +393,7 @@ function createAuthRoutes(config, auth) {
// No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
// page view, and a 401 logged a console error on each (browser check, OpenVibe.Host). A credential
// that is present but invalid or expired still answers 401.
-- if (!token) return res.set('Cache-Control', 'private, no-store').json({ user: null });
++ if (!token) return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: null });
const claims = await auth.verify(token);
if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
ddiff --git a/server/comments/routes.js b/server/comments/routes.js
iindex 7a1f370..b83910c 100644
---- a/server/comments/routes.js
++++ b/server/comments/routes.js
@@@ -15,6 +15,7 @@ const express = require('express');
const pages = require('../render/pages');
const { threadPage } = require('../render/comments');
const { ApiError } = require('../http/v1');
++const cache = require('openvibe-shared/cache-policy');
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@@ -22,7 +23,7 @@ function createCommentPages({ comments, viewers, config }) {
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '64kb' });
-- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', cache.htmlHeaders({ private: true })).send(body);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
function failPage(req, res, err, next) {
ddiff --git a/server/discovery.js b/server/discovery.js
nnew file mode 100644
iindex 0000000..3882ade
---- /dev/null
++++ b/server/discovery.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * What Community tells crawlers, feed readers and language models: the robots.txt disallow set,
++ * the /llms.txt and /llms-full.txt sections, the sitemap rows (cached ~1h) and the feed items.
++ * Only data lives here; openvibe-shared/seo writes every format (server/app.js, server/forum/routes.js).
++ */
++const config = require('./config');
++const catalog = require('./pastes/catalog');
++const source = require('./pastes/source');
++const { abs } = require('./render/layout');
++const { isoDate, clean } = require('./render/jsonld');
++
++const SITEMAP_TTL_MS = 60 * 60 * 1000;
++const ROBOTS_DISALLOW = ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso='];
++const SUMMARY = 'The discussion side of the OpenVibe network: pastes (text and screenshots), spaces with threads, and the Pulse feed of what is happening across the network.';
++const CONTENT_LABELS = 'Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.';
++
++// The forum service (server/forum/service.js), when the app has one: threads join the sitemap,
++// the feeds and /llms-full.txt.
++let _forum = null;
++function useForum(forum) { _forum = forum || null; resetCaches(); }
++
++// ── llms.txt / llms-full.txt (llmstxt.org) ───────────────────
++function llmsSections() {
++ const u = config.baseUrl;
++ return [
++ { title: 'Browse', links: [
++ { title: 'Pastes', url: `${u}/pastes`, note: 'public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)' },
++ { title: 'Spaces', url: `${u}/s`, note: 'open discussion spaces and their threads' },
++ { title: 'Pulse', url: `${u}/pulse`, note: 'network activity' },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Sitemap', url: `${u}/sitemap.xml` },
++ { title: 'Latest pastes (RSS)', url: `${u}/feed.xml` },
++ { title: 'Latest threads (RSS)', url: `${u}/s/feed.xml` },
++ { title: 'Full text for language models', url: `${u}/llms-full.txt` },
++ ] },
++ ];
++}
++
++/** The latest public threads and text pastes in full (no NSFW, no burn-after-read, nothing gated). */
++async function llmsFullSections() {
++ const { markdownToText } = require('./render/markdown');
++ const sections = [{ title: 'Content labels', pages: [{ title: 'Who wrote what', url: '/', text: CONTENT_LABELS }] }];
++ if (_forum) {
++ const threads = await _forum.recentPublic({ limit: 50 });
++ sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
++ title: `${t.title} (${t.space_name})`, url: abs(`/s/${t.space_slug}/t/${t.slug}`), text: markdownToText(t.opening, Infinity),
++ })) });
++ }
++ const pastes = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw) && !Number(p.burn_after_read) && p.type !== 'screenshot');
++ const full = await Promise.all(pastes.map((p) => source.publicForDiscovery(p.slug)));
++ sections.push({ title: 'Latest pastes', pages: full.filter(Boolean).map((p) => ({
++ title: p.title || 'Untitled', url: abs(`/p/${p.slug}`), text: String(p.content || ''),
++ })) });
++ return sections;
++}
++
++// ── sitemap.xml rows (cached ~1h) ────────────────────────────
++let _sitemap = null, _sitemapAt = 0;
++async function buildSitemapRows() {
++ const rows = [];
++ const add = (loc, lastmod, changefreq, priority) => rows.push({ loc: abs(loc), lastmod, changefreq, priority });
++ add('/', null, 'hourly', '1.0');
++ add('/pastes', null, 'hourly', '0.8');
++ add('/pastes?sort=views', null, 'daily', '0.5');
++ for (const p of await catalog.recent()) {
++ if (Number(p.is_nsfw) || Number(p.burn_after_read)) continue;
++ add(`/p/${p.slug}`, isoDate(p.updated_at || p.created_at), 'weekly', '0.6');
++ }
++ if (_forum) {
++ add('/s', null, 'hourly', '0.8');
++ add('/pulse', null, 'hourly', '0.5');
++ for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
++ for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
++ }
++ return rows;
++}
++/** The sitemap rows, rebuilt at most hourly; a failed rebuild keeps serving the last good rows. */
++async function sitemapRows() {
++ if (!_sitemap || Date.now() - _sitemapAt > SITEMAP_TTL_MS) {
++ try { _sitemap = await buildSitemapRows(); _sitemapAt = Date.now(); }
++ catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) throw e; }
++ }
++ return _sitemap;
++}
++
++// ── Feed items (openvibe-shared/seo feedXml, 2.5.0 item shape) ──
++/** The latest pastes for /feed.xml. */
++async function pasteFeedItems() {
++ return (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw)).map((p) => {
++ const url = abs(`/p/${p.slug}`);
++ const description = p.type === 'screenshot' ? 'A screenshot shared on OpenVibe.Community.' : clean(p.content || '', 300);
++ return { title: p.title || 'Untitled', link: url, guid: url, description, content: description,
++ author: p.display_name || p.username || 'Anonymous', published: isoDate(p.created_at), updated: isoDate(p.updated_at || p.created_at) };
++ });
++}
++
++/** Threads (forum.recentPublic rows) for /s/feed.xml and /s/:space/feed.xml. */
++function threadFeedItems(threads) {
++ const { markdownToText } = require('./render/markdown');
++ return threads.map((t) => {
++ const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
++ const description = markdownToText(t.opening, 300);
++ return { title: t.title, link: url, guid: url, description, content: description,
++ author: undefined, published: isoDate(t.created_at), updated: isoDate(t.last_activity_at || t.created_at) };
++ });
++}
++
++function resetCaches() { _sitemap = null; _sitemapAt = 0; }
++
++module.exports = { ROBOTS_DISALLOW, SUMMARY, CONTENT_LABELS, llmsSections, llmsFullSections, sitemapRows, pasteFeedItems, threadFeedItems, useForum, resetCaches };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex e67b0ca..4ed7549 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -20,7 +20,10 @@
*/
const express = require('express');
const multer = require('multer');
--const seo = require('../seo');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const discovery = require('../discovery');
++const { SITE_NAME } = require('../render/layout');
const pages = require('../render/pages');
const forumPages = require('../render/forum');
const boardPages = require('../render/board');
@@@ -47,7 +50,7 @@ function createForumRoutes({ forum, viewers, config }) {
return ids;
}
const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
-- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', cache.htmlHeaders({ private: true })).send(body);
const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@@ -86,7 +89,7 @@ function createForumRoutes({ forum, viewers, config }) {
const values = { name: b.name, slug: b.slug, description: b.description, style: b.style === 'feed' ? 'feed' : 'forum', votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null };
try {
const out = await forum.createSpace(req.viewer, values);
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, `/s/${out.space.slug}`);
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
@@@ -103,15 +106,23 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
}));
-- router.get('/s/feed.xml', async (_req, res) => {
-- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ threads: await forum.recentPublic({ limit: 30 }) }));
-- });
++ // RSS of the latest threads, overall and per public space (openvibe-shared/seo feedXml).
++ const threadFeed = async (res, space) => res.type('application/rss+xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(seo.feedXml({
++ title: space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`,
++ link: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}`,
++ description: space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Community.',
++ language: 'en',
++ selfUrl: `${config.baseUrl}${space ? `/s/${space.slug}` : '/s'}/feed.xml`,
++ items: discovery.threadFeedItems(await forum.recentPublic({ limit: 30, ...(space ? { space: space.slug } : {}) })),
++ }, { format: 'rss' }));
++
++ router.get('/s/feed.xml', wrap(async (_req, res) => threadFeed(res, null)));
-- router.get('/s/:space/feed.xml', async (req, res) => {
++ router.get('/s/:space/feed.xml', wrap(async (req, res) => {
const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
if (!space) return res.status(404).type('text/plain').send('Not found');
-- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(seo.threadFeed({ space, threads: await forum.recentPublic({ limit: 30, space: space.slug }) }));
-- });
++ await threadFeed(res, space);
++ }));
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
try {
@@@ -143,7 +154,7 @@ function createForumRoutes({ forum, viewers, config }) {
// Images go to Media only when the rest can be saved (no strays from an empty form).
if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
const out = await forum.createThread(req.viewer, req.params.space, values);
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, out.thread.url);
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
@@@ -202,7 +213,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
}));
@@@ -232,7 +243,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, out.thread.url);
} catch (err) { failPage(req, res, err, next); }
}));
@@@ -242,7 +253,7 @@ function createForumRoutes({ forum, viewers, config }) {
const b = req.body || {};
try {
await forum.updateSpaceSettings(req.viewer, req.params.space, { name: b.name, description: b.description, style: b.style, votes: b.votes === '1', reactions: b.reactions === '1', group: b.group || null });
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
}));
@@@ -283,7 +294,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
-- seo.resetCaches();
++ discovery.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
});
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 72c0238..4ea8153 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -344,6 +344,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };
},
++ /** A fresh public text paste for discovery; reading it has no view or burn side effects. */
++ async publicForDiscovery(slug) {
++ const p = await store.getBySlug(db, String(slug));
++ if (!p || p.visibility !== 'public' || p.type !== 'paste' || Number(p.is_nsfw) || Number(p.burn_after_read)) return null;
++ return { slug: p.slug, title: p.title, content: p.content };
++ },
++
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
const p = await visible(v, slug);
ddiff --git a/server/pastes/source.js b/server/pastes/source.js
iindex 43fc536..7ecd5c8 100644
---- a/server/pastes/source.js
++++ b/server/pastes/source.js
@@@ -19,6 +19,7 @@ function localSource(service) {
service,
listPastes: (query, ctx = {}) => service.list(who(ctx), query || {}),
getPaste: async (slug, ctx = {}) => (await service.get(who(ctx), slug, { noView: !!ctx.noView, ip: ctx.ip, userAgent: ctx.userAgent })).paste,
++ publicForDiscovery: (slug) => service.publicForDiscovery(slug),
listByUser: (username, query, ctx = {}) => service.byUser(who(ctx), username, query || {}),
createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),
};
@@@ -35,6 +36,7 @@ module.exports = {
get local() { return !!(current && current.local); },
listPastes: (...a) => current.listPastes(...a),
getPaste: (...a) => current.getPaste(...a),
++ publicForDiscovery: (...a) => current.publicForDiscovery(...a),
listByUser: (...a) => current.listByUser(...a),
createPaste: (...a) => current.createPaste(...a),
};
ddiff --git a/server/render/board.js b/server/render/board.js
iindex 5b20018..8618210 100644
---- a/server/render/board.js
++++ b/server/render/board.js
@@@ -8,7 +8,7 @@
* (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings, images and pastes.
* Complete without JavaScript: every action is a link or a form post.
*/
--const seo = require('../seo');
++const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
@@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
function boardRow(sp) {
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const last = sp.last_post
-- ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(seo.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
++ ? `<a href="${esc(sp.last_post.thread.url)}#post-${Number(sp.last_post.id)}">${esc(ld.clean(sp.last_post.thread.title, 48))}</a><span class="small muted">by ${esc(sp.last_post.author ? (sp.last_post.author.display_name || sp.last_post.author.username || 'someone') : 'someone')} · ${timeTag(sp.last_post.created_at)}</span>`
: '<span class="muted small">No posts yet</span>';
const children = (sp.children || []).length ? `<p class="small board-children">Child boards: ${sp.children.map((c) => `<a href="/s/${esc(c.slug)}">${esc(c.name)}</a>`).join(', ')}</p>` : '';
return `<tr>
@@@ -66,7 +66,7 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
canonicalPath: '/s',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
body,
});
}
@@@ -144,7 +144,7 @@ ${viewer.can_moderate ? settingsForm(space, groups) : ''}`;
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
});
}
@@@ -202,7 +202,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
const pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);
const body = `
<article class="thread forum-topic">
-- ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [seo.clean(thread.title, 60), base]])}
++ ${crumbs([...trail(space), [space.name, `/s/${space.slug}`], [ld.clean(thread.title, 60), base]])}
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
@@@ -218,7 +218,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
${pagerHtml}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
-- const description = gated ? `A topic for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
++ const description = gated ? `A topic for VIP members in ${space.name} on OpenVibe.Community.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
return renderPage({
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
description,
@@@ -233,8 +233,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
footerVariant: 'compact',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
-- ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),
-- seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
++ ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
],
body,
});
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 315e913..9eff9a1 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -6,7 +6,7 @@
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* Markdown renderer's output (render/markdown.js).
*/
--const seo = require('../seo');
++const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
@@@ -73,7 +73,7 @@ ${list}`;
canonicalPath: '/s',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }])],
body,
});
}
@@@ -144,7 +144,7 @@ ${footer}`;
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
body,
});
}
@@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const body = `
<article class="thread">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(seo.clean(thread.title, 60))}</span></nav>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span></nav>
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
@@@ -268,7 +268,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
${pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Earlier', 'Later'])}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
-- const description = gated ? `A thread for VIP members in ${space.name} on OpenVibe.Community.` : (seo.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
++ const description = gated ? `A thread for VIP members in ${space.name} on OpenVibe.Community.` : (ld.clean(opening ? markdownToText(opening.body_markdown, 200) : thread.title, 200) || thread.title);
return renderPage({
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
description,
@@@ -283,8 +283,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
footerVariant: 'compact',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
-- ...(gated ? [] : [seo.threadLd({ space, thread, posts, opening, description })]),
-- seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
++ ...(gated ? [] : [ld.threadLd({ space, thread, posts, opening, description })]),
++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }, { name: thread.title, url: base }]),
],
body,
});
@@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
? '<p class="muted">We could not confirm your membership just now. Try again in a moment.</p>'
: '<p class="muted">Your account does not have an active membership for this.</p>';
-- const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(seo.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
++ const crumbs = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › ${thread ? `<a href="/s/${esc(space.slug)}">${esc(space.name)}</a> › <span aria-current="page">${esc(ld.clean(thread.title, 60))}</span>` : `<span aria-current="page">${esc(space.name)}</span>`}</nav>`;
const body = `
<header class="page-head">
${crumbs}
ddiff --git a/server/render/jsonld.js b/server/render/jsonld.js
nnew file mode 100644
iindex 0000000..b2b1a12
---- /dev/null
++++ b/server/render/jsonld.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * JSON-LD builders for the pages (WebSite, breadcrumbs, pastes, threads and their authors) and the
++ * two small text helpers they share with the templates. renderPage emits them through
++ * openvibe-shared/seo's jsonLdTag (via headTags).
++ */
++const config = require('../config');
++const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./layout');
++
++function isoDate(v) {
++ if (!v) return null;
++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++function clean(s, max) {
++ const t = String(s || '').replace(/\s+/g, ' ').trim();
++ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
++}
++
++// ── JSON-LD ──────────────────────────────────────────────────
++function websiteLd() {
++ return {
++ '@context': 'https://schema.org', '@type': 'WebSite',
++ name: SITE_NAME, url: `${config.baseUrl}/`, description: DEFAULT_DESCRIPTION,
++ potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${config.baseUrl}/pastes?q={search_term_string}` }, 'query-input': 'required name=search_term_string' },
++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
++ };
++}
++
++function breadcrumbLd(items) {
++ return {
++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
++ itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),
++ };
++}
++
++function authorLd(paste) {
++ const name = paste.display_name || paste.username;
++ if (!name) return { '@type': 'Person', name: 'Anonymous' };
++ return { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(paste.username || name)}` };
++}
++
++function pasteLd(paste, { description, image }) {
++ const isScreenshot = paste.type === 'screenshot';
++ const url = abs(`/p/${paste.slug}`);
++ const base = {
++ '@context': 'https://schema.org',
++ '@type': isScreenshot ? 'ImageObject' : 'Article',
++ headline: clean(paste.title || 'Untitled', 110),
++ name: clean(paste.title || 'Untitled', 110),
++ description,
++ url,
++ mainEntityOfPage: url,
++ author: authorLd(paste),
++ datePublished: isoDate(paste.created_at) || undefined,
++ dateModified: isoDate(paste.updated_at || paste.created_at) || undefined,
++ publisher: { '@type': 'Organization', name: SITE_NAME, url: `${config.baseUrl}/` },
++ interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/ViewAction', userInteractionCount: Number(paste.views) || 0 },
++ };
++ if (isScreenshot) { base.contentUrl = image; base.uploadDate = base.datePublished; }
++ else {
++ base.image = image;
++ base.hasPart = {
++ '@type': 'SoftwareSourceCode',
++ programmingLanguage: paste.language && paste.language !== 'text' ? paste.language : undefined,
++ codeRepository: url,
++ };
++ }
++ return base;
++}
++
++/** Who wrote a thread or post, for JSON-LD: a person, the AI label (never a person), or anonymous. */
++function discussionAuthorLd(a) {
++ if (!a) return { '@type': 'Person', name: 'Anonymous' };
++ if (a.is_ai) return { '@type': 'Organization', name: a.display_name || 'OpenVibe AI' };
++ const name = a.display_name || a.username || 'Anonymous';
++ return a.username ? { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(a.username)}` } : { '@type': 'Person', name };
++}
++
++/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
++function threadLd({ space, thread, posts, opening, description }) {
++ const { markdownToText } = require('./markdown');
++ const url = abs(`/s/${space.slug}/t/${thread.slug}`);
++ const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
++ return {
++ '@context': 'https://schema.org',
++ '@type': 'DiscussionForumPosting',
++ headline: clean(thread.title, 110),
++ text: opening && !opening.deleted ? markdownToText(opening.body_markdown, 5000) : description,
++ url,
++ mainEntityOfPage: url,
++ author: discussionAuthorLd(thread.author),
++ datePublished: isoDate(thread.created_at) || undefined,
++ dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
++ isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
++ commentCount: thread.reply_count,
++ interactionStatistic: [
++ { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
++ { '@type': 'InteractionCounter', interactionType: 'https://schema.org/LikeAction', userInteractionCount: Math.max(Number(thread.score) || 0, 0) },
++ ],
++ comment: replies.map((p) => ({
++ '@type': 'Comment',
++ text: markdownToText(p.body_markdown, 2000),
++ author: discussionAuthorLd(p.author),
++ datePublished: isoDate(p.created_at) || undefined,
++ url: `${url}#post-${p.id}`,
++ })),
++ };
++}
++
++module.exports = { isoDate, clean, websiteLd, breadcrumbLd, pasteLd, threadLd, discussionAuthorLd };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c419e30..3ab56bf 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -1,13 +1,15 @@
'use strict';
/**
-- * Page shell — every page on the site is server-rendered through this: full <head> SEO
-- * (title, description, canonical, robots, Open Graph, Twitter card, JSON-LD), the shared
++ * Page shell — every page on the site is server-rendered through this: full <head> SEO from
++ * openvibe-shared/seo (description, canonical, robots, Open Graph, Twitter card, JSON-LD and the
++ * ai-summary page summary), the shared
* OpenVibe Frame (theme-loader first so there is no flash, navbar.js + footer.js from the
* Network), this site's small stylesheet and its progressive script.
*/
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
++const seo = require('openvibe-shared/seo');
const fs = require('fs');
const path = require('path');
const config = require('../config');
@@@ -38,11 +40,18 @@ function setRelease(id) { if (id) RELEASE = String(id); }
const abs = (p) => (/^https?:\/\//i.test(p) ? p : `${config.baseUrl}${p.startsWith('/') ? '' : '/'}${p}`);
--function jsonLdScript(objects) {
-- const list = (Array.isArray(objects) ? objects : [objects]).filter(Boolean);
-- if (!list.length) return '';
-- // </script> inside a JSON string would end the block early — encode the angle bracket.
-- return list.map((o) => `<script type="application/ld+json">${JSON.stringify(o).replace(/</g, '\\u003c')}</script>`).join('\n');
++// openvibe-shared/seo cuts descriptions at 160 characters on a word; a longer one loses whole
++// trailing sentences here first, so a cut never leaves half a phrase ("… Free…").
++const DESCRIPTION_MAX = 160;
++function fitDescription(text) {
++ if (text.length <= DESCRIPTION_MAX) return text;
++ let out = '';
++ for (const sentence of text.split(/(?<=[.!?])\s+/)) {
++ const next = out ? `${out} ${sentence}` : sentence;
++ if (next.length > DESCRIPTION_MAX) break;
++ out = next;
++ }
++ return out || text;
}
function navbarInit(opts) {
@@@ -94,7 +103,7 @@ function footerInit(opts) {
/**
* @param {object} o
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
-- * ogImage, jsonLd (array), body (main HTML), active ('home'|'pastes'|'spaces'|'pulse'|'new'|'my'),
++ * ogImage, imageAlt, jsonLd (array), alternates ([{ hreflang, href }]), body (main HTML), active ('home'|'pastes'|'spaces'|'pulse'|'new'|'my'),
* feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),
@@@ -102,7 +111,7 @@ function footerInit(opts) {
*/
function renderPage(o) {
const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — the people of OpenVibe`;
-- const description = (o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300);
++ const description = fitDescription((o.description || DEFAULT_DESCRIPTION).replace(/\s+/g, ' ').trim().slice(0, 300));
const canonical = abs(o.canonicalPath || '/');
const robots = o.robots || 'index,follow';
const ogType = o.ogType || 'website';
@@@ -116,28 +125,16 @@ function renderPage(o) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
--<meta name="description" content="${escapeHtml(description)}">
--<link rel="canonical" href="${escapeHtml(canonical)}">
--<meta name="robots" content="${escapeHtml(robots)}">
--<meta property="og:site_name" content="${SITE_NAME}">
--<meta property="og:type" content="${escapeHtml(ogType)}">
--<meta property="og:title" content="${escapeHtml(o.title || SITE_NAME)}">
--<meta property="og:description" content="${escapeHtml(description)}">
--<meta property="og:url" content="${escapeHtml(canonical)}">
--<meta property="og:image" content="${escapeHtml(ogImage)}">
++${seo.headTags({ title: o.title || SITE_NAME, noTitle: true, siteName: SITE_NAME, description, canonical, robots, type: ogType, image: ogImage, imageAlt: o.imageAlt, largeImage: !!o.ogImage, jsonLd: o.jsonLd, alternates: o.alternates })}
${o.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : ''}
${o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : ''}
--<meta name="twitter:card" content="${o.ogImage ? 'summary_large_image' : 'summary'}">
--<meta name="twitter:title" content="${escapeHtml(o.title || SITE_NAME)}">
--<meta name="twitter:description" content="${escapeHtml(description)}">
--<meta name="twitter:image" content="${escapeHtml(ogImage)}">
++${seo.pageSummary({ title: o.title || SITE_NAME, summary: description, url: canonical, updated: o.modified })}
${require('openvibe-shared/app-icon').headTags({ site: 'community', iconBase: '/assets' })}
${(o.feeds || [{ title: `${SITE_NAME} — latest pastes`, href: '/feed.xml' }]).map((f) => `<link rel="alternate" type="application/rss+xml" title="${escapeHtml(f.title)}" href="${escapeHtml(f.href)}">`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
<link rel="stylesheet" href="${asset('css/community.css')}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
--${jsonLdScript(o.jsonLd)}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${asset('js/community.js')}" defer></script>
@@@ -161,4 +158,4 @@ document.addEventListener('DOMContentLoaded', function () {
</html>`;
}
--module.exports = { renderPage, asset, assetVersion, abs, jsonLdScript, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
++module.exports = { renderPage, asset, assetVersion, abs, setRelease, SITE_NAME, DEFAULT_DESCRIPTION, DEFAULT_OG_IMAGE };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 3a506f3..23d8127 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -7,7 +7,7 @@
*/
const config = require('../config');
const ovServe = require('openvibe-shared/serve');
--const seo = require('../seo');
++const ld = require('./jsonld');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
@@@ -26,7 +26,7 @@ function mediaAbs(u) {
function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
function timeAgo(v) {
-- const iso = seo.isoDate(v);
++ const iso = ld.isoDate(v);
if (!iso) return '';
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return 'just now';
@@@ -37,12 +37,12 @@ function timeAgo(v) {
return `${Math.floor(s / (86400 * 365))}y ago`;
}
function fmtDate(v) {
-- const iso = seo.isoDate(v);
++ const iso = ld.isoDate(v);
if (!iso) return '';
return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');
}
function timeTag(v) {
-- const iso = seo.isoDate(v);
++ const iso = ld.isoDate(v);
return iso ? `<time datetime="${esc(iso)}" title="${esc(fmtDate(v))}">${esc(timeAgo(v))}</time>` : '';
}
function num(n) { return Number(n || 0).toLocaleString('en-US'); }
@@@ -173,7 +173,7 @@ const { url } = await res.json(); // "/p/<slug>"` },
description: 'The people of OpenVibe. A community-run, open source home for pastes, spaces and threads. Free speech within the rules.',
canonicalPath: '/',
active: 'home',
-- jsonLd: [seo.websiteLd()],
++ jsonLd: [ld.websiteLd()],
styles: [showcase.STYLESHEET],
body,
});
@@@ -231,16 +231,16 @@ function browsePage(result, languages) {
canonicalPath: canonical,
robots: q ? 'noindex,follow' : 'index,follow',
active: 'pastes',
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
body,
});
}
// ── Paste page ───────────────────────────────────────────────
function pasteDescription(p) {
-- if (p.ai_summary) return seo.clean(p.ai_summary, 200);
-- if (p.type === 'screenshot') return seo.clean(p.content || `A screenshot shared by ${authorName(p)} on ${SITE_NAME}.`, 200);
-- return seo.clean(`${p.title || 'Paste'} — ${languageLabel(p.language)} paste by ${authorName(p)}: ${String(p.content || '').slice(0, 200)}`, 200);
++ if (p.ai_summary) return ld.clean(p.ai_summary, 200);
++ if (p.type === 'screenshot') return ld.clean(p.content || `A screenshot shared by ${authorName(p)} on ${SITE_NAME}.`, 200);
++ return ld.clean(`${p.title || 'Paste'} — ${languageLabel(p.language)} paste by ${authorName(p)}: ${String(p.content || '').slice(0, 200)}`, 200);
}
function shareLinks(p) {
@@@ -298,13 +298,13 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
const body = `
<article class="paste" itemscope itemtype="https://schema.org/${isShot ? 'ImageObject' : 'Article'}">
-- <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/pastes">Pastes</a> › <span aria-current="page">${esc(seo.clean(title, 60))}</span></nav>
++ <nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/pastes">Pastes</a> › <span aria-current="page">${esc(ld.clean(title, 60))}</span></nav>
<header class="paste-head">
<h1 itemprop="headline">${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}${esc(title)}</h1>
<p class="paste-meta">
${authorHtml(p)}
<span class="sep">·</span>${langBadge(p)}
-- <span class="sep">·</span><time datetime="${esc(seo.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
++ <span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
<span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>
${!isShot && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
${p.visibility && p.visibility !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${p.visibility === 'private' ? 'lock' : 'eye-slash'}" aria-hidden="true"></i> ${esc(p.visibility)}</span>` : ''}
@@@ -335,15 +335,15 @@ ${related && related.length ? `<section class="section" id="related">
robots: indexable ? 'index,follow' : 'noindex,follow',
ogType: 'article',
ogImage: shot || undefined,
-- published: seo.isoDate(p.created_at) || undefined,
-- modified: seo.isoDate(p.updated_at) || undefined,
++ published: ld.isoDate(p.created_at) || undefined,
++ modified: ld.isoDate(p.updated_at) || undefined,
active: 'pastes',
historyType: 'paste',
historyTitle: title,
footerVariant: 'compact',
jsonLd: [
-- seo.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
-- seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
++ ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
++ ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
],
body,
});
ddiff --git a/server/render/pulse.js b/server/render/pulse.js
iindex 5dce113..dcac3c8 100644
---- a/server/render/pulse.js
++++ b/server/render/pulse.js
@@@ -5,7 +5,7 @@
* (server/pulse). Filtering by origin and paging ("Older") are links, so it works without
* JavaScript. AI items carry an AI label and no person; system items name no one either.
*/
--const seo = require('../seo');
++const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { timeTag } = require('./pages');
@@@ -60,7 +60,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null }) {
robots: after ? 'noindex,follow' : 'index,follow',
active: 'pulse',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pulse', url: '/pulse' }])],
body,
});
}
ddiff --git a/server/render/submissions.js b/server/render/submissions.js
iindex b364e97..f67fef3 100644
---- a/server/render/submissions.js
++++ b/server/render/submissions.js
@@@ -5,7 +5,7 @@
* submission, and the moderators' review queue. Filters and paging are links and every action is
* a plain form post, so all of it works without JavaScript.
*/
--const seo = require('../seo');
++const ld = require('./jsonld');
const { renderPage, abs } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { renderMarkdown, markdownToText } = require('./markdown');
@@@ -81,7 +81,7 @@ ${submitForm({ user, values, error })}`;
canonicalPath: listHref({ kind, after }),
robots: mine || after ? 'noindex,follow' : 'index,follow',
active: 'submissions',
-- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],
++ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],
body,
});
}
@@@ -127,7 +127,7 @@ ${reviewForm}`;
active: 'submissions',
published: s.created_at,
modified: s.updated_at,
-- jsonLd: accepted ? [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }, { name: s.title, url: abs(s.page_url) }])] : [],
++ jsonLd: accepted ? [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }, { name: s.title, url: abs(s.page_url) }])] : [],
body,
});
}
ddiff --git a/server/seo.js b/server/seo.js
ddeleted file mode 100644
iindex 07575fa..0000000
---- a/server/seo.js
++++ /dev/null
@@@ -1,253 +0,0 @@
--'use strict';
--
--/**
-- * robots.txt, the dynamic sitemap and the RSS feeds (latest pastes; latest threads overall and
-- * per space). JSON-LD builders live here too so page templates stay about markup.
-- */
--const config = require('./config');
--const catalog = require('./pastes/catalog');
--const { escapeHtml } = require('./render/highlight');
--const { abs, SITE_NAME, DEFAULT_DESCRIPTION } = require('./render/layout');
--
--const SITEMAP_TTL_MS = 60 * 60 * 1000;
--
--// The forum service (server/forum/service.js), when the app has one: threads join the sitemap
--// and get their own feeds.
--let _forum = null;
--function useForum(forum) { _forum = forum || null; _sitemap = null; }
--
--function isoDate(v) {
-- if (!v) return null;
-- const d = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(String(v)) ? `${String(v).replace(' ', 'T')}Z` : v);
-- return Number.isNaN(d.getTime()) ? null : d.toISOString();
--}
--
--function clean(s, max) {
-- const t = String(s || '').replace(/\s+/g, ' ').trim();
-- return t.length > max ? `${t.slice(0, max - 1)}…` : t;
--}
--
--// ── JSON-LD ──────────────────────────────────────────────────
--function websiteLd() {
-- return {
-- '@context': 'https://schema.org', '@type': 'WebSite',
-- name: SITE_NAME, url: `${config.baseUrl}/`, description: DEFAULT_DESCRIPTION,
-- potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${config.baseUrl}/pastes?q={search_term_string}` }, 'query-input': 'required name=search_term_string' },
-- publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
-- };
--}
--
--function breadcrumbLd(items) {
-- return {
-- '@context': 'https://schema.org', '@type': 'BreadcrumbList',
-- itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(it.name, 90), item: abs(it.url) })),
-- };
--}
--
--function authorLd(paste) {
-- const name = paste.display_name || paste.username;
-- if (!name) return { '@type': 'Person', name: 'Anonymous' };
-- return { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(paste.username || name)}` };
--}
--
--function pasteLd(paste, { description, image }) {
-- const isScreenshot = paste.type === 'screenshot';
-- const url = abs(`/p/${paste.slug}`);
-- const base = {
-- '@context': 'https://schema.org',
-- '@type': isScreenshot ? 'ImageObject' : 'Article',
-- headline: clean(paste.title || 'Untitled', 110),
-- name: clean(paste.title || 'Untitled', 110),
-- description,
-- url,
-- mainEntityOfPage: url,
-- author: authorLd(paste),
-- datePublished: isoDate(paste.created_at) || undefined,
-- dateModified: isoDate(paste.updated_at || paste.created_at) || undefined,
-- publisher: { '@type': 'Organization', name: SITE_NAME, url: `${config.baseUrl}/` },
-- interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/ViewAction', userInteractionCount: Number(paste.views) || 0 },
-- };
-- if (isScreenshot) { base.contentUrl = image; base.uploadDate = base.datePublished; }
-- else {
-- base.image = image;
-- base.hasPart = {
-- '@type': 'SoftwareSourceCode',
-- programmingLanguage: paste.language && paste.language !== 'text' ? paste.language : undefined,
-- codeRepository: url,
-- };
-- }
-- return base;
--}
--
--/** Who wrote a thread or post, for JSON-LD: a person, the AI label (never a person), or anonymous. */
--function discussionAuthorLd(a) {
-- if (!a) return { '@type': 'Person', name: 'Anonymous' };
-- if (a.is_ai) return { '@type': 'Organization', name: a.display_name || 'OpenVibe AI' };
-- const name = a.display_name || a.username || 'Anonymous';
-- return a.username ? { '@type': 'Person', name, url: `${config.liveUrl}/@${encodeURIComponent(a.username)}` } : { '@type': 'Person', name };
--}
--
--/** DiscussionForumPosting for a thread page: the opening post, counters and the replies on the page. */
--function threadLd({ space, thread, posts, opening, description }) {
-- const { markdownToText } = require('./render/markdown');
-- const url = abs(`/s/${space.slug}/t/${thread.slug}`);
-- const replies = posts.filter((p) => !p.is_opening && !p.deleted).slice(0, 20);
-- return {
-- '@context': 'https://schema.org',
-- '@type': 'DiscussionForumPosting',
-- headline: clean(thread.title, 110),
-- text: opening && !opening.deleted ? markdownToText(opening.body_markdown, 5000) : description,
-- url,
-- mainEntityOfPage: url,
-- author: discussionAuthorLd(thread.author),
-- datePublished: isoDate(thread.created_at) || undefined,
-- dateModified: isoDate((opening && opening.updated_at) || thread.created_at) || undefined,
-- isPartOf: { '@type': 'CollectionPage', name: space.name, url: abs(`/s/${space.slug}`) },
-- commentCount: thread.reply_count,
-- interactionStatistic: [
-- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: thread.reply_count },
-- { '@type': 'InteractionCounter', interactionType: 'https://schema.org/LikeAction', userInteractionCount: Math.max(Number(thread.score) || 0, 0) },
-- ],
-- comment: replies.map((p) => ({
-- '@type': 'Comment',
-- text: markdownToText(p.body_markdown, 2000),
-- author: discussionAuthorLd(p.author),
-- datePublished: isoDate(p.created_at) || undefined,
-- url: `${url}#post-${p.id}`,
-- })),
-- };
--}
--
--// ── llms.txt: what this site is, for language-model crawlers (llmstxt.org) ────────────
--function llmsTxt() {
-- const u = config.baseUrl;
-- return [
-- '# OpenVibe.Community',
-- '',
-- '> The discussion side of the OpenVibe network: pastes (text and screenshots), spaces with threads, and the Pulse feed of what is happening across the network.',
-- '',
-- '## Browse',
-- `- [Pastes](${u}/pastes): public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)`,
-- `- [Spaces](${u}/s): open discussion spaces and their threads`,
-- `- [Pulse](${u}/pulse): network activity`,
-- '',
-- '## Machine-readable',
-- `- [Sitemap](${u}/sitemap.xml)`,
-- `- [Latest pastes (RSS)](${u}/feed.xml)`,
-- `- [Latest threads (RSS)](${u}/s/feed.xml)`,
-- '',
-- '## Content labels',
-- '- Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.',
-- '',
-- ].join('\n');
--}
--
--// ── robots.txt ───────────────────────────────────────────────
--function robotsTxt() {
-- return [
-- 'User-agent: *',
-- 'Allow: /',
-- 'Disallow: /api/',
-- 'Disallow: /auth/',
-- 'Disallow: /my',
-- 'Disallow: /new',
-- 'Disallow: /s/*/new',
-- 'Disallow: /*?sso=',
-- '',
-- `Sitemap: ${config.baseUrl}/sitemap.xml`,
-- '',
-- ].join('\n');
--}
--
--// ── sitemap.xml (cached ~1h) ─────────────────────────────────
--let _sitemap = null, _sitemapAt = 0;
--async function buildSitemap() {
-- const urls = [];
-- const add = (loc, lastmod, changefreq, priority) => urls.push(
-- ` <url><loc>${escapeHtml(abs(loc))}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}<changefreq>${changefreq}</changefreq><priority>${priority}</priority></url>`
-- );
-- add('/', null, 'hourly', '1.0');
-- add('/pastes', null, 'hourly', '0.8');
-- add('/pastes?sort=views', null, 'daily', '0.5');
-- for (const p of await catalog.recent()) {
-- if (Number(p.is_nsfw) || Number(p.burn_after_read)) continue;
-- add(`/p/${p.slug}`, isoDate(p.updated_at || p.created_at), 'weekly', '0.6');
-- }
-- if (_forum) {
-- add('/s', null, 'hourly', '0.8');
-- add('/pulse', null, 'hourly', '0.5');
-- for (const s of await _forum.publicSpaces()) add(`/s/${s.slug}`, s.last_activity_at || null, 'hourly', '0.7');
-- for (const t of await _forum.recentPublic({ limit: 1000 })) add(`/s/${t.space_slug}/t/${t.slug}`, isoDate(t.last_activity_at || t.created_at), 'daily', '0.6');
-- }
-- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
--}
--async function sitemapHandler(_req, res) {
-- if (!_sitemap || Date.now() - _sitemapAt > SITEMAP_TTL_MS) {
-- try { _sitemap = await buildSitemap(); _sitemapAt = Date.now(); }
-- catch (e) { console.warn('[SEO] sitemap build failed:', e.message); if (!_sitemap) return res.status(503).end(); }
-- }
-- res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(_sitemap);
--}
--
--// ── RSS feed of the latest pastes ────────────────────────────
--async function feedHandler(_req, res) {
-- const items = (await catalog.latest(30)).filter((p) => !Number(p.is_nsfw)).map((p) => {
-- const url = abs(`/p/${p.slug}`);
-- const desc = p.type === 'screenshot' ? 'A screenshot shared on OpenVibe.Community.' : clean(p.content || '', 300);
-- return ` <item>
-- <title>${escapeHtml(p.title || 'Untitled')}</title>
-- <link>${escapeHtml(url)}</link>
-- <guid isPermaLink="true">${escapeHtml(url)}</guid>
-- ${isoDate(p.created_at) ? `<pubDate>${new Date(isoDate(p.created_at)).toUTCString()}</pubDate>` : ''}
-- <dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">${escapeHtml(p.display_name || p.username || 'Anonymous')}</dc:creator>
-- <description>${escapeHtml(desc)}</description>
-- </item>`;
-- });
-- const xml = `<?xml version="1.0" encoding="UTF-8"?>
--<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
-- <channel>
-- <title>${SITE_NAME} — latest pastes</title>
-- <link>${escapeHtml(config.baseUrl)}/pastes</link>
-- <atom:link href="${escapeHtml(config.baseUrl)}/feed.xml" rel="self" type="application/rss+xml"/>
-- <description>${escapeHtml(DEFAULT_DESCRIPTION)}</description>
-- <language>en</language>
--${items.join('\n')}
-- </channel>
--</rss>
--`;
-- res.type('application/rss+xml').set('Cache-Control', 'public, max-age=300').send(xml);
--}
--
--// ── RSS feeds of the latest threads (/s/feed.xml, /s/:space/feed.xml) ──
--function threadFeed({ space = null, threads }) {
-- const { markdownToText } = require('./render/markdown');
-- const items = threads.map((t) => {
-- const url = abs(`/s/${t.space_slug}/t/${t.slug}`);
-- return ` <item>
-- <title>${escapeHtml(t.title)}</title>
-- <link>${escapeHtml(url)}</link>
-- <guid isPermaLink="true">${escapeHtml(url)}</guid>
-- ${isoDate(t.created_at) ? `<pubDate>${new Date(isoDate(t.created_at)).toUTCString()}</pubDate>` : ''}
-- <category>${escapeHtml(t.space_name)}</category>
-- <description>${escapeHtml(markdownToText(t.opening, 300))}</description>
-- </item>`;
-- });
-- const link = abs(space ? `/s/${space.slug}` : '/s');
-- const self = abs(space ? `/s/${space.slug}/feed.xml` : '/s/feed.xml');
-- return `<?xml version="1.0" encoding="UTF-8"?>
--<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
-- <channel>
-- <title>${escapeHtml(space ? `${SITE_NAME} — ${space.name}` : `${SITE_NAME} — latest threads`)}</title>
-- <link>${escapeHtml(link)}</link>
-- <atom:link href="${escapeHtml(self)}" rel="self" type="application/rss+xml"/>
-- <description>${escapeHtml(space ? (space.description || space.name) : 'New threads in the public spaces of OpenVibe.Community.')}</description>
-- <language>en</language>
--${items.join('\n')}
-- </channel>
--</rss>
--`;
--}
--
--function resetCaches() { _sitemap = null; _sitemapAt = 0; }
--
--module.exports = { llmsTxt, isoDate, clean, websiteLd, breadcrumbLd, pasteLd, threadLd, discussionAuthorLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, threadFeed, useForum, resetCaches };
ddiff --git a/server/submissions/routes.js b/server/submissions/routes.js
iindex fb9fa12..7be7fed 100644
---- a/server/submissions/routes.js
++++ b/server/submissions/routes.js
@@@ -17,12 +17,13 @@ const express = require('express');
const pages = require('../render/pages');
const { submissionsPage, submissionPage, reviewQueuePage } = require('../render/submissions');
const { ApiError } = require('../http/v1');
++const cache = require('openvibe-shared/cache-policy');
function createSubmissionPages({ submissions, viewers, config }) {
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '64kb' });
-- const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
++ const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', cache.htmlHeaders({ private: true })).send(body);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..523bc87
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/** Static-asset Cache-Control comes from openvibe-shared/cache-policy: immutable only for the
++ * ?v= that matches the deployed bytes, a short revalidating window for everything else. */
++const assert = require('assert');
++const { assetVersion } = require('../server/render/layout');
++const cache = require('openvibe-shared/cache-policy');
++const { boot, check, done } = require('./helpers/app');
++
++const IMMUTABLE = cache.IMMUTABLE;
++const REVALIDATE = cache.assetHeaders('css/community.css', { hashed: false });
++
++(async () => {
++ const t = await boot();
++
++ assert.strictEqual(IMMUTABLE, 'public, max-age=31536000, immutable');
++ assert.strictEqual(REVALIDATE, 'public, max-age=300, stale-while-revalidate=86400');
++
++ await check('assets: the current ?v= is immutable, a wrong ?v= or none revalidates', async () => {
++ const v = assetVersion('css/community.css');
++ assert.ok(v && v !== 'dev', 'the asset has a real content hash');
++
++ const hashed = await t.get(`/css/community.css?v=${v}`);
++ assert.strictEqual(hashed.status, 200);
++ assert.strictEqual(hashed.headers.get('cache-control'), IMMUTABLE);
++
++ // A well-formed but wrong hash must not buy a year of caching.
++ const wrong = await t.get('/css/community.css?v=deadbeefdeadbeef');
++ assert.strictEqual(wrong.status, 200);
++ assert.strictEqual(wrong.headers.get('cache-control'), REVALIDATE);
++
++ const plain = await t.get('/css/community.css');
++ assert.strictEqual(plain.status, 200);
++ assert.strictEqual(plain.headers.get('cache-control'), REVALIDATE);
++ });
++
++ await check('assets: icons and images revalidate instead of being cached forever', async () => {
++ for (const rel of ['favicon.svg', 'og-default.png']) {
++ const r = await t.get(`/${rel}`);
++ assert.strictEqual(r.status, 200, rel);
++ assert.strictEqual(r.headers.get('cache-control'), cache.assetHeaders(rel, { hashed: false }), rel);
++ assert.strictEqual(r.headers.get('cache-control'), REVALIDATE, rel);
++ }
++ });
++
++ await check('assets: the page references its assets with the same ?v= that is immutable', async () => {
++ const home = await t.get('/');
++ let matches = 0;
++ for (const m of home.text.matchAll(/\/(css|js)\/([a-z0-9._-]+)\?v=([0-9a-f]{8,10})/g)) {
++ matches += 1;
++ const rel = `${m[1]}/${m[2]}`;
++ const r = await t.get(`/${rel}?v=${m[3]}`);
++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE, `${rel}?v=${m[3]}`);
++ }
++ assert.ok(matches > 0, 'page references versioned assets');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..2fc0b32
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * The discovery data (server/discovery.js), the JSON-LD builders (server/render/jsonld.js) and the
++ * page head (server/render/layout.js) as openvibe-shared/seo writes them, without booting the app.
++ */
++process.env.NODE_ENV = 'test';
++process.env.BASE_URL = 'https://openvibe.community';
++process.env.OV_LIVE_URL = 'https://openvibe.live';
++const assert = require('assert');
++const seo = require('openvibe-shared/seo');
++const discovery = require('../server/discovery');
++const catalog = require('../server/pastes/catalog');
++const ld = require('../server/render/jsonld');
++const { renderPage } = require('../server/render/layout');
++const { check, done } = require('./helpers/app');
++
++(async () => {
++ await check('robots.txt keeps the disallow set for every crawler and names the sitemap', () => {
++ const txt = seo.robotsTxt({ sitemaps: ['https://openvibe.community/sitemap.xml'], disallow: discovery.ROBOTS_DISALLOW });
++ assert.deepStrictEqual(discovery.ROBOTS_DISALLOW, ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']);
++ assert.ok(txt.startsWith('User-agent: *\nAllow: /\nDisallow: /api/\n'));
++ assert.strictEqual(txt.split('Disallow: /*?sso=').length - 1, 2, 'the generic and the search/AI group both disallow');
++ assert.ok(txt.endsWith('Sitemap: https://openvibe.community/sitemap.xml\n'));
++ });
++
++ await check('llms.txt sections link the site, the feeds and llms-full.txt', () => {
++ const txt = seo.llmsTxt({ name: 'OpenVibe.Community', summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, sections: discovery.llmsSections() });
++ assert.ok(txt.includes('## Browse\n\n- [Pastes](https://openvibe.community/pastes): public pastes, newest first'));
++ assert.ok(txt.includes('- [Latest threads (RSS)](https://openvibe.community/s/feed.xml)'));
++ assert.ok(txt.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));
++ assert.ok(txt.includes('labelled as AI-generated'));
++ });
++
++ await check('llms-full.txt keeps opening posts past 4,000 characters', async () => {
++ const latest = catalog.latest;
++ catalog.latest = async () => [];
++ discovery.useForum({ recentPublic: async () => [{
++ title: 'Long thread', space_name: 'General', space_slug: 'general', slug: 'long-thread',
++ opening: 'First ' + 'x'.repeat(4100) + ' THREAD_END',
++ }] });
++ try {
++ const sections = await discovery.llmsFullSections();
++ assert.ok(sections[1].pages[0].text.endsWith('THREAD_END'));
++ assert.ok(sections[1].pages[0].text.length > 4000);
++ } finally {
++ discovery.useForum(null);
++ catalog.latest = latest;
++ }
++ });
++
++ await check('thread feed items use the 2.5.0 feedXml shape and keep the thread link as guid', () => {
++ const items = discovery.threadFeedItems([{ title: 'Hi <b>', space_slug: 'general', slug: 'hi-1', opening: 'Some **text**', created_at: '2026-09-15 10:00:00', last_activity_at: '2026-09-16 10:00:00' }]);
++ assert.deepStrictEqual(Object.keys(items[0]), ['title', 'link', 'guid', 'description', 'content', 'author', 'published', 'updated']);
++ assert.strictEqual(items[0].link, 'https://openvibe.community/s/general/t/hi-1');
++ assert.strictEqual(items[0].published, '2026-09-15T10:00:00.000Z');
++ assert.strictEqual(items[0].updated, '2026-09-16T10:00:00.000Z');
++ const xml = seo.feedXml({ title: 't', link: 'https://openvibe.community/s', description: 'd', selfUrl: 'https://openvibe.community/s/feed.xml', items }, { format: 'rss' });
++ assert.ok(xml.includes('<atom:link href="https://openvibe.community/s/feed.xml" rel="self" type="application/rss+xml"/>'));
++ assert.ok(xml.includes('<title>Hi &lt;b&gt;</title>'));
++ assert.ok(xml.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));
++ });
++
++ await check('JSON-LD: thread posting with comments, people linked to Live, AI never a person', () => {
++ const thread = { slug: 'hi-1', title: 'Hi', created_at: '2026-09-15 10:00:00', reply_count: 1, score: 2, author: { username: 'alex', display_name: 'Alex' } };
++ const posts = [{ id: 1, is_opening: true, body_markdown: 'open' }, { id: 2, body_markdown: 'reply', author: { is_ai: true, display_name: 'OpenVibe AI' }, created_at: '2026-09-15 11:00:00' }];
++ const out = ld.threadLd({ space: { slug: 'general', name: 'General' }, thread, posts, opening: posts[0], description: 'd' });
++ assert.strictEqual(out['@type'], 'DiscussionForumPosting');
++ assert.deepStrictEqual(out.author, { '@type': 'Person', name: 'Alex', url: 'https://openvibe.live/@alex' });
++ assert.deepStrictEqual(out.comment[0].author, { '@type': 'Organization', name: 'OpenVibe AI' });
++ assert.strictEqual(out.comment[0].url, 'https://openvibe.community/s/general/t/hi-1#post-2');
++ assert.strictEqual(ld.isoDate('nope'), null);
++ });
++
++ await check('page head: one canonical, og/twitter from headTags, ai-summary, article times, JSON-LD escaped', () => {
++ const page = renderPage({ title: 'Hello', description: 'A paste.', canonicalPath: '/p/x', ogType: 'article', published: '2026-09-15T10:00:00.000Z', jsonLd: [{ '@type': 'Thing', name: '</script><b>' }] });
++ const head = page.split('</head>')[0];
++ assert.ok(head.includes('<title>Hello · OpenVibe.Community</title>'));
++ assert.strictEqual((head.match(/<title>/g) || []).length, 1);
++ assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe.community\/p\/x">/g) || []).length, 1);
++ assert.ok(head.includes('<meta property="og:type" content="article">'));
++ assert.ok(head.includes('<meta name="twitter:card" content="summary">'), 'no large card without a page image');
++ assert.ok(head.includes('<meta name="ai-summary" content="A paste.">'));
++ assert.ok(head.includes('<meta property="article:published_time" content="2026-09-15T10:00:00.000Z">'));
++ assert.ok(head.includes('"name":"\\u003c/script>\\u003cb>"') && !head.includes('</script><b>'));
++ });
++
++ await check('a description over 160 characters loses whole sentences, never half a phrase', () => {
++ const long = `${'A sentence that is long enough. '.repeat(4)}The people of OpenVibe made this. Free speech within the rules.`;
++ const head = renderPage({ description: long }).split('</head>')[0];
++ const [, desc] = head.match(/<meta name="description" content="([^"]*)">/);
++ assert.ok(desc.length <= 160 && desc.endsWith('.') && !/Free/.test(desc), desc);
++ });
++
++ done();
++})();
ddiff --git a/test/forum-ssr.test.js b/test/forum-ssr.test.js
iindex a75a778..eac0dc2 100644
---- a/test/forum-ssr.test.js
++++ b/test/forum-ssr.test.js
@@@ -165,7 +165,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const feed = await t.get('/s/feed.xml');
assert.ok(!feed.text.includes('Members only chat'));
assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
-- require('../server/seo').resetCaches();
++ require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
assert.ok(!map.text.includes('members-only-chat'));
});
@@@ -180,7 +180,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
});
await check('sitemap, feeds and robots.txt include the forum', async () => {
-- require('../server/seo').resetCaches();
++ require('../server/discovery').resetCaches();
const map = await t.get('/sitemap.xml');
for (const loc of ['/s', '/s/general', '/pulse', url]) has(map.text, `<loc>https://openvibe.community${loc}</loc>`);
const feed = await t.get('/s/feed.xml');
ddiff --git a/test/seo.test.js b/test/seo.test.js
iindex 1ad5226..17106cf 100644
---- a/test/seo.test.js
++++ b/test/seo.test.js
@@@ -1,6 +1,8 @@
'use strict';
--/** robots.txt, sitemap.xml, feed.xml, and the static-asset cache policy. */
++/** robots.txt, llms.txt, llms-full.txt, sitemap.xml, feed.xml (all written by openvibe-shared/seo), the
++ * page head, the JSON-LD builders (server/render/jsonld.js) and the cache policy (openvibe-shared/cache-policy). */
const assert = require('assert');
++const cache = require('openvibe-shared/cache-policy');
const { boot, check, done } = require('./helpers/app');
(async () => {
@@@ -19,8 +21,48 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.text.includes('User-agent: *\nAllow: /'));
-- for (const d of ['/api/', '/auth/', '/my', '/new']) assert.ok(r.text.includes(`Disallow: ${d}`));
++ for (const d of ['/api/', '/auth/', '/my', '/new', '/s/*/new', '/*?sso=']) assert.ok(r.text.includes(`Disallow: ${d}\n`), d);
assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));
++ assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
++ });
++
++ await check('llms.txt maps the site; llms-full.txt carries the public pastes in full', async () => {
++ const r = await t.get('/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.headers.get('content-type').startsWith('text/plain'));
++ assert.ok(r.text.startsWith('# OpenVibe.Community\n'));
++ assert.ok(r.text.includes('- [Latest pastes (RSS)](https://openvibe.community/feed.xml)'));
++ assert.ok(r.text.includes('- [Full text for language models](https://openvibe.community/llms-full.txt)'));
++ const full = await t.get('/llms-full.txt');
++ assert.strictEqual(full.status, 200);
++ assert.ok(full.headers.get('content-type').startsWith('text/plain'));
++ assert.strictEqual(full.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
++ assert.ok(full.text.includes('### Hello world in JavaScript\n\nURL: https://openvibe.community/p/amber-fox-42\n\nconst x = 1;'));
++ assert.ok(!full.text.includes('dark-owl-55') && !full.text.includes('grim-vale-61') && !full.text.includes('secret notes'));
++
++ // The catalog keeps a short preview for a few seconds. Discovery must read the
++ // current full row and omit it if its visibility changes after the listing.
++ const long = 'a'.repeat(4200) + ' END_OF_PASTE';
++ const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;
++ try {
++ await t.db.prepare('UPDATE pastes SET content = ? WHERE slug = ?').run(long, 'amber-fox-42');
++ const expanded = await t.get('/llms-full.txt');
++ assert.ok(expanded.text.includes(long), 'the full paste survives the preview and 4,000-character limits');
++ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views, before);
++
++ await t.db.prepare("UPDATE pastes SET visibility = 'private' WHERE slug = ?").run('amber-fox-42');
++ assert.ok(!(await t.get('/llms-full.txt')).text.includes('amber-fox-42'), 'cached public preview cannot expose a private paste');
++
++ await t.db.prepare("UPDATE pastes SET visibility = 'public', burn_after_read = 1 WHERE slug = ?").run('amber-fox-42');
++ assert.ok(!(await t.get('/llms-full.txt')).text.includes('amber-fox-42'), 'a newly burning paste is omitted without a view');
++ assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views, before);
++
++ await t.db.prepare('UPDATE pastes SET burn_after_read = 0, is_nsfw = 1 WHERE slug = ?').run('amber-fox-42');
++ assert.ok(!(await t.get('/llms-full.txt')).text.includes('amber-fox-42'), 'a newly NSFW paste is omitted');
++ } finally {
++ await t.db.prepare('UPDATE pastes SET content = ?, visibility = ?, burn_after_read = 0, is_nsfw = 0 WHERE slug = ?')
++ .run('const x = 1;\n', 'public', 'amber-fox-42');
++ }
});
await check('sitemap.xml lists home, /pastes and every recent public paste — never unlisted/private', async () => {
@@@ -34,6 +76,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(r.text.includes('<lastmod>2026-09-15</lastmod>'));
assert.ok(r.text.includes('/p/fair-moon-23'));
assert.ok(!r.text.includes('dark-owl-55') && !r.text.includes('grim-vale-61'));
++ assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 3600 }));
});
await check('feed.xml is a valid-looking RSS of the latest pastes', async () => {
@@@ -41,7 +84,50 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('<rss version="2.0"'));
assert.ok(r.text.includes('<link>https://openvibe.community/p/amber-fox-42</link>'));
-- assert.ok(r.text.includes('<dc:creator'));
++ assert.ok(r.text.includes('<atom:link href="https://openvibe.community/feed.xml" rel="self" type="application/rss+xml"/>'));
++ assert.ok(r.text.includes('<author>Alex</author>'));
++ assert.ok(r.text.includes('<pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate>'));
++ assert.ok(!r.text.includes('secret notes'));
++ assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ maxAge: 900 }));
++ });
++
++ await check('a page head has the canonical, Open Graph, Twitter and ai-summary tags from openvibe-shared/seo', async () => {
++ const r = await t.get('/p/amber-fox-42');
++ assert.strictEqual(r.status, 200);
++ const head = r.text.split('</head>')[0];
++ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.community/p/amber-fox-42">'));
++ assert.ok(head.includes('<meta property="og:url" content="https://openvibe.community/p/amber-fox-42">'));
++ assert.ok(head.includes('<meta property="og:title" content="Hello world in JavaScript">'));
++ assert.ok(head.includes('<meta property="og:site_name" content="OpenVibe.Community">'));
++ assert.match(head, /<meta property="og:description" content="[^"]+">/);
++ assert.match(head, /<meta name="twitter:title" content="Hello world in JavaScript">/);
++ assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
++ assert.match(head, /<meta property="article:published_time" content="2026-09-15T10:00:00.000Z">/);
++ assert.ok(head.includes('"@type":"BreadcrumbList"') && head.includes('"@type":"WebPage"'));
++ assert.strictEqual((head.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
++ });
++
++ await check('HTML pages carry the private page policy; raw and download never reach a shared cache', async () => {
++ for (const path of ['/', '/pastes', '/p/amber-fox-42', '/updates', '/nope-not-a-page']) {
++ const r = await t.get(path);
++ assert.ok(r.headers.get('content-type').startsWith('text/html'), path);
++ assert.strictEqual(r.headers.get('cache-control'), cache.htmlHeaders({ private: true }), path);
++ }
++ assert.strictEqual((await t.get('/p/amber-fox-42/raw')).headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/p/amber-fox-42/download')).headers.get('cache-control'), 'private, no-store');
++ });
++
++ await check('JSON-LD builders (server/render/jsonld.js): absolute breadcrumbs, a paste Article, AI never a person', async () => {
++ const ld = require('../server/render/jsonld'); // after boot: the test config is loaded
++ const crumbs = ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }]);
++ assert.deepStrictEqual(crumbs.itemListElement.map((i) => i.item), ['https://openvibe.community/', 'https://openvibe.community/pastes']);
++ const paste = ld.pasteLd({ slug: 'amber-fox-42', type: 'paste', title: 'Hi', language: 'javascript', created_at: '2026-09-15 10:00:00', views: 3 }, { description: 'd', image: 'i' });
++ assert.strictEqual(paste['@type'], 'Article');
++ assert.strictEqual(paste.datePublished, '2026-09-15T10:00:00.000Z');
++ assert.strictEqual(paste.hasPart.programmingLanguage, 'javascript');
++ assert.deepStrictEqual(ld.discussionAuthorLd({ is_ai: true, display_name: 'OpenVibe AI' }), { '@type': 'Organization', name: 'OpenVibe AI' });
++ assert.strictEqual(ld.websiteLd().potentialAction.target.urlTemplate, 'https://openvibe.community/pastes?q={search_term_string}');
++ assert.strictEqual(ld.clean('a b c', 4), 'a b…');
});
await check('assets: hashed ?v= is immutable, everything else revalidates; the page references the hashed URLs', async () => {
@@@ -49,9 +135,9 @@ const { boot, check, done } = require('./helpers/app');
const m = home.text.match(/\/css\/community\.css\?v=([0-9a-f]{10})/);
assert.ok(m, 'hashed css url in page');
const hashed = await t.get(`/css/community.css?v=${m[1]}`);
-- assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ assert.strictEqual(hashed.headers.get('cache-control'), cache.IMMUTABLE);
const plain = await t.get('/css/community.css');
-- assert.strictEqual(plain.headers.get('cache-control'), 'no-cache');
++ assert.strictEqual(plain.headers.get('cache-control'), cache.assetHeaders('css/community.css', { hashed: false }));
const fav = await t.get('/favicon.svg');
assert.strictEqual(fav.status, 200);
const og = await t.get('/og-default.png');