openvibers/openvibe.community Commits
Serve discovery, page heads and cache headers from openvibe-shared (#11)
Changes
@@@ -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`. Thesitemap 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 labelvotes.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 templatesrender/forum.js spaces / threads / thread / new-thread / members-only teaser templatesrender/pulse.js the /pulse page@@@ -770,7 +783,7 @@ server/render/comments.js a comment thread's own pagerender/markdown.js the safe Markdown subset for postsrender/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 itemspublic/ 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@@@ -40,5 +40,5 @@"N-1 compatibility tests","per-actor limits (openvibe-sdk/limits)"],-- "updated": "2026-09-28"++ "updated": "2026-10-03"}@@@ -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 || ''));}));@@@ -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 });@@@ -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) {nnew file mode 100644@@@ -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 };@@@ -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); }});@@@ -344,6 +344,13 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn { 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);@@@ -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),};@@@ -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 postcanonicalPath: '/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, vieweconst 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, viewefooterVariant: '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,});@@@ -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, uconst 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, ufooterVariant: '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}nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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 = 'neconst 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,});@@@ -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,});}@@@ -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,});}ddeleted file mode 100644@@@ -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 };@@@ -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)}`);nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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 <b></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();++})();@@@ -165,7 +165,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toStringconst 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');@@@ -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');
@@ -156,6 +156,7 @@ API and machine endpoints:
@@ -156,6 +156,7 @@ API and machine endpoints:
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `GET /auth/me` | Offline-verified profile from `ov_token` |
| `POST /auth/refresh` | Rotate via refresh token |
| `POST /auth/refresh` | Rotate via refresh token |
| `GET /robots.txt`, `GET /sitemap.xml`, `GET /feed.xml` | SEO + RSS of the latest pastes |
| `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) |
| `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
**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`.
@@ -604,12 +605,23 @@ per-address form limits. `test/actor-limits.test.js`.
## SEO
## 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
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
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
## Configuration
@@ -762,7 +774,8 @@ server/
@@ -762,7 +774,8 @@ server/
identity/authors.js author display from subject_projection; the AI label
identity/authors.js author display from subject_projection; the AI label
votes.js race-safe up/down votes (comments, threads)
votes.js race-safe up/down votes (comments, threads)
limits.js per-person write limits
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/pages.js home / browse / paste / new / my / error templates
render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/forum.js spaces / threads / thread / new-thread / members-only teaser templates
render/pulse.js the /pulse page
render/pulse.js the /pulse page
@@ -770,7 +783,7 @@ server/
@@ -770,7 +783,7 @@ server/
render/comments.js a comment thread's own page
render/comments.js a comment thread's own page
render/markdown.js the safe Markdown subset for posts
render/markdown.js the safe Markdown subset for posts
render/highlight.js highlight.js wrapper, language list, download extensions
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
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)
(openvibe-shared is the pinned OpenVibe.Shared v2.2.0 release, installed by npm)
deploy/ systemd unit, nginx vhost
deploy/ systemd unit, nginx vhost
@@ -40,5 +40,5 @@
@@ -40,5 +40,5 @@
"N-1 compatibility tests",
"N-1 compatibility tests",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
],
],
- "updated": "2026-09-28"
+ "updated": "2026-10-03"
}
}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* 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/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 /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 /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 /s … spaces, threads, posts (forum/routes.js) POST /release-metrics (open tabs' update reports)
* GET /pulse the network's public activity
* GET /pulse the network's public activity
@@ -34,9 +34,9 @@ const rateLimit = require('express-rate-limit');
@@ -34,9 +34,9 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const config = require('./config');
const catalog = require('./pastes/catalog');
const catalog = require('./pastes/catalog');
const source = require('./pastes/source');
const source = require('./pastes/source');
-const seo = require('./seo');
+const discovery = require('./discovery');
const pages = require('./render/pages');
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 { createAuthClient, createAuthRoutes } = require('./auth/routes');
const { getDb } = require('./db');
const { getDb } = require('./db');
const { createNetworkIdentity } = require('./identity/network');
const { createNetworkIdentity } = require('./identity/network');
@@ -63,11 +63,16 @@ const { createSubmissionPages } = require('./submissions/routes');
@@ -63,11 +63,16 @@ const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
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 PUBLIC_DIR = path.join(__dirname, '..', 'public');
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const pasteRef = (slug) => ({ service: 'community', type: 'paste', id: String(slug) });
const pasteRef = (slug) => ({ service: 'community', type: 'paste', id: String(slug) });
const VERSION = require('../package.json').version;
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 = {}) {
async function createApp(opts = {}) {
const app = express();
const app = express();
@@ -165,7 +170,7 @@ async function createApp(opts = {}) {
@@ -165,7 +170,7 @@ async function createApp(opts = {}) {
const comments = createCommentService({ db, network, limits: opts.commentLimits });
const comments = createCommentService({ db, network, limits: opts.commentLimits });
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
// 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 });
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 });
Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow });
if (opts.startRelay !== false) relay.start();
if (opts.startRelay !== false) relay.start();
@@ -196,7 +201,7 @@ async function createApp(opts = {}) {
@@ -196,7 +201,7 @@ async function createApp(opts = {}) {
handler: (req, res) => {
handler: (req, res) => {
const error = 'Too many anonymous posts — sign in or try again later';
const error = 'Too many anonymous posts — sign in or try again later';
if (req.originalUrl.startsWith('/api/')) return res.status(429).json({ error });
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 = {}) {
@@ -252,17 +257,26 @@ async function createApp(opts = {}) {
setHeaders(res, filePath) {
setHeaders(res, filePath) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
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 ────────────────────────────────────
// ── 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).
// GET /<key>.txt — the IndexNow key file (only when a key is configured; it serves itself).
if (indexnow.enabled) app.use(indexnow.keyFile);
if (indexnow.enabled) app.use(indexnow.keyFile);
@@ -270,7 +284,9 @@ async function createApp(opts = {}) {
@@ -270,7 +284,9 @@ async function createApp(opts = {}) {
// Pages are for browsers: the viewer (subject, staff) is resolved here too.
// Pages are for browsers: the viewer (subject, staff) is resolved here too.
const withUser = viewers.middleware({ services: false });
const withUser = viewers.middleware({ services: false });
const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
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) => {
app.get('/', withUser, wrap(async (req, res) => {
const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);
const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);
@@ -355,7 +371,7 @@ async function createApp(opts = {}) {
@@ -355,7 +371,7 @@ async function createApp(opts = {}) {
try {
try {
const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
const out = await app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
if (out.redirect) return res.redirect(302, out.redirect);
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);
res.type('text/plain; charset=utf-8').send(out.content);
} catch (err) {
} 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.');
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 = {}) {
@@ -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; }
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`);
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('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 || ''));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
}));
}));
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const REFRESH_COOKIE = 'ov_refresh';
@@ -392,7 +393,7 @@ function createAuthRoutes(config, auth) {
@@ -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
// 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
// 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.
// 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);
const claims = await auth.verify(token);
if (!claims) return res.status(401).json({ error: 'Invalid or expired 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 });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
@@ -15,6 +15,7 @@ const express = require('express');
@@ -15,6 +15,7 @@ const express = require('express');
const pages = require('../render/pages');
const pages = require('../render/pages');
const { threadPage } = require('../render/comments');
const { threadPage } = require('../render/comments');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
+const cache = require('openvibe-shared/cache-policy');
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -22,7 +23,7 @@ function createCommentPages({ comments, viewers, config }) {
@@ -22,7 +23,7 @@ function createCommentPages({ comments, viewers, config }) {
const router = express.Router();
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '64kb' });
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 wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
function failPage(req, res, err, next) {
function failPage(req, res, err, next) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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 };
@@ -20,7 +20,10 @@
@@ -20,7 +20,10 @@
*/
*/
const express = require('express');
const express = require('express');
const multer = require('multer');
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 pages = require('../render/pages');
const forumPages = require('../render/forum');
const forumPages = require('../render/forum');
const boardPages = require('../render/board');
const boardPages = require('../render/board');
@@ -47,7 +50,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -47,7 +50,7 @@ function createForumRoutes({ forum, viewers, config }) {
return ids;
return ids;
}
}
const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
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 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);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -86,7 +89,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -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 };
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 {
try {
const out = await forum.createSpace(req.viewer, values);
const out = await forum.createSpace(req.viewer, values);
- seo.resetCaches();
+ discovery.resetCaches();
res.redirect(303, `/s/${out.space.slug}`);
res.redirect(303, `/s/${out.space.slug}`);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 403) return failPage(req, res, err, next);
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 }) {
@@ -103,15 +106,23 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} 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);
const space = (await forum.publicSpaces()).find((s) => s.slug === req.params.space);
if (!space) return res.status(404).type('text/plain').send('Not found');
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) => {
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
try {
try {
@@ -143,7 +154,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -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).
// 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; }
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);
const out = await forum.createThread(req.viewer, req.params.space, values);
- seo.resetCaches();
+ discovery.resetCaches();
res.redirect(303, out.thread.url);
res.redirect(303, out.thread.url);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
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 }) {
@@ -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) => {
router.post('/s/:space/t/:slug/members-only', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
await forum.setThreadMembersOnly(req.viewer, req.params.space, req.params.slug, { members_only: (req.body || {}).on === '1' ? true : null });
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));
res.redirect(303, back(req));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -232,7 +243,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -232,7 +243,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/t/:slug/crosspost', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
try {
try {
const out = await forum.crosspost(req.viewer, req.params.space, req.params.slug, { to: String((req.body || {}).to || '') });
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);
res.redirect(303, out.thread.url);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -242,7 +253,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -242,7 +253,7 @@ function createForumRoutes({ forum, viewers, config }) {
const b = req.body || {};
const b = req.body || {};
try {
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 });
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)}`);
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
@@ -283,7 +294,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -283,7 +294,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, async (req, res, next) => {
try {
try {
await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
await forum.deleteThread(req.viewer, req.params.space, req.params.slug);
- seo.resetCaches();
+ discovery.resetCaches();
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
res.redirect(303, `/s/${encodeURIComponent(req.params.space)}`);
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
});
});
@@ -344,6 +344,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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) };
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. */
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
async get(v, slug, ctx = {}) {
const p = await visible(v, slug);
const p = await visible(v, slug);
@@ -19,6 +19,7 @@ function localSource(service) {
@@ -19,6 +19,7 @@ function localSource(service) {
service,
service,
listPastes: (query, ctx = {}) => service.list(who(ctx), query || {}),
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,
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 || {}),
listByUser: (username, query, ctx = {}) => service.byUser(who(ctx), username, query || {}),
createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),
createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),
};
};
@@ -35,6 +36,7 @@ module.exports = {
@@ -35,6 +36,7 @@ module.exports = {
get local() { return !!(current && current.local); },
get local() { return !!(current && current.local); },
listPastes: (...a) => current.listPastes(...a),
listPastes: (...a) => current.listPastes(...a),
getPaste: (...a) => current.getPaste(...a),
getPaste: (...a) => current.getPaste(...a),
+ publicForDiscovery: (...a) => current.publicForDiscovery(...a),
listByUser: (...a) => current.listByUser(...a),
listByUser: (...a) => current.listByUser(...a),
createPaste: (...a) => current.createPaste(...a),
createPaste: (...a) => current.createPaste(...a),
};
};
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* (subreddit-like cards, votes, hot/new/top); both share threads, posts, ratings, images and pastes.
* (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.
* 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 { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
@@ -25,7 +25,7 @@ const plural = (n, one, many) => `${num(n)} ${n === 1 ? one : many}`;
function boardRow(sp) {
function boardRow(sp) {
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const icon = sp.style === 'forum' ? 'fa-comments' : 'fa-fire';
const last = sp.last_post
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>';
: '<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>` : '';
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>
return `<tr>
@@ -66,7 +66,7 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
@@ -66,7 +66,7 @@ ${user ? '' : '<p class="muted small">Sign in with your OpenVibe account to post
canonicalPath: '/s',
canonicalPath: '/s',
active: 'spaces',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
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,
body,
});
});
}
}
@@ -144,7 +144,7 @@ ${viewer.can_moderate ? settingsForm(space, groups) : ''}`;
@@ -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',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
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,
body,
});
});
}
}
@@ -202,7 +202,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -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 pagerHtml = f.pager((n) => `${base}${n > 1 ? `?page=${n}` : ''}`, page, pages, ['Previous', 'Next']);
const body = `
const body = `
<article class="thread forum-topic">
<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">
<header class="thread-head">
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
${space.votes === false ? '' : f.voteForm(base, thread, viewer)}
<div>
<div>
@@ -218,7 +218,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -218,7 +218,7 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
${pagerHtml}
${pagerHtml}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</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({
return renderPage({
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
description,
description,
@@ -233,8 +233,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
@@ -233,8 +233,8 @@ function forumTopicPage({ space, thread, posts, page, pages, perPage = 50, viewe
footerVariant: 'compact',
footerVariant: 'compact',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
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,
body,
});
});
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* moderator buttons are plain form posts. Every user value is escaped; post bodies are the safe
* Markdown renderer's output (render/markdown.js).
* Markdown renderer's output (render/markdown.js).
*/
*/
-const seo = require('../seo');
+const ld = require('./jsonld');
const { renderPage } = require('./layout');
const { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { markdownToText } = require('./markdown');
const { markdownToText } = require('./markdown');
@@ -73,7 +73,7 @@ ${list}`;
@@ -73,7 +73,7 @@ ${list}`;
canonicalPath: '/s',
canonicalPath: '/s',
active: 'spaces',
active: 'spaces',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
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,
body,
});
});
}
}
@@ -144,7 +144,7 @@ ${footer}`;
@@ -144,7 +144,7 @@ ${footer}`;
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
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,
body,
});
});
}
}
@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -253,7 +253,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
const body = `
const body = `
<article class="thread">
<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">
<header class="thread-head">
${space.votes === false ? '' : voteForm(base, thread, viewer)}
${space.votes === false ? '' : voteForm(base, thread, viewer)}
<div>
<div>
@@ -268,7 +268,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -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'])}
${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>`}
${page === pages ? replyBlock : `<p class="muted"><a href="${esc(base)}?page=${pages}#reply">Go to the last page to reply</a></p>`}
</article>`;
</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({
return renderPage({
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
title: `${thread.title}${page > 1 ? ` (page ${page})` : ''}`,
description,
description,
@@ -283,8 +283,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -283,8 +283,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
footerVariant: 'compact',
footerVariant: 'compact',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [
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,
body,
});
});
@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
@@ -336,7 +336,7 @@ function membersOnlyPage({ space, thread = null, members_only: mo = null, reason
: reason === 'vip_unavailable' || reason === 'entitlement_unknown'
: 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">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>';
: '<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 = `
const body = `
<header class="page-head">
<header class="page-head">
${crumbs}
${crumbs}
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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 };
@@ -1,13 +1,15 @@
@@ -1,13 +1,15 @@
'use strict';
'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
* 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.
* Network), this site's small stylesheet and its progressive script.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
+const seo = require('openvibe-shared/seo');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const config = require('../config');
const config = require('../config');
@@ -38,11 +40,18 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -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}`);
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) {
function navbarInit(opts) {
@@ -94,7 +103,7 @@ function footerInit(opts) {
@@ -94,7 +103,7 @@ function footerInit(opts) {
/**
/**
* @param {object} o
* @param {object} o
* title, description, canonicalPath, robots ('index,follow'), ogType ('website'|'article'),
* 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),
* feeds ([{ title, href }] RSS alternates; default: the latest-pastes feed),
* historyType ('page'|'paste'), historyTitle, footerVariant ('full'|'compact'), bodyClass,
* 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']),
* styles (Shared stylesheets by name, linked before community.css so this site's rules win: ['showcase.css']),
@@ -102,7 +111,7 @@ function footerInit(opts) {
@@ -102,7 +111,7 @@ function footerInit(opts) {
*/
*/
function renderPage(o) {
function renderPage(o) {
const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME} — the people of OpenVibe`;
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 canonical = abs(o.canonicalPath || '/');
const robots = o.robots || 'index,follow';
const robots = o.robots || 'index,follow';
const ogType = o.ogType || 'website';
const ogType = o.ogType || 'website';
@@ -116,28 +125,16 @@ function renderPage(o) {
@@ -116,28 +125,16 @@ function renderPage(o) {
<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>${escapeHtml(title)}</title>
<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.published ? `<meta property="article:published_time" content="${escapeHtml(o.published)}">` : ''}
${o.modified ? `<meta property="article:modified_time" content="${escapeHtml(o.modified)}">` : ''}
${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' })}
${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')}
${(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>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
${(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="${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">
<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('navbar.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${asset('js/community.js')}" defer></script>
<script src="${asset('js/community.js')}" defer></script>
@@ -161,4 +158,4 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -161,4 +158,4 @@ document.addEventListener('DOMContentLoaded', function () {
</html>`;
</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 };
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
*/
*/
const config = require('../config');
const config = require('../config');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
-const seo = require('../seo');
+const ld = require('./jsonld');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const { renderPage, SITE_NAME, DEFAULT_OG_IMAGE } = require('./layout');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
@@ -26,7 +26,7 @@ function mediaAbs(u) {
@@ -26,7 +26,7 @@ function mediaAbs(u) {
function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
function timeAgo(v) {
function timeAgo(v) {
- const iso = seo.isoDate(v);
+ const iso = ld.isoDate(v);
if (!iso) return '';
if (!iso) return '';
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 60) return 'just now';
@@ -37,12 +37,12 @@ function timeAgo(v) {
@@ -37,12 +37,12 @@ function timeAgo(v) {
return `${Math.floor(s / (86400 * 365))}y ago`;
return `${Math.floor(s / (86400 * 365))}y ago`;
}
}
function fmtDate(v) {
function fmtDate(v) {
- const iso = seo.isoDate(v);
+ const iso = ld.isoDate(v);
if (!iso) return '';
if (!iso) return '';
return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');
return new Date(iso).toUTCString().replace(/:\d\d GMT$/, ' UTC');
}
}
function timeTag(v) {
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>` : '';
return iso ? `<time datetime="${esc(iso)}" title="${esc(fmtDate(v))}">${esc(timeAgo(v))}</time>` : '';
}
}
function num(n) { return Number(n || 0).toLocaleString('en-US'); }
function num(n) { return Number(n || 0).toLocaleString('en-US'); }
@@ -173,7 +173,7 @@ const { url } = await res.json(); // "/p/<slug>"` },
@@ -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.',
description: 'The people of OpenVibe. A community-run, open source home for pastes, spaces and threads. Free speech within the rules.',
canonicalPath: '/',
canonicalPath: '/',
active: 'home',
active: 'home',
- jsonLd: [seo.websiteLd()],
+ jsonLd: [ld.websiteLd()],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body,
body,
});
});
@@ -231,16 +231,16 @@ function browsePage(result, languages) {
@@ -231,16 +231,16 @@ function browsePage(result, languages) {
canonicalPath: canonical,
canonicalPath: canonical,
robots: q ? 'noindex,follow' : 'index,follow',
robots: q ? 'noindex,follow' : 'index,follow',
active: 'pastes',
active: 'pastes',
- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }])],
body,
body,
});
});
}
}
// ── Paste page ───────────────────────────────────────────────
// ── Paste page ───────────────────────────────────────────────
function pasteDescription(p) {
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) {
function shareLinks(p) {
@@ -298,13 +298,13 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -298,13 +298,13 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
const body = `
const body = `
<article class="paste" itemscope itemtype="https://schema.org/${isShot ? 'ImageObject' : 'Article'}">
<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">
<header class="paste-head">
<h1 itemprop="headline">${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}${esc(title)}</h1>
<h1 itemprop="headline">${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}${esc(title)}</h1>
<p class="paste-meta">
<p class="paste-meta">
${authorHtml(p)}
${authorHtml(p)}
<span class="sep">·</span>${langBadge(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>
<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>` : ''}
${!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>` : ''}
${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">
@@ -335,15 +335,15 @@ ${related && related.length ? `<section class="section" id="related">
robots: indexable ? 'index,follow' : 'noindex,follow',
robots: indexable ? 'index,follow' : 'noindex,follow',
ogType: 'article',
ogType: 'article',
ogImage: shot || undefined,
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',
active: 'pastes',
historyType: 'paste',
historyType: 'paste',
historyTitle: title,
historyTitle: title,
footerVariant: 'compact',
footerVariant: 'compact',
jsonLd: [
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,
body,
});
});
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* (server/pulse). Filtering by origin and paging ("Older") are links, so it works without
* (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.
* 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 { renderPage } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { timeTag } = require('./pages');
const { timeTag } = require('./pages');
@@ -60,7 +60,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null }) {
@@ -60,7 +60,7 @@ function pulsePage({ items, origin = '', after = null, nextCursor = null }) {
robots: after ? 'noindex,follow' : 'index,follow',
robots: after ? 'noindex,follow' : 'index,follow',
active: 'pulse',
active: 'pulse',
feeds: [{ title: 'OpenVibe.Community — latest threads', href: '/s/feed.xml' }],
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,
body,
});
});
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* submission, and the moderators' review queue. Filters and paging are links and every action is
* 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.
* 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 { renderPage, abs } = require('./layout');
const { escapeHtml: esc } = require('./highlight');
const { escapeHtml: esc } = require('./highlight');
const { renderMarkdown, markdownToText } = require('./markdown');
const { renderMarkdown, markdownToText } = require('./markdown');
@@ -81,7 +81,7 @@ ${submitForm({ user, values, error })}`;
@@ -81,7 +81,7 @@ ${submitForm({ user, values, error })}`;
canonicalPath: listHref({ kind, after }),
canonicalPath: listHref({ kind, after }),
robots: mine || after ? 'noindex,follow' : 'index,follow',
robots: mine || after ? 'noindex,follow' : 'index,follow',
active: 'submissions',
active: 'submissions',
- jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],
+ jsonLd: [ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Submissions', url: '/submissions' }])],
body,
body,
});
});
}
}
@@ -127,7 +127,7 @@ ${reviewForm}`;
@@ -127,7 +127,7 @@ ${reviewForm}`;
active: 'submissions',
active: 'submissions',
published: s.created_at,
published: s.created_at,
modified: s.updated_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,
body,
});
});
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,253 +0,0 @@
@@ -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 };
@@ -17,12 +17,13 @@ const express = require('express');
@@ -17,12 +17,13 @@ const express = require('express');
const pages = require('../render/pages');
const pages = require('../render/pages');
const { submissionsPage, submissionPage, reviewQueuePage } = require('../render/submissions');
const { submissionsPage, submissionPage, reviewQueuePage } = require('../render/submissions');
const { ApiError } = require('../http/v1');
const { ApiError } = require('../http/v1');
+const cache = require('openvibe-shared/cache-policy');
function createSubmissionPages({ submissions, viewers, config }) {
function createSubmissionPages({ submissions, viewers, config }) {
const router = express.Router();
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '64kb' });
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 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)}`);
const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 <b></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();
+})();
@@ -165,7 +165,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -165,7 +165,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
const feed = await t.get('/s/feed.xml');
const feed = await t.get('/s/feed.xml');
assert.ok(!feed.text.includes('Members only chat'));
assert.ok(!feed.text.includes('Members only chat'));
assert.strictEqual((await t.get('/s/insiders/feed.xml')).status, 404);
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');
const map = await t.get('/sitemap.xml');
assert.ok(!map.text.includes('members-only-chat'));
assert.ok(!map.text.includes('members-only-chat'));
});
});
@@ -180,7 +180,7 @@ const form = (obj) => ({ method: 'POST', body: new URLSearchParams(obj).toString
@@ -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 () => {
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');
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>`);
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');
const feed = await t.get('/s/feed.xml');
@@ -1,6 +1,8 @@
@@ -1,6 +1,8 @@
'use strict';
'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 assert = require('assert');
+const cache = require('openvibe-shared/cache-policy');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
@@ -19,8 +21,48 @@ const { boot, check, done } = require('./helpers/app');
@@ -19,8 +21,48 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.headers.get('content-type').startsWith('text/plain'));
assert.ok(r.text.includes('User-agent: *\nAllow: /'));
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.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 () => {
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');
@@ -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('<lastmod>2026-09-15</lastmod>'));
assert.ok(r.text.includes('/p/fair-moon-23'));
assert.ok(r.text.includes('/p/fair-moon-23'));
assert.ok(!r.text.includes('dark-owl-55') && !r.text.includes('grim-vale-61'));
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 () => {
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');
@@ -41,7 +84,50 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('<rss version="2.0"'));
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('<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 () => {
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');
@@ -49,9 +135,9 @@ const { boot, check, done } = require('./helpers/app');
const m = home.text.match(/\/css\/community\.css\?v=([0-9a-f]{10})/);
const m = home.text.match(/\/css\/community\.css\?v=([0-9a-f]{10})/);
assert.ok(m, 'hashed css url in page');
assert.ok(m, 'hashed css url in page');
const hashed = await t.get(`/css/community.css?v=${m[1]}`);
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');
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');
const fav = await t.get('/favicon.svg');
assert.strictEqual(fav.status, 200);
assert.strictEqual(fav.status, 200);
const og = await t.get('/og-default.png');
const og = await t.get('/og-default.png');